feat(session-query): add model-facing tools (round 1)

This commit is contained in:
Hypatia May
2026-07-24 15:09:55 +08:00
parent b92235c490
commit a350e95165
40 changed files with 2781 additions and 13 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316
architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee
architecture.md: b362a4cb97aa04223805ce7f2892f9fe9b4104cf
architecture.zh.md: 3791cb619448b70005d42ea98f11869082e943fa

View File

@@ -43,7 +43,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` interface: concrete live-preferred exact/filter/trace; exactly two abstract FTS methods via `session-query-sqlite` |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | Live-preferred exact/filter/trace interface, SQLite FTS backend, and workspace-authorized model tools |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks plus one optional asynchronous provider |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry for package-owned runtime checks |

View File

@@ -43,7 +43,7 @@
| `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 |
| `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 |
| `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` 接口:精确检索过滤追踪采用实时优先的具体实现;恰有两个全文搜索方法为抽象方法,由 `session-query-sqlite` 实现 |
| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索过滤追踪接口、SQLite 全文搜索后端,以及经工作区授权的模型工具 |
| `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题,以及单个可选的异步提供方 |
| `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 |

View File

@@ -38,6 +38,7 @@ flowchart LR
pkg_acp["acp"]
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
pkg_session_reference["session-reference"]
pkg_tool_session_query["tool-session-query"]
svc_sessionReferences["ctx.sessionReferences<br/>Cross-session snapshot preparation"]
pkg_tui["tui"]
pkg_session_title["session-title"]
@@ -223,6 +224,7 @@ flowchart LR
svc_sessionPersistence --> pkg_session_query_sqlite
svc_sessionPersistence --> pkg_tool_bash
svc_sessionQuery --> pkg_session_reference
svc_sessionQuery --> pkg_tool_session_query
svc_sessionReferences --> pkg_acp
svc_sessionReferences --> pkg_tui
svc_sessions --> pkg_agent
@@ -276,7 +278,7 @@ flowchart LR
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. |
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. |
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
| `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. |

View File

@@ -1423,6 +1423,22 @@ export interface Config {
Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts)
## `@deepseek-ai/dsh-tool-session-query`
Requires: `tools` · `systemPrompt` · `sessionQuery`
```ts config-catalog
/** Deployment-owned search count and timeout bounds. */
export interface Config {
/** Maximum authorized hits returned by one search call. Defaults to 100. */
maxSearchResults?: number
/** Cooperative full-text search deadline in milliseconds. Defaults to 30000. */
searchTimeoutMs?: number
}
```
Source: [`packages/session-query/tool-session-query/src/index.ts:50`](../packages/session-query/tool-session-query/src/index.ts)
## `@deepseek-ai/dsh-tool-skill`
Requires: `tools` · `skills`

View File

@@ -106,6 +106,7 @@ flowchart TD
subgraph group_session_query["packages/session-query"]
pkg_session_query["session-query"]
pkg_session_query_sqlite["session-query-sqlite"]
pkg_tool_session_query["tool-session-query"]
end
subgraph group_session_title["packages/session-title"]
pkg_session_title["session-title"]
@@ -570,6 +571,13 @@ flowchart TD
pkg_session_checkpoint_policy --> pkg_session
pkg_session_checkpoint_policy --> pkg_session_persistence
pkg_session_checkpoint_policy --> pkg_tools
pkg_tool_session_query --> pkg_invariants
pkg_tool_session_query --> pkg_llm
pkg_tool_session_query --> pkg_session
pkg_tool_session_query --> pkg_session_query
pkg_tool_session_query --> pkg_system_prompt
pkg_tool_session_query --> pkg_timeout
pkg_tool_session_query --> pkg_tools
pkg_agent_loop_testkit --> pkg_agent
pkg_agent_loop_testkit --> pkg_invariants
pkg_agent_loop_testkit --> pkg_llm
@@ -883,6 +891,7 @@ flowchart TD
| [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) |
| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |

View File

@@ -27,6 +27,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - |
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy. |
| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. |
| `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. |
| `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. |
@@ -778,6 +779,239 @@ Load the full instructions for an available skill. Call this with the exact skil
Source: [`packages/skill/tool-skill/src/index.ts`](../packages/skill/tool-skill/src/index.ts)
## `@deepseek-ai/dsh-tool-session-query`
### `session_event_read`
Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.
```json
{
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
},
"seq": {
"type": "integer",
"description": "Target event sequence number."
},
"before": {
"type": "integer",
"description": "Number of preceding raw events to summarize. Omit for none."
},
"after": {
"type": "integer",
"description": "Number of following raw events to summarize. Omit for none."
}
},
"required": [
"seq"
]
}
```
Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts)
### `session_event_search`
Search prior events in one authorized session; the current session excludes the step performing this call.
```json
{
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
},
"query": {
"type": "string",
"description": "Literal full-text query over the target session."
},
"seq_from": {
"type": "integer",
"description": "Inclusive event sequence lower bound."
},
"seq_to": {
"type": "integer",
"description": "Inclusive event sequence upper bound."
},
"time_from": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time lower bound."
},
"time_to": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time upper bound."
},
"event_types": {
"type": "array",
"description": "Event types to include.",
"items": {
"type": "string"
}
},
"surfaces": {
"type": "array",
"description": "Event surfaces to include.",
"items": {
"type": "string",
"enum": [
"current",
"shadowed",
"log-only"
]
}
}
},
"required": [
"query"
]
}
```
Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts)
### `session_event_trace`
Read every direct replacement and provenance relationship for one event in an authorized session.
```json
{
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
},
"seq": {
"type": "integer",
"description": "Target event sequence number."
}
},
"required": [
"seq"
]
}
```
Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts)
### `session_search`
Search prior sessions in the caller workspace and return the strongest matching event from each session.
```json
{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Literal full-text query over prior session history."
},
"session_ids": {
"type": "array",
"description": "Optional session ids to include.",
"items": {
"type": "string"
}
},
"created_at_from": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound."
},
"created_at_to": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound."
},
"parent_session_ids": {
"type": "array",
"description": "Optional direct parent session ids.",
"items": {
"type": "string"
}
},
"include_root_sessions": {
"type": "boolean",
"description": "Include sessions with no parent in the parent filter."
},
"availability": {
"type": "array",
"description": "Require at least one selected source availability.",
"items": {
"type": "string",
"enum": [
"live",
"persisted"
]
}
},
"event_seq_from": {
"type": "integer",
"description": "Inclusive event sequence lower bound."
},
"event_seq_to": {
"type": "integer",
"description": "Inclusive event sequence upper bound."
},
"event_time_from": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time lower bound."
},
"event_time_to": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time upper bound."
},
"event_types": {
"type": "array",
"description": "Event types to include.",
"items": {
"type": "string"
}
},
"event_surfaces": {
"type": "array",
"description": "Event surfaces to include.",
"items": {
"type": "string",
"enum": [
"current",
"shadowed",
"log-only"
]
}
}
},
"required": [
"query"
]
}
```
Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts)
### `session_trace`
Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.
```json
{
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
}
}
}
```
Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts)
The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy.
## `@deepseek-ai/dsh-tool-subagent`
### `subagent`

View File

@@ -29,6 +29,14 @@ flowchart LR
bundle_agent_core --> spine_sessions["ctx.sessions"]
bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
plugin_acp_tool_session_query["tool-session-query<br/>@deepseek-ai/dsh-tool-session-query"]
cfg --> plugin_acp_tool_session_query
plugin_acp_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-timeout-policy"]
cfg --> plugin_acp_timeout_policy
plugin_acp_spill_local["spill-local<br/>@deepseek-ai/dsh-spill-local"]
cfg --> plugin_acp_spill_local
plugin_acp_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"]
cfg --> plugin_acp_spill_policy
plugin_acp_plan_mode["plan-mode<br/>@deepseek-ai/dsh-plan-mode"]
cfg --> plugin_acp_plan_mode
plugin_acp_tool_ask_user["tool-ask-user<br/>@deepseek-ai/dsh-tool-ask-user"]
@@ -78,6 +86,10 @@ flowchart LR
| `approval` | `@deepseek-ai/dsh-user-approval` |
| `permission` | `@deepseek-ai/dsh-permission` |
| `acp-agent` | `@deepseek-ai/dsh-acp-demo` |
| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` |
| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` |
| `spill-local` | `@deepseek-ai/dsh-spill-local` |
| `spill-policy` | `@deepseek-ai/dsh-spill-policy` |
| `plan-mode` | `@deepseek-ai/dsh-plan-mode` |
| `tool-ask-user` | `@deepseek-ai/dsh-tool-ask-user` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |

View File

@@ -65,6 +65,24 @@
Verify your work by running the code or tests. Keep answers brief and factual.
# Workspace-authorized prior-session search and exact trace/read tools. The app
# above owns ctx.sessionQuery; this leaf owns the model-facing consumer.
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
# Enforce declared search deadlines and spill oversized plain-text tool output
# without introducing a session-query-specific truncation path.
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
# Plan mode is additive to the canonical ACP server. The ACP bridge projects
# it onto the protocol picker; sandbox and approval remain independent options.
- id: plan-mode

View File

@@ -100,6 +100,13 @@ const SCENARIOS: Scenario[] = [
configPath: FS_CONFIG,
},
{ name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
{
name: 'session-query-spill',
hasModelTurn: true,
recorded: false,
configPath: FS_CONFIG,
posixOnly: true,
},
{
name: 'pty-tools',
hasModelTurn: true,

View File

@@ -0,0 +1,7 @@
{
"steps": [
{ "op": "initialize" },
{ "op": "newSession" },
{ "op": "prompt", "text": "Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE." }
]
}

View File

@@ -0,0 +1,34 @@
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"}
{"type":"session/title","seq":2,"time":0,"data":{"title":"Read request event 4 with","messageSeqs":[1],"source":{"kind":"fallback"}}}
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":4}"}}}
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}}}}
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}}
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}}
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}
{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","content":[{"type":"text","text":"SPILL_CANONICAL_OK\n"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"}
{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}}
{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}}
{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}
{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}}
{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"}
{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}}
{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}

View File

@@ -0,0 +1,10 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[<objective>|clear|edit <objective>|pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Read request event 4 with","updatedAt":"{{updatedAt}}"}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_query_spill","title":"Read event 4","kind":"read","status":"in_progress","rawInput":{"seq":4}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK","kind":"execute","status":"in_progress","rawInput":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK","content":[{"type":"content","content":{"type":"text","text":"Verify complete session query spill"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_verify_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_CANONICAL_OK\n```"}}]}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor
Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.
Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).

View File

@@ -238,6 +238,210 @@
]
}
},
{
"name": "session_event_read",
"description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.",
"parameters": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
},
"seq": {
"type": "integer",
"description": "Target event sequence number."
},
"before": {
"type": "integer",
"description": "Number of preceding raw events to summarize. Omit for none."
},
"after": {
"type": "integer",
"description": "Number of following raw events to summarize. Omit for none."
}
},
"required": [
"seq"
]
}
},
{
"name": "session_event_search",
"description": "Search prior events in one authorized session; the current session excludes the step performing this call.",
"parameters": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
},
"query": {
"type": "string",
"description": "Literal full-text query over the target session."
},
"seq_from": {
"type": "integer",
"description": "Inclusive event sequence lower bound."
},
"seq_to": {
"type": "integer",
"description": "Inclusive event sequence upper bound."
},
"time_from": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time lower bound."
},
"time_to": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time upper bound."
},
"event_types": {
"type": "array",
"description": "Event types to include.",
"items": {
"type": "string"
}
},
"surfaces": {
"type": "array",
"description": "Event surfaces to include.",
"items": {
"type": "string",
"enum": [
"current",
"shadowed",
"log-only"
]
}
}
},
"required": [
"query"
]
}
},
{
"name": "session_event_trace",
"description": "Read every direct replacement and provenance relationship for one event in an authorized session.",
"parameters": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
},
"seq": {
"type": "integer",
"description": "Target event sequence number."
}
},
"required": [
"seq"
]
}
},
{
"name": "session_search",
"description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Literal full-text query over prior session history."
},
"session_ids": {
"type": "array",
"description": "Optional session ids to include.",
"items": {
"type": "string"
}
},
"created_at_from": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound."
},
"created_at_to": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound."
},
"parent_session_ids": {
"type": "array",
"description": "Optional direct parent session ids.",
"items": {
"type": "string"
}
},
"include_root_sessions": {
"type": "boolean",
"description": "Include sessions with no parent in the parent filter."
},
"availability": {
"type": "array",
"description": "Require at least one selected source availability.",
"items": {
"type": "string",
"enum": [
"live",
"persisted"
]
}
},
"event_seq_from": {
"type": "integer",
"description": "Inclusive event sequence lower bound."
},
"event_seq_to": {
"type": "integer",
"description": "Inclusive event sequence upper bound."
},
"event_time_from": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time lower bound."
},
"event_time_to": {
"type": "string",
"description": "Inclusive timezone-qualified ISO 8601 event-time upper bound."
},
"event_types": {
"type": "array",
"description": "Event types to include.",
"items": {
"type": "string"
}
},
"event_surfaces": {
"type": "array",
"description": "Event surfaces to include.",
"items": {
"type": "string",
"enum": [
"current",
"shadowed",
"log-only"
]
}
}
},
"required": [
"query"
]
}
},
{
"name": "session_trace",
"description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.",
"parameters": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Target session id. Omit for the current session."
}
}
}
},
{
"name": "skill",
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",

View File

@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-tool-goal": "workspace:*",
"@deepseek-ai/dsh-tool-lsp": "workspace:*",
"@deepseek-ai/dsh-tool-ralph": "workspace:*",
"@deepseek-ai/dsh-tool-session-query": "workspace:*",
"@deepseek-ai/dsh-tool-subagent": "workspace:*",
"@deepseek-ai/dsh-tool-todo": "workspace:*",
"@deepseek-ai/dsh-tool-workflow": "workspace:*",

View File

@@ -23,6 +23,8 @@ flowchart LR
bundle_agent_core --> spine_sessions["ctx.sessions"]
bundle_agent_core --> spine_tools["ctx.tools + tool-bash"]
bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"]
plugin_tui_tool_session_query["tool-session-query<br/>@deepseek-ai/dsh-tool-session-query"]
cfg --> plugin_tui_tool_session_query
plugin_tui_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"]
cfg --> plugin_tui_session_title_llm
plugin_tui_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"]
@@ -71,6 +73,7 @@ flowchart LR
| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
| `bash` | `@deepseek-ai/dsh-bash-local` |
| `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` |
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
| `token-meter` | `@deepseek-ai/dsh-token-meter` |
| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` |

View File

@@ -7,6 +7,7 @@
- id: hmr
name: '@cordisjs/plugin-hmr'
disabled: !!js process.env.CI === 'true'
config:
root: ['.']
@@ -52,6 +53,11 @@
Verify your work by running the code or tests. Keep answers brief and
factual.
# The app above owns ctx.sessionQuery; expose its workspace-authorized
# prior-session search and exact trace/read operations to the model.
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
# Model-made session titles on the first-message cadence: replaces the spine's
# deterministic fallback title with a short model summary. The TUI renders the
# logged `session/title` as the banner subtitle and the terminal window title.

View File

@@ -15,7 +15,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots; the default leaf adds the model-facing query tools |
| `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |

View File

@@ -70,6 +70,7 @@
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",

View File

@@ -29,8 +29,9 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Repo root is four levels up from packages/examples/acp-demo/tests.
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
// A minimal leaf that loads this app + the two backends the same shape as
// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
// A minimal leaf that loads this app + the two backends and the shipped
// session-query consumer/policies — the same shape as examples/acp-agent/cordis.yml,
// inlined so the package test owns its fixture.
const CORDIS_YML = `
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
@@ -45,6 +46,16 @@ const CORDIS_YML = `
model: deepseek-v4-flash
persona: 'You are a test agent.'
workspaceContext: false
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
`
interface Spawned {

View File

@@ -13,7 +13,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; the default leaf adds the model-facing query tools |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |

View File

@@ -41,6 +41,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
@@ -58,6 +59,7 @@
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-session-query": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",

View File

@@ -5,6 +5,7 @@
*/
import { Context } from 'cordis'
import { join } from 'node:path'
import Timer from '@cordisjs/plugin-timer'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
@@ -18,6 +19,8 @@ import TaskService from '@deepseek-ai/dsh-tasks'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import * as toolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import * as toolTodo from '@deepseek-ai/dsh-tool-todo'
@@ -61,7 +64,7 @@ const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = {
/** Options for bootHost — the assembly-layer composition knobs. */
export interface BootHostOptions {
/** Root directory for JSONL session persistence. */
/** Root for JSONL persistence and parent directory of the derived session-query SQLite index. */
persistenceRoot: string
/** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */
workspaceContext: workspaceContext.Config | false
@@ -131,6 +134,8 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LlmDeepSeek, {})
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot })
await ctx.plugin(SessionQuerySqlite, { path: join(options.persistenceRoot, 'session-query.db') })
await ctx.plugin(toolSessionQuery, {})
await ctx.plugin(LocalBashExecutor, {})
// Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml +
// the agent-spine bundle) so web sessions get the same coding-agent tool

View File

@@ -141,6 +141,24 @@ describe('bootHost / startHost', () => {
await handle.dispose()
})
it('assembles workspace-authorized session query tools over the derived SQLite index', async () => {
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-boot-session-query-'))
const handle = await bootHost({
persistenceRoot,
workspaceContext: false,
})
expect(handle.ctx.get('sessionQuery')).toBeDefined()
expect(handle.ctx.tools.schemas().map(schema => schema.name)).toEqual(expect.arrayContaining([
'session_search',
'session_event_search',
'session_trace',
'session_event_trace',
'session_event_read',
]))
expect(handle.ctx.tools.get('session_search')?.timeoutMs).toBe(30_000)
await handle.dispose()
})
it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
const running = await boot()
expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })

View File

@@ -47,6 +47,12 @@
{
"path": "../../session-persistence/session-persistence-jsonl"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../../session-query/tool-session-query"
},
{
"path": "../../bash/bash-local"
},

View File

@@ -6,5 +6,6 @@ Trusted exact reads, relationship traces, provider-independent semantic filterin
|---|---|---|
| [`session-query/`](session-query/README.md) | Combined service contract with concrete logical-corpus reads, traces, and semantic filters plus abstract full-text methods | `ctx.sessionQuery` |
| [`session-query-sqlite/`](session-query-sqlite/README.md) | Concrete service backend with SQLite FTS5 persistent bases and live overlays | `ctx.sessionQuery` |
| [`tool-session-query/`](tool-session-query/README.md) | Workspace-authorized model-facing search, lineage, relationship, and exact event tools | — |
The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, and one concrete backend owns the full-text lifecycle without a provider registry or coordinator.
The query service is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, one concrete backend owns the full-text lifecycle without a provider registry or coordinator, and the consumer leaves oversized plain-text results to the generic post-execute spill policy.

View File

@@ -0,0 +1,72 @@
# @deepseek-ai/dsh-tool-session-query
Workspace-authorized model tools over `ctx.sessionQuery`. The package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`.
## Configuration
| Key | Default | Meaning |
|---|---:|---|
| `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages |
| `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools |
The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters.
`session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id.
The package deliberately performs no byte or character truncation and does not import a spill backend. Deployments that need bounded inline output mount `@deepseek-ai/dsh-spill-policy`, which can replace the rendered text after execution while retaining the complete result.
## Model Experience
### System prompt
#### What the model sees
The model receives one fixed prior-history guidance section.
##### Prior-history guidance
```markdown
Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.
```
#### Token effect
One fixed concise section is present on each request while the plugin is mounted.
#### KV Cache effect
Prefix-stable while the plugin and guidance text are unchanged.
### Tool schemas
#### What the model sees
The model sees the generated [`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query). Search filters add fixed schema tokens, while cursors, workspace paths, output pagination, and model-controlled result limits remain absent.
#### Token effect
Five fixed read-only schemas are sent on each request while visible.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged.
### Tool results
#### What the model sees
Each successful call emits one plain-text block. Search results include titles and best-match excerpts; traces include all authorized relationships; event reads include unabridged target JSON. The generic spill policy may replace oversized inline text with its preview, opaque locator, and retrieval hint.
#### Token effect
Results are data-dependent and remain in logged tool history until compaction; `maxSearchResults` bounds search-hit count.
#### KV Cache effect
Append-only result text follows the reusable request prefix and does not invalidate earlier cache entries.
## Known Limitations and Deferred Work
- Search returns at most the deployment cap and asks the model to narrow its query when more matches exist; it offers no continuation token.
- Workspace identity is conservative exact-string `cwd` equality, so symlink-equivalent paths do not share authority.
- Custom compositions without the generic spill policy accept complete trace and event payloads inline.

View File

@@ -0,0 +1,57 @@
{
"name": "@deepseek-ai/dsh-tool-session-query",
"description": "Workspace-authorized model-facing session history search, trace, and event read tools",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,961 @@
/**
* Model-facing, workspace-authorized session-history search and read tools.
*
* @module @deepseek-ai/dsh-tool-session-query
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
SessionId,
type SessionEvent,
type SessionEventType,
type SessionHeader,
type SessionId as SessionIdValue,
} from '@deepseek-ai/dsh-session'
import {
SessionQueryError,
extractSessionEventText,
type SessionAvailability,
type SessionEventMetadataFilter,
type SessionEventSearchHit,
type SessionEventSurface,
type SessionEventTrace,
type SessionEventWindow,
type SessionLineageNode,
type SessionLineageTrace,
type SessionRecord,
type SessionResultFilter,
type SessionSearchCursor,
type SessionSearchHit,
} from '@deepseek-ai/dsh-session-query'
import { defineTool, type GenericCallView, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
/** Cordis plugin name used by Loader diagnostics. */
export const name = 'tool-session-query'
/** Capability services required by the model-facing consumer. */
export const inject = ['tools', 'systemPrompt', 'sessionQuery']
/** Default maximum number of authorized search hits returned by one call. */
export const DEFAULT_MAX_SEARCH_RESULTS = 100
/** Default cooperative deadline for either full-text search tool. */
export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000
/** Deployment-owned search count and timeout bounds. */
export interface Config {
/** Maximum authorized hits returned by one search call. Defaults to 100. */
maxSearchResults?: number
/** Cooperative full-text search deadline in milliseconds. Defaults to 30000. */
searchTimeoutMs?: number
}
/** Schemastery config for Loader defaults and generated configuration docs. */
export const Config: z<Config> = z.object({
maxSearchResults: z.number().step(1).min(1).default(DEFAULT_MAX_SEARCH_RESULTS),
searchTimeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).default(DEFAULT_SEARCH_TIMEOUT_MS),
})
interface ResolvedConfig {
readonly maxSearchResults: number
readonly searchTimeoutMs: number
}
interface SessionSearchArgs {
query: string
session_ids?: string[]
created_at_from?: string
created_at_to?: string
parent_session_ids?: string[]
include_root_sessions?: boolean
availability?: SessionAvailability[]
event_seq_from?: number
event_seq_to?: number
event_time_from?: string
event_time_to?: string
event_types?: string[]
event_surfaces?: SessionEventSurface[]
}
interface EventSearchArgs {
session_id?: string
query: string
seq_from?: number
seq_to?: number
time_from?: string
time_to?: string
event_types?: string[]
surfaces?: SessionEventSurface[]
}
interface SessionTargetArgs {
session_id?: string
}
interface EventTargetArgs extends SessionTargetArgs {
seq: number
}
interface EventReadArgs extends EventTargetArgs {
before?: number
after?: number
}
interface Caller {
readonly id: SessionIdValue
readonly header: SessionHeader
readonly events: readonly SessionEvent[]
}
interface TitleView {
readonly text: string
readonly unavailableCode?: string
}
interface CompleteTitleMap extends ReadonlyMap<SessionIdValue, TitleView> {
get(id: SessionIdValue): TitleView
}
interface SearchCollection<T> {
readonly items: T[]
readonly capped: boolean
}
interface AuthorizedDescendant {
readonly record: SessionRecord
readonly descendants: Array<AuthorizedDescendant | null>
}
const SESSION_SEARCH_PARAMETERS = {
query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' },
session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' },
created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' },
created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' },
parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' },
include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' },
availability: {
type: 'array',
items: { type: 'string', enum: ['live', 'persisted'] },
description: 'Require at least one selected source availability.',
},
event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' },
event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' },
event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' },
event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' },
event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' },
event_surfaces: {
type: 'array',
items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] },
description: 'Event surfaces to include.',
},
} as const
const EVENT_SEARCH_PARAMETERS = {
session_id: { type: 'string', description: 'Target session id. Omit for the current session.' },
query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' },
seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' },
seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' },
time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' },
time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' },
event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' },
surfaces: {
type: 'array',
items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] },
description: 'Event surfaces to include.',
},
} as const
const TARGET_SESSION_PARAMETER = {
session_id: { type: 'string', description: 'Target session id. Omit for the current session.' },
} as const
const TEXT_OUTPUT = {
schema: { type: 'string' as const },
render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }],
}
const PROMPT_TEXT =
'Use session_search to find relevant work from prior sessions, or session_event_search to search earlier '
+ 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with '
+ 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.'
/** Register all five tools and their shared model guidance. */
export function apply(ctx: Context, config: Config): void {
const resolved = resolveConfig(config)
ctx.systemPrompt.section({
name: 'tool:session-query',
order: 113,
text: PROMPT_TEXT,
})
ctx.tools.register(defineTool({
name: 'session_search',
description: 'Search prior sessions in the caller workspace and return the strongest matching event from each session.',
parameters: SESSION_SEARCH_PARAMETERS,
output: TEXT_OUTPUT,
timeoutMs: resolved.searchTimeoutMs,
isConcurrencySafe: () => true,
execute: (args, exec) => executeSessionSearch(ctx, args, exec, resolved.maxSearchResults),
presentCall: presentSessionSearchCall,
}))
ctx.tools.register(defineTool({
name: 'session_event_search',
description: 'Search prior events in one authorized session; the current session excludes the step performing this call.',
parameters: EVENT_SEARCH_PARAMETERS,
output: TEXT_OUTPUT,
timeoutMs: resolved.searchTimeoutMs,
isConcurrencySafe: () => true,
execute: (args, exec) => executeEventSearch(ctx, args, exec, resolved.maxSearchResults),
presentCall: presentEventSearchCall,
}))
ctx.tools.register(defineTool({
name: 'session_trace',
description: 'Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.',
parameters: TARGET_SESSION_PARAMETER,
output: TEXT_OUTPUT,
isConcurrencySafe: () => true,
execute: (args, exec) => executeSessionTrace(ctx, args, exec),
presentCall: presentSessionTraceCall,
}))
ctx.tools.register(defineTool({
name: 'session_event_trace',
description: 'Read every direct replacement and provenance relationship for one event in an authorized session.',
parameters: {
...TARGET_SESSION_PARAMETER,
seq: { type: 'integer', required: true, description: 'Target event sequence number.' },
},
output: TEXT_OUTPUT,
isConcurrencySafe: () => true,
execute: (args, exec) => executeEventTrace(ctx, args, exec),
presentCall: args => presentEventTargetCall('Trace event', args),
}))
ctx.tools.register(defineTool({
name: 'session_event_read',
description: 'Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.',
parameters: {
...TARGET_SESSION_PARAMETER,
seq: { type: 'integer', required: true, description: 'Target event sequence number.' },
before: { type: 'integer', description: 'Number of preceding raw events to summarize. Omit for none.' },
after: { type: 'integer', description: 'Number of following raw events to summarize. Omit for none.' },
},
output: TEXT_OUTPUT,
isConcurrencySafe: () => true,
execute: (args, exec) => executeEventRead(ctx, args, exec),
presentCall: args => presentEventTargetCall('Read event', args),
}))
}
function resolveConfig(config: Config): ResolvedConfig {
const maxSearchResults = config.maxSearchResults ?? DEFAULT_MAX_SEARCH_RESULTS
const searchTimeoutMs = config.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS
if (!Number.isSafeInteger(maxSearchResults) || maxSearchResults < 1) {
throw new TypeError('tool-session-query: maxSearchResults must be a positive safe integer')
}
if (!Number.isInteger(searchTimeoutMs) || searchTimeoutMs < 1 || searchTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new TypeError(
`tool-session-query: searchTimeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
return { maxSearchResults, searchTimeoutMs }
}
function callerOf(exec: ToolRunContext): Caller {
const agent = exec.agent
if (agent === undefined) {
throw new HarnessError(
'session query tools require an agent-bound caller',
'SESSION_QUERY_TOOL_MISSING_AGENT',
)
}
return {
id: agent.session.id,
header: agent.session.header,
events: agent.session.events,
}
}
function targetId(args: SessionTargetArgs, caller: Caller): SessionIdValue {
return args.session_id === undefined ? caller.id : SessionId(args.session_id)
}
async function authorizeTarget(
ctx: Context,
caller: Caller,
target: SessionIdValue,
signal: AbortSignal,
): Promise<void> {
if (target === caller.id) return
const cwd = caller.header.cwd
if (cwd === undefined) throw unauthorizedTarget()
signal.throwIfAborted()
const records = await ctx.sessionQuery.filterSessions([
{ kind: 'id', values: [target] },
{ kind: 'cwd', values: [cwd] },
])
signal.throwIfAborted()
if (records.length !== 1) throw unauthorizedTarget()
}
function unauthorizedTarget(): HarnessError {
return new HarnessError(
'session target is outside the caller workspace',
'SESSION_QUERY_TOOL_UNAUTHORIZED',
)
}
async function executeSessionSearch(
ctx: Context,
args: SessionSearchArgs,
exec: ToolRunContext,
maxResults: number,
): Promise<string> {
const caller = callerOf(exec)
const cwd = caller.header.cwd
if (cwd === undefined) {
throw new HarnessError(
'cross-session search is unavailable because the caller session has no workspace',
'SESSION_QUERY_TOOL_UNAUTHORIZED',
)
}
const query = normalizeQuery(args.query)
const sessionFilters = buildSessionFilters(args)
sessionFilters.push({ kind: 'cwd', values: [cwd] })
const eventFilters = buildEventFilters({
seqFrom: args.event_seq_from,
seqTo: args.event_seq_to,
timeFrom: args.event_time_from,
timeTo: args.event_time_to,
eventTypes: args.event_types,
surfaces: args.event_surfaces,
})
const collected = await collectPages(
maxResults,
exec.signal,
cursor => ctx.sessionQuery.searchSessions({
query,
sessionFilters,
eventFilters,
...cursor === undefined ? {} : { cursor },
}, { signal: exec.signal }),
hit => hit.header.id !== caller.id && recordAuthorized(hit, caller),
)
const parentIds = collected.items
.map(hit => hit.header.parentSession)
.filter((id): id is SessionIdValue => id !== undefined)
const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal)
const titles = await readTitles(ctx, collected.items.map(hit => hit.header.id), exec.signal)
return formatSessionSearch(collected, titles, authorizedParents)
}
async function executeEventSearch(
ctx: Context,
args: EventSearchArgs,
exec: ToolRunContext,
maxResults: number,
): Promise<string> {
const caller = callerOf(exec)
const sessionId = targetId(args, caller)
await authorizeTarget(ctx, caller, sessionId, exec.signal)
const query = normalizeQuery(args.query)
const range = sequenceRange(args.seq_from, args.seq_to)
if (sessionId === caller.id) {
const stepStart = caller.events.findLast(event => event.type === 'step/start')
if (stepStart === undefined) {
throw new HarnessError(
'current-session search requires an active step boundary',
'SESSION_QUERY_TOOL_NO_CURRENT_STEP',
)
}
range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1)
}
const title = await readTitle(ctx, sessionId, exec.signal)
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
return formatEventSearch(sessionId, title, { items: [], capped: false })
}
const filters = buildEventFilters({
seqFrom: range.from,
seqTo: range.to,
timeFrom: args.time_from,
timeTo: args.time_to,
eventTypes: args.event_types,
surfaces: args.surfaces,
})
const collected = await collectPages(
maxResults,
exec.signal,
cursor => ctx.sessionQuery.searchEvents({
sessionId,
query,
filters,
...cursor === undefined ? {} : { cursor },
}, { signal: exec.signal }),
() => true,
)
return formatEventSearch(sessionId, title, collected)
}
async function executeSessionTrace(
ctx: Context,
args: SessionTargetArgs,
exec: ToolRunContext,
): Promise<string> {
const caller = callerOf(exec)
const sessionId = targetId(args, caller)
await authorizeTarget(ctx, caller, sessionId, exec.signal)
const trace = await ctx.sessionQuery.traceSession(sessionId)
exec.signal.throwIfAborted()
const ancestors: SessionRecord[] = []
let ancestorBoundary = false
for (const ancestor of trace.ancestors) {
if (!recordAuthorized(ancestor, caller)) {
ancestorBoundary = true
break
}
ancestors.push(ancestor)
}
if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true
const descendants = authorizeDescendants(trace.descendants, caller)
const visibleIds = [
trace.target.header.id,
...ancestors.map(record => record.header.id),
...descendantIds(descendants),
]
const titles = await readTitles(ctx, visibleIds, exec.signal)
return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles)
}
async function executeEventTrace(
ctx: Context,
args: EventTargetArgs,
exec: ToolRunContext,
): Promise<string> {
assertNonNegativeSafeInteger('seq', args.seq)
const caller = callerOf(exec)
const sessionId = targetId(args, caller)
await authorizeTarget(ctx, caller, sessionId, exec.signal)
const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq })
exec.signal.throwIfAborted()
const title = await readTitle(ctx, sessionId, exec.signal)
return formatEventTrace(sessionId, title, trace)
}
async function executeEventRead(
ctx: Context,
args: EventReadArgs,
exec: ToolRunContext,
): Promise<string> {
assertNonNegativeSafeInteger('seq', args.seq)
if (args.before !== undefined) assertNonNegativeSafeInteger('before', args.before)
if (args.after !== undefined) assertNonNegativeSafeInteger('after', args.after)
const caller = callerOf(exec)
const sessionId = targetId(args, caller)
await authorizeTarget(ctx, caller, sessionId, exec.signal)
const window = await ctx.sessionQuery.readEvent({
sessionId,
seq: args.seq,
...args.before === undefined ? {} : { before: args.before },
...args.after === undefined ? {} : { after: args.after },
})
exec.signal.throwIfAborted()
const title = await readTitle(ctx, sessionId, exec.signal)
return formatEventRead(sessionId, title, window)
}
function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] {
const filters: SessionResultFilter[] = []
if (args.session_ids !== undefined) {
assertNonEmptyArray('session_ids', args.session_ids)
filters.push({ kind: 'id', values: args.session_ids.map(SessionId) })
}
const created = timestampRange('created_at', args.created_at_from, args.created_at_to)
if (created !== undefined) filters.push({ kind: 'created-at', ...created })
if (args.parent_session_ids !== undefined || args.include_root_sessions === true) {
const values: Array<SessionIdValue | null> = []
if (args.parent_session_ids !== undefined) {
assertNonEmptyArray('parent_session_ids', args.parent_session_ids)
values.push(...args.parent_session_ids.map(SessionId))
}
if (args.include_root_sessions === true) values.push(null)
filters.push({ kind: 'parent', values })
}
if (args.availability !== undefined) {
assertNonEmptyArray('availability', args.availability)
filters.push({ kind: 'availability', values: args.availability })
}
return filters
}
interface EventFilterInput {
readonly seqFrom?: number | undefined
readonly seqTo?: number | undefined
readonly timeFrom?: string | undefined
readonly timeTo?: string | undefined
readonly eventTypes?: string[] | undefined
readonly surfaces?: SessionEventSurface[] | undefined
}
function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] {
const filters: SessionEventMetadataFilter[] = []
const seq = sequenceRange(input.seqFrom, input.seqTo)
if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq })
const time = timestampRange('time', input.timeFrom, input.timeTo)
if (time !== undefined) filters.push({ kind: 'time', ...time })
if (input.eventTypes !== undefined) {
assertNonEmptyArray('event_types', input.eventTypes)
filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] })
}
if (input.surfaces !== undefined) {
assertNonEmptyArray('surfaces', input.surfaces)
filters.push({ kind: 'surface', values: input.surfaces })
}
return filters
}
function normalizeQuery(value: string): string {
const query = value.trim().replace(/\s+/gu, ' ')
if (query.length === 0) {
throw new SessionQueryError(
'session-search query must contain non-whitespace text',
'SESSION_QUERY_INVALID_QUERY',
)
}
if (query.includes('\0')) {
throw new SessionQueryError(
'session-search query must not contain NUL',
'SESSION_QUERY_INVALID_QUERY',
)
}
return query
}
function sequenceRange(
from: number | undefined,
to: number | undefined,
): { from?: number; to?: number } {
if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from)
if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to)
if (from !== undefined && to !== undefined && from > to) {
throw invalidRange('sequence', 'from must be less than or equal to to')
}
return {
...from === undefined ? {} : { from },
...to === undefined ? {} : { to },
}
}
function timestampRange(
name: string,
from: string | undefined,
to: string | undefined,
): { from?: number; to?: number } | undefined {
if (from === undefined && to === undefined) return undefined
const fromMs = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from)
const toMs = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to)
if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) {
throw invalidRange(name, 'from must be less than or equal to to')
}
return {
...fromMs === undefined ? {} : { from: fromMs },
...toMs === undefined ? {} : { to: toMs },
}
}
const ISO_TIMESTAMP =
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/
function parseIsoTimestamp(name: string, value: string): number {
const match = ISO_TIMESTAMP.exec(value)
if (match === null) {
throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset')
}
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const hour = Number(match[4])
const minute = Number(match[5])
const second = Number(match[6] ?? 0)
const offsetHour = Number(match[10] ?? 0)
const offsetMinute = Number(match[11] ?? 0)
if (
month < 1 || month > 12
|| day < 1 || day > daysInMonth(year, month)
|| hour > 23 || minute > 59 || second > 59
|| offsetHour > 23 || offsetMinute > 59
) {
throw invalidRange(name, 'must be a valid ISO 8601 timestamp')
}
const timestamp = Date.parse(value)
return timestamp
}
function daysInMonth(year: number, month: number): number {
if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28
return [4, 6, 9, 11].includes(month) ? 30 : 31
}
function invalidRange(name: string, detail: string): SessionQueryError {
return new SessionQueryError(
`session ${name} range ${detail}`,
'SESSION_QUERY_INVALID_FILTER',
)
}
function assertNonNegativeSafeInteger(name: string, value: number): void {
if (!Number.isSafeInteger(value) || value < 0) {
throw new SessionQueryError(
`${name} must be a non-negative safe integer`,
'SESSION_QUERY_INVALID_FILTER',
)
}
}
function assertNonEmptyArray(name: string, values: readonly unknown[]): void {
if (values.length === 0) {
throw new SessionQueryError(
`${name} must contain at least one value when supplied`,
'SESSION_QUERY_INVALID_FILTER',
)
}
}
async function collectPages<T>(
maxResults: number,
signal: AbortSignal,
request: (cursor?: SessionSearchCursor) => Promise<{
readonly items: readonly T[]
readonly nextCursor?: SessionSearchCursor
}>,
accept: (item: T) => boolean,
): Promise<SearchCollection<T>> {
const items: T[] = []
const seen = new Set<SessionSearchCursor>()
let cursor: SessionSearchCursor | undefined
while (true) {
signal.throwIfAborted()
let page: Awaited<ReturnType<typeof request>>
try {
page = await request(cursor)
} catch (error: unknown) {
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_STALE_CURSOR') {
throw new SessionQueryError(
'session history changed while paging; retry the complete search call',
'SESSION_QUERY_STALE_CURSOR',
{ cause: error },
)
}
throw error
}
signal.throwIfAborted()
for (const item of page.items) {
if (!accept(item)) continue
items.push(item)
if (items.length === maxResults) {
return {
items,
capped: page.nextCursor !== undefined || item !== page.items.at(-1),
}
}
}
if (page.nextCursor === undefined) return { items, capped: false }
if (seen.has(page.nextCursor)) {
throw new SessionQueryError(
'session-search provider repeated a continuation cursor',
'SESSION_QUERY_INVALID_CURSOR',
)
}
seen.add(page.nextCursor)
cursor = page.nextCursor
}
}
function recordAuthorized(record: SessionRecord, caller: Caller): boolean {
if (record.header.id === caller.id) return true
return caller.header.cwd !== undefined && record.header.cwd === caller.header.cwd
}
async function authorizeSessionIds(
ctx: Context,
caller: Caller,
ids: readonly SessionIdValue[],
signal: AbortSignal,
): Promise<ReadonlySet<SessionIdValue>> {
const unique = [...new Set(ids)]
const authorized = new Set<SessionIdValue>()
if (unique.includes(caller.id)) authorized.add(caller.id)
const cwd = caller.header.cwd
const other = unique.filter(id => id !== caller.id)
if (cwd === undefined || other.length === 0) return authorized
signal.throwIfAborted()
const records = await ctx.sessionQuery.filterSessions([
{ kind: 'id', values: other },
{ kind: 'cwd', values: [cwd] },
])
signal.throwIfAborted()
for (const record of records) authorized.add(record.header.id)
return authorized
}
async function readTitles(
ctx: Context,
ids: readonly SessionIdValue[],
signal: AbortSignal,
): Promise<CompleteTitleMap> {
const result = new Map<SessionIdValue, TitleView>()
for (const id of new Set(ids)) {
result.set(id, await readTitle(ctx, id, signal))
}
return result as CompleteTitleMap
}
async function readTitle(
ctx: Context,
id: SessionIdValue,
signal: AbortSignal,
): Promise<TitleView> {
signal.throwIfAborted()
try {
const title = await ctx.sessionQuery.readTitle(id)
signal.throwIfAborted()
return { text: title?.title ?? 'untitled' }
} catch (error: unknown) {
if (signal.aborted) signal.throwIfAborted()
const code = error instanceof HarnessError ? error.code : 'UNKNOWN'
ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`)
return { text: 'untitled', unavailableCode: code }
}
}
function fullError(error: unknown): string {
return error instanceof Error ? error.stack ?? String(error) : String(error)
}
function authorizeDescendants(
nodes: readonly SessionLineageNode[],
caller: Caller,
): Array<AuthorizedDescendant | null> {
return nodes.map((node) => {
if (!recordAuthorized(node.session, caller)) return null
return {
record: node.session,
descendants: authorizeDescendants(node.descendants, caller),
}
})
}
function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] {
const ids: SessionIdValue[] = []
for (const node of nodes) {
if (node === null) continue
ids.push(node.record.header.id, ...descendantIds(node.descendants))
}
return ids
}
function titleText(view: TitleView): string {
return view.unavailableCode === undefined
? view.text
: `${view.text} (title unavailable: ${view.unavailableCode})`
}
function formatSessionSearch(
collected: SearchCollection<SessionSearchHit>,
titles: CompleteTitleMap,
authorizedParents: ReadonlySet<SessionIdValue>,
): string {
if (collected.items.length === 0) return 'No prior session matches found.'
const lines = [`Session search results (${collected.items.length}):`]
for (const [index, hit] of collected.items.entries()) {
const parent = hit.header.parentSession === undefined
? 'root'
: authorizedParents.has(hit.header.parentSession)
? hit.header.parentSession
: '[outside workspace]'
const availability = [
hit.live ? 'live' : undefined,
hit.persisted ? 'persisted' : undefined,
].filter((value): value is string => value !== undefined).join(', ') || 'unavailable'
lines.push(
'',
`${index + 1}. Session ${hit.header.id}${titleText(titles.get(hit.header.id))}`,
` Created: ${formatTime(hit.header.createdAt)}`,
` Parent: ${parent}`,
` Availability: ${availability}`,
` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`,
` Snippet: ${hit.bestMatch.snippet}`,
)
}
if (collected.capped) {
lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.')
}
return lines.join('\n')
}
function formatEventSearch(
sessionId: SessionIdValue,
title: TitleView,
collected: SearchCollection<SessionEventSearchHit>,
): string {
const lines = [`Session ${sessionId}${titleText(title)}`]
if (collected.items.length === 0) {
lines.push('', 'No prior event matches found.')
return lines.join('\n')
}
lines.push('', `Event search results (${collected.items.length}):`)
for (const [index, hit] of collected.items.entries()) {
lines.push(
`${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`,
` Snippet: ${hit.snippet}`,
)
}
if (collected.capped) {
lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.')
}
return lines.join('\n')
}
function formatSessionTrace(
trace: SessionLineageTrace,
ancestors: readonly SessionRecord[],
ancestorBoundary: boolean,
descendants: readonly (AuthorizedDescendant | null)[],
titles: CompleteTitleMap,
): string {
const lines = [
`Session ${trace.target.header.id}${titleText(titles.get(trace.target.header.id))}`,
`Created: ${formatTime(trace.target.header.createdAt)}`,
`Availability: ${availabilityText(trace.target)}`,
'',
'Ancestors (nearest first):',
]
if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)')
for (const record of ancestors) {
lines.push(`- ${record.header.id}${titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`)
}
if (ancestorBoundary) lines.push('- [outside workspace boundary]')
lines.push('', 'Descendants:')
if (descendants.length === 0) lines.push('- none')
else renderDescendants(lines, descendants, titles, 0)
return lines.join('\n')
}
function renderDescendants(
lines: string[],
nodes: readonly (AuthorizedDescendant | null)[],
titles: CompleteTitleMap,
depth: number,
): void {
for (const node of nodes) {
const indent = ' '.repeat(depth)
if (node === null) {
lines.push(`${indent}- [outside workspace subtree]`)
continue
}
const id = node.record.header.id
lines.push(`${indent}- ${id}${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`)
renderDescendants(lines, node.descendants, titles, depth + 1)
}
}
function formatEventTrace(
sessionId: SessionIdValue,
title: TitleView,
trace: SessionEventTrace,
): string {
return [
`Session ${sessionId}${titleText(title)}`,
`Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`,
`Replaced by: ${trace.replacedBy ?? 'none'}`,
`Replacement chain: ${seqList(trace.replacementChain)}`,
`Events replaced by target: ${seqList(trace.replacedEventSeqs)}`,
`Direct provenance sources: ${seqList(trace.sourceEventSeqs)}`,
`Direct derived events: ${seqList(trace.derivedEventSeqs)}`,
].join('\n')
}
function formatEventRead(
sessionId: SessionIdValue,
title: TitleView,
window: SessionEventWindow,
): string {
const before = window.events.filter(event => event.seq < window.target.seq)
const after = window.events.filter(event => event.seq > window.target.seq)
const lines = [
`Session ${sessionId}${titleText(title)}`,
`Target event seq ${window.target.seq}:`,
'```json',
JSON.stringify(window.target, null, 2),
'```',
]
if (before.length > 0) {
lines.push('', 'Before:')
for (const event of before) lines.push(formatNeighbor(event))
}
if (after.length > 0) {
lines.push('', 'After:')
for (const event of after) lines.push(formatNeighbor(event))
}
return lines.join('\n')
}
function formatNeighbor(event: SessionEvent): string {
const text = extractSessionEventText(event)
return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}`
+ (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`)
}
function availabilityText(record: SessionRecord): string {
return [
record.live ? 'live' : undefined,
record.persisted ? 'persisted' : undefined,
].filter((value): value is string => value !== undefined).join(', ') || 'unavailable'
}
function seqList(values: readonly number[]): string {
return values.length === 0 ? 'none' : values.join(', ')
}
function formatTime(value: number): string {
return new Date(value).toISOString()
}
function presentSessionSearchCall(args: SessionSearchArgs): GenericCallView {
return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query }
}
function presentEventSearchCall(args: EventSearchArgs): GenericCallView {
return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query }
}
function presentSessionTraceCall(args: SessionTargetArgs): GenericCallView {
return {
card: 'generic',
kind: 'read',
title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`,
...args.session_id === undefined ? {} : { rawInput: args.session_id },
}
}
function presentEventTargetCall(
action: string,
args: EventTargetArgs,
): GenericCallView {
return {
card: 'generic',
kind: 'read',
title: `${action} ${args.seq}`,
rawInput: {
...args.session_id === undefined ? {} : { session_id: args.session_id },
seq: args.seq,
},
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-session-query`.
* @module @deepseek-ai/dsh-tool-session-query/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-session-query'
/** Cordis companion plugin name. */
export const name = 'tool-session-query-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this read-only model adapter owns no event or mutable
* data relationship beyond the registries that already validate registration.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,100 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
SESSION_FORMAT_VERSION,
SessionId,
type Session,
} from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
const temporaryDirectories: string[] = []
const contexts: Context[] = []
afterEach(async () => {
for (const ctx of contexts.splice(0)) await ctx.fiber.dispose()
for (const directory of temporaryDirectories.splice(0)) {
await rm(directory, { recursive: true, force: true })
}
})
function fakeAgent(session: Session): Agent {
return { id: session.id, session } as unknown as Agent
}
describe('tool-session-query with the real SQLite provider', () => {
it('searches live prior-step history and a persisted same-workspace log', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-'))
temporaryDirectories.push(root)
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
await ctx.plugin(SessionQuerySqlite, { path: join(root, 'session-query.db') })
await ctx.plugin(ToolSessionQuery)
const persisted = SessionId('persisted')
await ctx.sessionPersistence.create({
version: SESSION_FORMAT_VERSION,
id: persisted,
createdAt: 1,
cwd: '/work',
})
await ctx.sessionPersistence.append(persisted, [{
type: 'user/message',
seq: 0,
time: 2,
data: {
content: [{ type: 'text', text: 'persisted integration needle' }],
source: { kind: 'user' },
},
surfaceOp: 'append',
}])
const caller = ctx.sessions.create(SessionId('caller'), {
meta: { createdAt: 10, cwd: '/work' },
})
caller.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
caller.append(
'user/message',
{ content: [{ type: 'text', text: 'live integration needle' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
caller.append('step/start', { turn: 1, step: 1 })
let call = 0
const execute = (name: string, args: unknown) => ctx.tools.execute({
name,
arguments: args,
callId: CallId(`integration-${++call}`),
signal: new AbortController().signal,
agent: fakeAgent(caller),
})
const sessions = await execute('session_search', { query: 'persisted integration needle' })
expect(sessions.isError).toBe(false)
expect(sessions.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
.toContain('Session persisted')
const persistedEvents = await execute('session_event_search', {
session_id: persisted,
query: 'persisted integration needle',
})
expect(persistedEvents.isError).toBe(false)
expect(persistedEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
.toContain('seq 0')
const liveEvents = await execute('session_event_search', { query: 'live integration needle' })
expect(liveEvents.isError).toBe(false)
expect(liveEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n'))
.toContain('seq 1')
})
})

View File

@@ -0,0 +1,796 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import SessionStore, {
SESSION_FORMAT_VERSION,
SessionId,
type Session,
type SessionHeader,
type SessionId as SessionIdValue,
} from '@deepseek-ai/dsh-session'
import SessionQueryService, {
SessionQueryError,
SessionSearchCursor,
type SessionEventSearchHit,
type SessionEventSearchRequest,
type SessionSearchExecContext,
type SessionSearchHit,
type SessionSearchPage,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
const activeContexts: Context[] = []
afterEach(async () => {
vi.restoreAllMocks()
for (const ctx of activeContexts.splice(0)) await ctx.fiber.dispose()
FakeQuery.reset()
})
function header(id: string, cwd: string | undefined, createdAt = 1, parentSession?: SessionIdValue): SessionHeader {
return {
version: SESSION_FORMAT_VERSION,
id: SessionId(id),
createdAt,
...cwd === undefined ? {} : { cwd },
...parentSession === undefined ? {} : { parentSession },
}
}
function createSession(
ctx: Context,
id: string,
cwd: string | undefined,
createdAt = 1,
parentSession?: SessionIdValue,
): Session {
return ctx.sessions.create(SessionId(id), {
meta: {
createdAt,
...cwd === undefined ? {} : { cwd },
...parentSession === undefined ? {} : { parentSession },
},
})
}
function openStep(session: Session, text = 'prior needle'): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append(
'user/message',
{ content: [{ type: 'text', text }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append('step/start', { turn: 1, step: 1 })
}
function fakeAgent(session: Session): Agent {
return { id: session.id, session } as unknown as Agent
}
function sessionHit(
id: string,
cwd: string | undefined,
text = 'needle excerpt',
parentSession?: SessionIdValue,
): SessionSearchHit {
return {
header: header(id, cwd, 100, parentSession),
live: true,
persisted: false,
bestMatch: {
sessionId: SessionId(id),
seq: 4,
type: 'assistant/message',
time: 200,
surface: 'current',
snippet: text,
},
}
}
function eventHit(sessionId: SessionIdValue, seq: number, text = 'needle excerpt'): SessionEventSearchHit {
return {
sessionId,
seq,
type: 'user/message',
time: 200 + seq,
surface: 'current',
snippet: text,
}
}
class FakeQuery extends SessionQueryService {
static sessionSearch: (
request: SessionSearchRequest,
exec?: SessionSearchExecContext,
) => Promise<SessionSearchPage<SessionSearchHit>> = () => Promise.resolve({ items: [] })
static eventSearch: (
request: SessionEventSearchRequest,
exec?: SessionSearchExecContext,
) => Promise<SessionSearchPage<SessionEventSearchHit>> = () => Promise.resolve({ items: [] })
static sessionRequests: SessionSearchRequest[] = []
static eventRequests: SessionEventSearchRequest[] = []
static searchSignals: Array<AbortSignal | undefined> = []
static titles = new Map<SessionIdValue, string | Error>()
static reset(): void {
this.sessionSearch = () => Promise.resolve({ items: [] })
this.eventSearch = () => Promise.resolve({ items: [] })
this.sessionRequests = []
this.eventRequests = []
this.searchSignals = []
this.titles = new Map()
}
override searchSessions(
request: SessionSearchRequest,
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
FakeQuery.sessionRequests.push(request)
FakeQuery.searchSignals.push(exec?.signal)
return FakeQuery.sessionSearch(request, exec)
}
override searchEvents(
request: SessionEventSearchRequest,
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
FakeQuery.eventRequests.push(request)
FakeQuery.searchSignals.push(exec?.signal)
return FakeQuery.eventSearch(request, exec)
}
override async readTitle(sessionId: SessionIdValue) {
const value = FakeQuery.titles.get(sessionId)
if (value instanceof Error) throw value
if (value === undefined) return super.readTitle(sessionId)
return {
title: value,
messageSeqs: [],
source: { kind: 'fallback' as const },
eventSeq: 0,
updatedAt: 1,
}
}
}
interface Mounted {
readonly ctx: Context
readonly fiber: Fiber
readonly caller: Session
call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult>
}
async function mount(
config: ToolSessionQuery.Config = {},
callerCwd: string | null = '/work',
): Promise<Mounted> {
const ctx = new Context()
activeContexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeQuery)
const fiber = await ctx.plugin(ToolSessionQuery, config)
const caller = createSession(ctx, 'caller', callerCwd ?? undefined, 10)
openStep(caller)
let calls = 0
return {
ctx,
fiber,
caller,
call: (toolName, args, options = {}) => ctx.tools.execute({
name: toolName,
arguments: args,
callId: CallId(`call-${++calls}`),
signal: options.signal ?? new AbortController().signal,
...options.agent === undefined ? { agent: fakeAgent(caller) } : { agent: options.agent },
}),
}
}
function text(result: ToolExecutionResult): string {
return result.content.map(block => block.type === 'text' ? block.text : '').join('\n')
}
function errorCode(result: ToolExecutionResult): string | undefined {
return result.isError ? result.error.info?.code : undefined
}
describe('registration and schemas', () => {
it('registers the five cursor-free tools, prompt, timeouts, and pure generic presenters, then disposes them', async () => {
const mounted = await mount({ maxSearchResults: 7, searchTimeoutMs: 1234 })
const names = mounted.ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual([
'session_search',
'session_event_search',
'session_trace',
'session_event_trace',
'session_event_read',
])
const sessionSchema = mounted.ctx.tools.schemas().find(schema => schema.name === 'session_search')
expect(sessionSchema?.parameters).not.toHaveProperty('properties.cursor')
expect(sessionSchema?.parameters).not.toHaveProperty('properties.limit')
expect(sessionSchema?.parameters).not.toHaveProperty('properties.cwd')
expect(mounted.ctx.tools.get('session_search')?.timeoutMs).toBe(1234)
expect(mounted.ctx.tools.get('session_trace')?.timeoutMs).toBeUndefined()
const safeArgs: Record<string, unknown> = {
session_search: { query: 'q' },
session_event_search: { query: 'q' },
session_trace: {},
session_event_trace: { seq: 0 },
session_event_read: { seq: 0 },
}
for (const name of names) {
expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(safeArgs[name])).toBe(true)
}
expect(mounted.ctx.tools.get('session_search')?.output.render({}, 'rendered'))
.toEqual([{ type: 'text', text: 'rendered' }])
expect(mounted.ctx.tools.get('session_search')?.presentCall?.({ query: 'needle' }))
.toEqual({ card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: 'needle' })
expect(mounted.ctx.tools.get('session_event_search')?.presentCall?.({ query: 'needle' }))
.toEqual({ card: 'generic', kind: 'search', title: 'Search session events', rawInput: 'needle' })
expect(mounted.ctx.tools.get('session_trace')?.presentCall?.({}))
.toEqual({ card: 'generic', kind: 'read', title: 'Trace current session' })
expect(mounted.ctx.tools.get('session_trace')?.presentCall?.({ session_id: 'other' }))
.toEqual({ card: 'generic', kind: 'read', title: 'Trace session other', rawInput: 'other' })
expect(mounted.ctx.tools.get('session_event_trace')?.presentCall?.({ session_id: 'other', seq: 3 }))
.toEqual({
card: 'generic',
kind: 'read',
title: 'Trace event 3',
rawInput: { session_id: 'other', seq: 3 },
})
expect(mounted.ctx.tools.get('session_event_read')?.presentCall?.({ seq: 4 }))
.toEqual({ card: 'generic', kind: 'read', title: 'Read event 4', rawInput: { seq: 4 } })
const assembly = await mounted.ctx.systemPrompt.assemble()
expect(assembly.sections.find(section => section.name === 'tool:session-query')?.text)
.toContain('prior sessions')
await mounted.fiber.dispose()
expect(mounted.ctx.tools.schemas().map(schema => schema.name)).toEqual([])
expect((await mounted.ctx.systemPrompt.assemble()).sections.map(section => section.name))
.not.toContain('tool:session-query')
})
it('fails invalid direct config before registering anything', async () => {
const mounted = await mount()
for (const maxSearchResults of [0, 1.5, Number.NaN]) {
expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) })
.toThrow('maxSearchResults')
}
for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) {
expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) })
.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
}
expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow()
})
it('expresses the complete Node timer range in the Loader config schema', () => {
expect(new ToolSessionQuery.Config({ searchTimeoutMs: MAX_TIMER_DELAY_MS }))
.toEqual({ maxSearchResults: 100, searchTimeoutMs: MAX_TIMER_DELAY_MS })
expect(() => new ToolSessionQuery.Config({ searchTimeoutMs: 1.5 })).toThrow()
expect(() => new ToolSessionQuery.Config({ searchTimeoutMs: MAX_TIMER_DELAY_MS + 1 })).toThrow()
})
})
describe('input validation and translation', () => {
it.each([
[{ query: ' ' }, 'SESSION_QUERY_INVALID_QUERY'],
[{ query: 'bad\0query' }, 'SESSION_QUERY_INVALID_QUERY'],
[{ query: 'q', session_ids: [] }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', parent_session_ids: [] }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', availability: [] }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', availability: ['archived'] }, 'INVALID_ARGS'],
[{ query: 'q', event_types: [] }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', event_surfaces: [] }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', event_surfaces: ['hidden'] }, 'INVALID_ARGS'],
[{ query: 'q', event_seq_from: -1 }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', event_seq_to: Number.MAX_SAFE_INTEGER + 1 }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', event_seq_from: 2, event_seq_to: 1 }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-07-24T10:00:00' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-02-30T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2100-02-29T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-04-31T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-01-01T24:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-01-01T00:60:00Z' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-01-01T00:00:60Z' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-01-01T00:00:00+24:00' }, 'SESSION_QUERY_INVALID_FILTER'],
[{ query: 'q', created_at_from: '2026-01-01T00:00:00+00:60' }, 'SESSION_QUERY_INVALID_FILTER'],
[{
query: 'q',
created_at_from: '2026-07-25T00:00:00Z',
created_at_to: '2026-07-24T00:00:00Z',
}, 'SESSION_QUERY_INVALID_FILTER'],
])('rejects invalid search arguments %#', async (args, code) => {
const mounted = await mount()
const result = await mounted.call('session_search', args)
expect(errorCode(result)).toBe(code)
})
it('normalizes the query and compiles inclusive session/event filters with one parent OR clause', async () => {
const mounted = await mount()
await mounted.call('session_search', {
query: ' alpha beta ',
session_ids: ['a', 'b'],
created_at_from: '2026-07-24T00:00:00+08:00',
created_at_to: '2026-07-24T01:00:00+08:00',
parent_session_ids: ['parent'],
include_root_sessions: true,
availability: ['live'],
event_seq_from: 2,
event_seq_to: 9,
event_time_from: '2026-07-24T00:00:00Z',
event_time_to: '2026-07-24T01:00:00Z',
event_types: ['plugin/open-event'],
event_surfaces: ['shadowed'],
})
expect(FakeQuery.sessionRequests).toHaveLength(1)
expect(FakeQuery.sessionRequests[0]).toEqual({
query: 'alpha beta',
sessionFilters: [
{ kind: 'id', values: ['a', 'b'] },
{
kind: 'created-at',
from: Date.parse('2026-07-24T00:00:00+08:00'),
to: Date.parse('2026-07-24T01:00:00+08:00'),
},
{ kind: 'parent', values: ['parent', null] },
{ kind: 'availability', values: ['live'] },
{ kind: 'cwd', values: ['/work'] },
],
eventFilters: [
{ kind: 'seq', from: 2, to: 9 },
{
kind: 'time',
from: Date.parse('2026-07-24T00:00:00Z'),
to: Date.parse('2026-07-24T01:00:00Z'),
},
{ kind: 'type', values: ['plugin/open-event'] },
{ kind: 'surface', values: ['shadowed'] },
],
})
})
it('compiles one-sided timestamps and independent root/parent clauses', async () => {
const mounted = await mount()
await mounted.call('session_search', {
query: 'q',
created_at_from: '2024-02-29T00:00Z',
include_root_sessions: true,
event_time_to: '2000-02-29T00:00Z',
})
expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
kind: 'created-at',
from: Date.parse('2024-02-29T00:00Z'),
})
expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({
kind: 'parent',
values: [null],
})
expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({
kind: 'time',
to: Date.parse('2000-02-29T00:00Z'),
})
await mounted.call('session_search', {
query: 'q',
parent_session_ids: ['parent'],
})
expect(FakeQuery.sessionRequests[1]?.sessionFilters).toContainEqual({
kind: 'parent',
values: ['parent'],
})
})
})
describe('workspace authority and lineage redaction', () => {
it('fails closed without an agent and for direct cross-workspace targets', async () => {
const mounted = await mount()
createSession(mounted.ctx, 'outside', '/outside')
const missing = await mounted.ctx.tools.execute({
name: 'session_trace',
arguments: {},
callId: CallId('missing-agent'),
signal: new AbortController().signal,
})
expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_MISSING_AGENT')
const denied = await mounted.call('session_event_read', { session_id: 'outside', seq: 0 })
expect(errorCode(denied)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
expect(text(denied)).not.toContain('session "outside"')
})
it('allows only self for a null-cwd caller and denies cross-session search', async () => {
const mounted = await mount({}, null)
const own = await mounted.call('session_trace', {})
expect(own.isError).toBe(false)
expect(text(own)).toContain('Session caller')
expect(errorCode(await mounted.call('session_search', { query: 'q' })))
.toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
createSession(mounted.ctx, 'other', undefined)
expect(errorCode(await mounted.call('session_trace', { session_id: 'other' })))
.toBe('SESSION_QUERY_TOOL_UNAUTHORIZED')
})
it('redacts an unauthorized ancestor and prunes unauthorized descendant subtrees without hidden ids', async () => {
const mounted = await mount()
const hiddenParent = createSession(mounted.ctx, 'hidden-parent-secret', '/outside')
const target = createSession(mounted.ctx, 'target', '/work', 20, hiddenParent.id)
const visible = createSession(mounted.ctx, 'visible-child', '/work', 30, target.id)
const hidden = createSession(mounted.ctx, 'hidden-child-secret', '/outside', 40, target.id)
createSession(mounted.ctx, 'hidden-grandchild-secret', '/work', 50, hidden.id)
FakeQuery.titles.set(target.id, 'Target title')
FakeQuery.titles.set(visible.id, 'Visible title')
const result = await mounted.call('session_trace', { session_id: target.id })
const output = text(result)
expect(output).toContain('Target title')
expect(output).toContain('visible-child')
expect(output).toContain('[outside workspace boundary]')
expect(output).toContain('[outside workspace subtree]')
expect(output).not.toContain('hidden-parent-secret')
expect(output).not.toContain('hidden-child-secret')
expect(output).not.toContain('hidden-grandchild-secret')
})
it('renders authorized ancestors and an unresolved lineage boundary without leaking it', async () => {
const mounted = await mount()
const root = createSession(mounted.ctx, 'visible-root', '/work', 5)
const target = createSession(mounted.ctx, 'visible-target', '/work', 6, root.id)
const complete = text(await mounted.call('session_trace', { session_id: target.id }))
expect(complete).toContain('visible-root')
const missingParent = SessionId('missing-parent-secret')
const incomplete = createSession(mounted.ctx, 'incomplete-target', '/work', 7, missingParent)
const redacted = text(await mounted.call('session_trace', { session_id: incomplete.id }))
expect(redacted).toContain('[outside workspace boundary]')
expect(redacted).not.toContain(missingParent)
})
it('renders unavailable trace records and keeps a self-id descendant authorized', async () => {
const mounted = await mount()
const target = createSession(mounted.ctx, 'trace-unavailable', '/work')
const [record] = await mounted.ctx.sessionQuery.filterSessions([{ kind: 'id', values: [target.id] }])
const [callerRecord] = await mounted.ctx.sessionQuery.filterSessions([{
kind: 'id',
values: [mounted.caller.id],
}])
if (record === undefined || callerRecord === undefined) throw new Error('expected live records')
const unavailable = { ...record, live: false, persisted: false }
const persisted = { ...callerRecord, live: false, persisted: true }
vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({
target: unavailable,
ancestors: [],
descendants: [{ session: persisted, descendants: [] }],
complete: true,
root: unavailable,
})
const output = text(await mounted.call('session_trace', { session_id: target.id }))
expect(output).toContain('Availability: unavailable')
expect(output).toContain(mounted.caller.id)
expect(output).toContain('persisted')
})
})
describe('search paging, prior-history bounds, titles, and cancellation', () => {
it('drains hidden internal pages to the authorized non-self cap and masks an unauthorized parent id', async () => {
const mounted = await mount({ maxSearchResults: 2 })
const outside = createSession(mounted.ctx, 'outside-parent-secret', '/outside')
const a = createSession(mounted.ctx, 'a', '/work')
const b = createSession(mounted.ctx, 'b', '/work')
FakeQuery.titles.set(a.id, 'Alpha')
FakeQuery.titles.set(b.id, 'Beta')
const c1 = SessionSearchCursor('c1')
const c2 = SessionSearchCursor('c2')
FakeQuery.sessionSearch = (request) => {
if (request.cursor === undefined) {
return Promise.resolve({
items: [
sessionHit('caller', '/work'),
sessionHit('unauthorized', '/outside'),
],
nextCursor: c1,
})
}
if (request.cursor === c1) {
return Promise.resolve({
items: [sessionHit('a', '/work', 'first', outside.id)],
nextCursor: c2,
})
}
return Promise.resolve({
items: [sessionHit('b', '/work', 'second')],
nextCursor: SessionSearchCursor('more'),
})
}
const result = await mounted.call('session_search', { query: 'needle' })
const output = text(result)
expect(FakeQuery.sessionRequests).toHaveLength(3)
expect(FakeQuery.sessionRequests.every(request => request.limit === undefined)).toBe(true)
expect(FakeQuery.sessionRequests.map(request => request.cursor)).toEqual([undefined, c1, c2])
expect(output).toContain('Session a — Alpha')
expect(output).toContain('Session b — Beta')
expect(output).toContain('Parent: [outside workspace]')
expect(output).not.toContain('outside-parent-secret')
expect(output).toContain('Result cap reached')
})
it('preserves stale-cursor diagnostics without transparently restarting', async () => {
const mounted = await mount({ maxSearchResults: 2 })
const cursor = SessionSearchCursor('stale-next')
FakeQuery.sessionSearch = request => request.cursor === undefined
? Promise.resolve({ items: [], nextCursor: cursor })
: Promise.reject(new SessionQueryError('stale provider generation', 'SESSION_QUERY_STALE_CURSOR'))
const result = await mounted.call('session_search', { query: 'needle' })
expect(errorCode(result)).toBe('SESSION_QUERY_STALE_CURSOR')
expect(text(result)).toContain('retry the complete search call')
expect(FakeQuery.sessionRequests).toHaveLength(2)
})
it('rejects a repeated internal cursor instead of looping', async () => {
const mounted = await mount()
const cursor = SessionSearchCursor('repeat')
FakeQuery.sessionSearch = () => Promise.resolve({ items: [], nextCursor: cursor })
const result = await mounted.call('session_search', { query: 'needle' })
expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_CURSOR')
expect(FakeQuery.sessionRequests).toHaveLength(2)
})
it('renders authorized parent ids and all availability states', async () => {
const mounted = await mount({ maxSearchResults: 3 })
const parent = createSession(mounted.ctx, 'parent', '/work')
const child = createSession(mounted.ctx, 'child', '/work', 2, parent.id)
const callerChild = createSession(mounted.ctx, 'caller-child', '/work', 3, mounted.caller.id)
FakeQuery.sessionSearch = () => Promise.resolve({
items: [
{ ...sessionHit(child.id, '/work', 'both', parent.id), live: true, persisted: true },
{ ...sessionHit(callerChild.id, '/work', 'persisted', mounted.caller.id), live: false, persisted: true },
{ ...sessionHit('unavailable', '/work', 'neither'), live: false, persisted: false },
],
})
const output = text(await mounted.call('session_search', { query: 'needle' }))
expect(output).toContain('Parent: parent')
expect(output).toContain(`Parent: ${mounted.caller.id}`)
expect(output).toContain('Availability: live, persisted')
expect(output).toContain('Availability: persisted')
expect(output).toContain('Availability: unavailable')
})
it('intersects current-session search with the event before the latest step and leaves other targets unchanged', async () => {
const mounted = await mount()
FakeQuery.eventSearch = request => Promise.resolve({
items: [eventHit(request.sessionId, 1)],
})
await mounted.call('session_event_search', {
query: 'prior',
seq_from: 0,
seq_to: 99,
})
expect(FakeQuery.eventRequests[0]?.filters).toContainEqual({ kind: 'seq', from: 0, to: 1 })
const other = createSession(mounted.ctx, 'other', '/work')
await mounted.call('session_event_search', {
session_id: other.id,
query: 'prior',
seq_from: 0,
seq_to: 99,
})
expect(FakeQuery.eventRequests[1]?.filters).toContainEqual({ kind: 'seq', from: 0, to: 99 })
})
it('returns no current-session hits without calling FTS when the user range starts in the active step', async () => {
const mounted = await mount()
const result = await mounted.call('session_event_search', {
query: 'prior',
seq_from: 2,
})
expect(result.isError).toBe(false)
expect(text(result)).toContain('No prior event matches found.')
expect(FakeQuery.eventRequests).toEqual([])
})
it('requires a current step boundary and drains event pages to a capped result', async () => {
const mounted = await mount({ maxSearchResults: 2 })
const noStep = createSession(mounted.ctx, 'no-step', '/work')
const missing = await mounted.call(
'session_event_search',
{ query: 'q' },
{ agent: fakeAgent(noStep) },
)
expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP')
const other = createSession(mounted.ctx, 'paged-events', '/work')
const cursor = SessionSearchCursor('events-next')
FakeQuery.eventSearch = request => request.cursor === undefined
? Promise.resolve({ items: [eventHit(other.id, 1)], nextCursor: cursor })
: Promise.resolve({ items: [eventHit(other.id, 2), eventHit(other.id, 3)] })
const result = await mounted.call('session_event_search', {
session_id: other.id,
query: 'q',
})
expect(FakeQuery.eventRequests.map(request => request.cursor)).toEqual([undefined, cursor])
expect(text(result)).toContain('Result cap reached')
})
it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => {
const mounted = await mount()
const hit = createSession(mounted.ctx, 'hit', '/work')
const failure = new HarnessError('title backend failed', 'TITLE_BACKEND')
FakeQuery.titles.set(hit.id, failure)
FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
const result = await mounted.call('session_search', { query: 'needle' })
expect(result.isError).toBe(false)
expect(text(result)).toContain('untitled (title unavailable: TITLE_BACKEND)')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('title backend failed'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('HarnessError'))
})
it('reports unknown title failures and preserves an Error without a stack', async () => {
const mounted = await mount()
const first = createSession(mounted.ctx, 'unknown-title', '/work')
const second = createSession(mounted.ctx, 'stackless-title', '/work')
const stackless = new Error('stackless')
Object.defineProperty(stackless, 'stack', { value: undefined })
const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitle')
.mockRejectedValueOnce('string failure')
.mockRejectedValueOnce(stackless)
FakeQuery.sessionSearch = () => Promise.resolve({
items: [
sessionHit(first.id, '/work'),
sessionHit(second.id, '/work'),
],
})
const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined)
const result = await mounted.call('session_search', { query: 'needle' })
expect(text(result)).toContain('title unavailable: UNKNOWN')
expect(readTitle).toHaveBeenCalledTimes(2)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless'))
})
it('does not downgrade cancellation during title enrichment', async () => {
const mounted = await mount()
const hit = createSession(mounted.ctx, 'abort-title', '/work')
const controller = new AbortController()
FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] })
vi.spyOn(mounted.ctx.sessionQuery, 'readTitle').mockImplementation(() => {
controller.abort()
return Promise.reject(new Error('cancelled title'))
})
const result = await mounted.call('session_search', { query: 'needle' }, { signal: controller.signal })
expect(result.isError).toBe(true)
expect(text(result)).not.toContain('title unavailable')
})
it('passes the exact execution signal to every FTS page and stops on cancellation', async () => {
const mounted = await mount()
const controller = new AbortController()
let started!: () => void
const bodyStarted = new Promise<void>((resolve) => { started = resolve })
FakeQuery.sessionSearch = (_request, exec) => new Promise((_resolve, reject) => {
started()
exec?.signal?.addEventListener('abort', () => {
reject(new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED'))
}, { once: true })
})
const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal })
await bodyStarted
controller.abort()
const result = await pending
expect(result.isError).toBe(true)
expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED')
expect(FakeQuery.searchSignals).toEqual([controller.signal])
})
})
describe('trace and exact read rendering', () => {
it('renders every event relationship sequence and a UTC target timestamp', async () => {
const mounted = await mount()
const session = createSession(mounted.ctx, 'relationships', '/work')
session.append(
'user/message',
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append(
'assistant/message',
{
turn: 1,
step: 1,
content: [{ type: 'text', text: 'replacement' }],
provenance: { provider: 'test', model: 'test' },
},
{ surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
)
const result = await mounted.call('session_event_trace', { session_id: session.id, seq: 0 })
expect(text(result)).toContain('Replacement chain: 1')
expect(text(result)).toContain('Direct derived events: 1')
expect(text(result)).toContain(new Date(session.events[0]?.time ?? 0).toISOString())
})
it('renders unabridged fenced target JSON and readable semantic neighbor summaries', async () => {
const mounted = await mount()
const session = createSession(mounted.ctx, 'read', '/work')
session.append(
'user/message',
{ content: [{ type: 'text', text: 'before semantic text' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append(
'assistant/message',
{
turn: 1,
step: 1,
content: [{ type: 'text', text: 'target full text' }],
provenance: { provider: 'test', model: 'test' },
},
{ surfaceOp: 'append' },
)
session.append(
'context/message',
{ content: [{ type: 'text', text: 'after semantic text' }], source: { kind: 'plugin', plugin: 'test' } },
{ surfaceOp: 'append' },
)
const result = await mounted.call('session_event_read', {
session_id: session.id,
seq: 1,
before: 1,
after: 1,
})
const output = text(result)
expect(output).toContain('```json')
expect(output).toContain('"text": "target full text"')
expect(output).toContain('before semantic text')
expect(output).toContain('after semantic text')
expect(output).not.toContain('truncated')
})
it('renders empty event relationships and neighbors without semantic text', async () => {
const mounted = await mount()
const session = createSession(mounted.ctx, 'empty-relations', '/work')
session.append('step/start', { turn: 1, step: 1 })
session.append('step/end', { turn: 1, step: 1 })
const trace = text(await mounted.call('session_event_trace', {
session_id: session.id,
seq: 0,
}))
expect(trace).toContain('Replaced by: none')
expect(trace).toContain('Replacement chain: none')
const onlyAfter = text(await mounted.call('session_event_read', {
session_id: session.id,
seq: 0,
after: 1,
}))
expect(onlyAfter).not.toContain('Before:')
expect(onlyAfter).toContain('(no semantic text)')
const onlyBefore = text(await mounted.call('session_event_read', {
session_id: session.id,
seq: 1,
before: 1,
}))
expect(onlyBefore).toContain('Before:')
expect(onlyBefore).not.toContain('After:')
})
it.each([
['session_event_trace', { seq: -1 }],
['session_event_read', { seq: Number.MAX_SAFE_INTEGER + 1 }],
['session_event_read', { seq: 0, before: -1 }],
['session_event_read', { seq: 0, after: 1.5 }, 'INVALID_ARGS'],
])('rejects invalid exact-read integers for %s', async (name, args, expected = 'SESSION_QUERY_INVALID_FILTER') => {
const mounted = await mount()
expect(errorCode(await mounted.call(name, args))).toBe(expected)
})
})

View File

@@ -0,0 +1,40 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../session-query"
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/timeout"
}
]
}

View File

@@ -12,11 +12,13 @@ const SYSTEM = '{{system}}'
const TOOLS = '{{tools}}'
const MESSAGE_PREFIX = '{{messagePrefix}}'
const UPDATED_AT = '{{updatedAt}}'
const EVENT_TIME = '{{eventTime}}'
/** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */
const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g
const PATH_TAG_RE = /(<path>)([^<]*)(<\/path>)/g
const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g
const EMBEDDED_EVENT_TIME_RE = /("time": )\d+(?=,\r?\n)/g
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
@@ -72,6 +74,9 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM
}
out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`)
// Exact event-read tools render pretty JSON inside a text block. The event's
// wall-clock time is volatile even though its seq and payload are deterministic.
out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`)
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
out = out.replace(UUID_RE, SESSION_ID)
return out

View File

@@ -123,6 +123,28 @@ Additional instructions from: nested\AGENTS.md`,
expect(out).not.toContain('2026-07-20T17:03:13.689Z')
})
it('stabilizes a pretty-printed event timestamp embedded in tool-result text', () => {
const raw = JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: {
update: {
sessionUpdate: 'tool_call_update',
content: [{
type: 'content',
content: {
type: 'text',
text: 'Target event:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```',
},
}],
},
},
})
const out = normalizeStdout(raw, ctx)
expect(out).toContain('\\"time\\": {{eventTime}}')
expect(out).not.toContain('1784876275593')
})
it('throws on a non-JSON stdout line (the purity check)', () => {
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
expect(() => normalizeStdout(raw, ctx)).toThrow()

58
pnpm-lock.yaml generated
View File

@@ -363,6 +363,9 @@ importers:
'@deepseek-ai/dsh-tool-ralph':
specifier: workspace:*
version: link:../packages/workflow/tool-ralph
'@deepseek-ai/dsh-tool-session-query':
specifier: workspace:*
version: link:../packages/session-query/tool-session-query
'@deepseek-ai/dsh-tool-subagent':
specifier: workspace:*
version: link:../packages/subagent/tool-subagent
@@ -1348,6 +1351,9 @@ importers:
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-tool-session-query':
specifier: workspace:^
version: link:../../session-query/tool-session-query
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -2115,6 +2121,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-session-query-sqlite':
specifier: workspace:^
version: link:../../session-query/session-query-sqlite
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session-title/session-title
@@ -2163,6 +2172,9 @@ importers:
'@deepseek-ai/dsh-tool-fs-search':
specifier: workspace:^
version: link:../../fs/tool-fs-search
'@deepseek-ai/dsh-tool-session-query':
specifier: workspace:^
version: link:../../session-query/tool-session-query
'@deepseek-ai/dsh-tool-skill':
specifier: workspace:^
version: link:../../skill/tool-skill
@@ -2900,6 +2912,52 @@ importers:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader)
packages/session-query/tool-session-query:
dependencies:
schemastery:
specifier: ^3.18.0
version: 3.18.0
devDependencies:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../support/invariants
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../../session-persistence/session-persistence
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session-persistence/session-persistence-jsonl
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../session-query
'@deepseek-ai/dsh-session-query-sqlite':
specifier: workspace:^
version: link:../session-query-sqlite
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session-title/session-title
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
'@deepseek-ai/dsh-timeout':
specifier: workspace:^
version: link:../../util/timeout
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
cordis:
specifier: ^4.0.0-rc.7
version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
packages/session-title/session-title:
dependencies:
schemastery:

View File

@@ -138,8 +138,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Session reads, traces, filters, and search',
mode: 'seam',
implementations: ['session-query-sqlite'],
consumers: ['session-reference'],
note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service.',
consumers: ['session-reference', 'tool-session-query'],
note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.',
},
{
key: 'sessionReferences',

View File

@@ -11,6 +11,8 @@ import { basename, resolve } from 'node:path'
import { Context } from 'cordis'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
import GoalService from '@deepseek-ai/dsh-goal'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
@@ -39,6 +41,7 @@ import * as ToolGoal from '@deepseek-ai/dsh-tool-goal'
import Lsp from '@deepseek-ai/dsh-lsp'
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
import * as ToolSkill from '@deepseek-ai/dsh-tool-skill'
import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent'
@@ -316,6 +319,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolSkill)
},
},
{
pkg: '@deepseek-ai/dsh-tool-session-query',
dir: 'tool-session-query',
source: 'packages/session-query/tool-session-query/src/index.ts',
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'a calling Agent for workspace authority'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQuerySqlite, { path: ':memory:' })
await ctx.plugin(ToolSessionQuery)
},
note:
'The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy.',
},
{
pkg: '@deepseek-ai/dsh-tool-subagent',
dir: 'tool-subagent',

View File

@@ -44,6 +44,7 @@
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
{ "path": "./packages/session-query/session-query" },
{ "path": "./packages/session-query/session-query-sqlite" },
{ "path": "./packages/session-query/tool-session-query" },
{ "path": "./packages/session-title/session-title" },
{ "path": "./packages/session-title/session-title-llm" },
{ "path": "./packages/session-title/session-title-first-message-llm" },