From a350e95165cd136a946a218f829935d9775060e3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 15:09:55 +0800 Subject: [PATCH] feat(session-query): add model-facing tools (round 1) --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 16 + docs/module-graph.md | 9 + docs/tool-catalog.md | 234 +++++ examples/acp-agent/composition.md | 12 + examples/acp-agent/cordis.yml | 18 + examples/acp-agent/tests/acp.snapshot.ts | 7 + .../snapshots/session-query-spill/input.json | 7 + .../session-query-spill/session.jsonl | 34 + .../session-query-spill/stdout.expected.jsonl | 10 + .../text-turn/system-prompt.expected.md | 2 + .../text-turn/tool-schemas.expected.json | 204 ++++ examples/package.json | 1 + examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 6 + packages/examples/acp-demo/README.md | 2 +- packages/examples/acp-demo/package.json | 1 + .../examples/acp-demo/tests/load-path.e2e.ts | 15 +- packages/examples/tui-demo/README.md | 2 +- packages/host/runtime/package.json | 2 + packages/host/runtime/src/boot.ts | 7 +- .../host/runtime/tests/host-runtime.spec.ts | 18 + packages/host/runtime/tsconfig.json | 6 + packages/session-query/README.md | 3 +- .../tool-session-query/README.md | 72 ++ .../tool-session-query/package.json | 57 ++ .../tool-session-query/src/index.ts | 961 ++++++++++++++++++ .../tool-session-query/src/invariant.ts | 30 + .../tests/sqlite-integration.spec.ts | 100 ++ .../tests/tool-session-query.spec.ts | 796 +++++++++++++++ .../tool-session-query/tsconfig.json | 40 + .../support/acp-snapshot/src/normalize.ts | 5 + .../acp-snapshot/tests/normalize.spec.ts | 22 + pnpm-lock.yaml | 58 ++ scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 17 + tsconfig.host.json | 1 + 40 files changed, 2781 insertions(+), 13 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/input.json create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl create mode 100644 packages/session-query/tool-session-query/README.md create mode 100644 packages/session-query/tool-session-query/package.json create mode 100644 packages/session-query/tool-session-query/src/index.ts create mode 100644 packages/session-query/tool-session-query/src/invariant.ts create mode 100644 packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts create mode 100644 packages/session-query/tool-session-query/tests/tool-session-query.spec.ts create mode 100644 packages/session-query/tool-session-query/tsconfig.json diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..6e47d0b420 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..b362a4cb97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..3791cb6194 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -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) | 按包名筛选包自有运行时检查的注册表 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..4806a86e72 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,7 @@ flowchart LR pkg_acp["acp"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] + pkg_tool_session_query["tool-session-query"] svc_sessionReferences["ctx.sessionReferences
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. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..d76443579a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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` diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e2ee76934..5600279890 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 4f9de3644f..10f7fc74d9 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -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` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 841639fd90..b81676f382 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -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
@deepseek-ai/dsh-tool-session-query"] + cfg --> plugin_acp_tool_session_query + plugin_acp_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_acp_timeout_policy + plugin_acp_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_acp_spill_local + plugin_acp_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_acp_spill_policy plugin_acp_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] cfg --> plugin_acp_plan_mode plugin_acp_tool_ask_user["tool-ask-user
@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` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index d0f9b43367..3e7235c35c 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -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 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..a19f863de6 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -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, diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/input.json b/examples/acp-agent/tests/snapshots/session-query-spill/input.json new file mode 100644 index 0000000000..0e0abb74a2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/input.json @@ -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." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl new file mode 100644 index 0000000000..c4b4f54047 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -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"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl new file mode 100644 index 0000000000..a1a65b452c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl @@ -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":"[|clear|edit |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"}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 17e6773a03..68bdd841c7 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -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`). diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index b01e7683d1..02237f770b 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -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.", diff --git a/examples/package.json b/examples/package.json index 1b4e28c4fd..9e9534379d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -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:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..557923b733 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -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
@deepseek-ai/dsh-tool-session-query"] + cfg --> plugin_tui_tool_session_query plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm plugin_tui_token_meter["token-meter
@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` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 3070fcdc01..479ad24d09 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -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. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 93d89d6557..bd8c980993 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -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 | diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 36de692404..ad1ece0095 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -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:^", diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 982fac3b50..a866aaa243 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -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 { diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index a20a0fae9d..5bcbed615c 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -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 | diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..f6d43afae0 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -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:^", diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index c0960ab678..353f74d0b6 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -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 { 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 diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..1b58a6511b 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -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' }) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index aee28b5371..b0cef9eb16 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -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" }, diff --git a/packages/session-query/README.md b/packages/session-query/README.md index f79d35936c..503a9cfed8 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -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. diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md new file mode 100644 index 0000000000..2a5ad72f9c --- /dev/null +++ b/packages/session-query/tool-session-query/README.md @@ -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. diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json new file mode 100644 index 0000000000..9438ddb1de --- /dev/null +++ b/packages/session-query/tool-session-query/package.json @@ -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" + } +} diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts new file mode 100644 index 0000000000..c8c7350604 --- /dev/null +++ b/packages/session-query/tool-session-query/src/index.ts @@ -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 = 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 { + get(id: SessionIdValue): TitleView +} + +interface SearchCollection { + readonly items: T[] + readonly capped: boolean +} + +interface AuthorizedDescendant { + readonly record: SessionRecord + readonly descendants: Array +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = [] + 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( + maxResults: number, + signal: AbortSignal, + request: (cursor?: SessionSearchCursor) => Promise<{ + readonly items: readonly T[] + readonly nextCursor?: SessionSearchCursor + }>, + accept: (item: T) => boolean, +): Promise> { + const items: T[] = [] + const seen = new Set() + let cursor: SessionSearchCursor | undefined + while (true) { + signal.throwIfAborted() + let page: Awaited> + 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> { + const unique = [...new Set(ids)] + const authorized = new Set() + 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 { + const result = new Map() + 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 { + 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 { + 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, + titles: CompleteTitleMap, + authorizedParents: ReadonlySet, +): 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, +): 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, + }, + } +} diff --git a/packages/session-query/tool-session-query/src/invariant.ts b/packages/session-query/tool-session-query/src/invariant.ts new file mode 100644 index 0000000000..73f0e35409 --- /dev/null +++ b/packages/session-query/tool-session-query/src/invariant.ts @@ -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 */ diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts new file mode 100644 index 0000000000..fb85bdc309 --- /dev/null +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -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') + }) +}) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts new file mode 100644 index 0000000000..60136d4f83 --- /dev/null +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -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> = () => Promise.resolve({ items: [] }) + + static eventSearch: ( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ) => Promise> = () => Promise.resolve({ items: [] }) + + static sessionRequests: SessionSearchRequest[] = [] + static eventRequests: SessionEventSearchRequest[] = [] + static searchSignals: Array = [] + static titles = new Map() + + 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> { + FakeQuery.sessionRequests.push(request) + FakeQuery.searchSignals.push(exec?.signal) + return FakeQuery.sessionSearch(request, exec) + } + + override searchEvents( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + 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 +} + +async function mount( + config: ToolSessionQuery.Config = {}, + callerCwd: string | null = '/work', +): Promise { + 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 = { + 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((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) + }) +}) diff --git a/packages/session-query/tool-session-query/tsconfig.json b/packages/session-query/tool-session-query/tsconfig.json new file mode 100644 index 0000000000..561b414216 --- /dev/null +++ b/packages/session-query/tool-session-query/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index b32c5c0574..a4cd595b12 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -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>)/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 diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index bdd85491d2..b74dd49e49 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..322d6c658c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5cfa05bc2f..309cd36c19 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -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', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index cc85d48ba5..118ac61b0a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -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', diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..b67bfb222d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -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" },