dsh-session-recall 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,32 @@ English | [中文](README.zh.md)
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-session-recall)](https://www.npmjs.com/package/dsh-session-recall) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
 
7
- Cross-session full-text recall for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the model-facing `recall` tool lets the agent **search its own past session transcripts** — "that bug we fixed last week", "the font we chose for my resume" — through the trusted `ctx.sessionQuery` seam.
7
+ Deterministic cross-session full-text retrieval for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness): the model-facing `recall` tool lets the agent **search its own past session transcripts** — "that bug we fixed last week", "the font we chose for my resume" — through the trusted `ctx.sessionQuery` seam.
8
+
9
+ ## Positioning
10
+
11
+ `dsh-session-recall` is a **transcript retrieval layer** focused on correctness and control.
12
+
13
+ - It returns evidence from original session logs, not synthesized summaries.
14
+ - It enforces explicit retrieval scope (cwd by default, opt-in widening).
15
+ - It favors deterministic behavior over "smart" but lossy memory extraction.
16
+
17
+ If you need agent memory orchestration, use a memory framework; if you need bounded, auditable lookup over historical transcripts, use this plugin.
18
+
19
+ ## Competitive context
20
+
21
+ | Capability focus | Memory frameworks | Generic transcript search | `dsh-session-recall` |
22
+ |---|---|---|---|
23
+ | Retrieval target | Derived memory objects | Varies by implementation | **Original session transcript events** |
24
+ | Scope control | Framework-specific | Often coarse | **cwd-scoped default + explicit `all_projects` gate** |
25
+ | CJK behavior | Framework-specific | Often tokenizer-limited | **FTS + CJK zero-hit substring fallback** |
26
+ | Output contract | Usually framework-native | Varies | **Typed `recall` result with stable fields/hints** |
27
+
28
+ ## Roadmap
29
+
30
+ - **P1: ranking controls** — configurable recency decay and session pinning on top of FTS relevance.
31
+ - **P1: query diagnostics** — expose match reason (fts/cjk-fallback/filters) and scan budget in result metadata.
32
+ - **P2: evidence handoff** — one-click bridge to session export for matched sessions.
8
33
 
9
34
  ## Why
10
35
 
@@ -33,7 +58,7 @@ recall({ query, session_id }) → search the events of one session
33
58
  recall({ query, limit, cursor }) → page through results
34
59
  ```
35
60
 
36
- Each hit carries the session id, title (best-effort), date, and a match snippet; the result renders as a native search card in the Web UI (`SearchMatchesResultView`). Because the FTS `unicode61` tokenizer indexes an uninterrupted CJK run as a single token, a short Chinese phrase inside a longer sentence would otherwise never match the index — so a zero-hit CJK query automatically falls back to an exact substring scan over session text (the `sessionQuery.filterEvents` literal text clause), and the hint reports when that path matched.
61
+ Each hit carries the session id, title (best-effort), date, and a match snippet; the result renders as a native search card in the Web UI (`SearchMatchesResultView`). Because the FTS `unicode61` tokenizer indexes an uninterrupted CJK run as a single token, a short Chinese phrase inside a longer sentence would otherwise never match the index — so a zero-hit CJK query automatically falls back to a substring scan over session text (the `sessionQuery.filterEvents` literal text clause). Every whitespace-separated term must match, so `简历 模板` still recovers `简历模板`; the hint reports when that path matched.
37
62
 
38
63
  ## Scoping (the authorization gap)
39
64
 
@@ -86,10 +111,23 @@ Plugin row config (all optional):
86
111
 
87
112
  Every failure returns a friendly `hint` instead of a raw exception: a disabled index explains the two config keys needed, a stale cursor tells the model to restart without one, an unknown `session_id` suggests discovering sessions first. Title enrichment is best-effort — a failed title batch degrades to untitled rows, never a failed search.
88
113
 
114
+ ## Scope policy & redaction (v0.4)
115
+
116
+ Deployment-level controls for what the model may read back:
117
+
118
+ | Option | Values | Default | Effect |
119
+ |---|---|---|---|
120
+ | `redactionMode` | `off` / `mask` / `hash` | `off` | Redact secret-looking text (bearer headers, prefixed API keys, private-key blocks, emails) in snippets and titles. `hash` keeps secrets comparable (`#xxxxxxxx`, same secret → same marker) without being readable. Results carry a `redacted` count. |
121
+ | `cwdAllowlist` | list of paths | (none) | Only sessions started in these directories are searchable; the calling cwd itself must be listed. |
122
+ | `cwdDenylist` | list of paths | (none) | These directories are never searchable. Deny wins over allow. |
123
+ | `allProjectsPolicy` | `allow` / `deny` / `confirm` | `allow` | `deny` ignores `all_projects` with a model-facing hint; `confirm` asks the user through the official `@deepseek-ai/dsh-user-approval` seam — fail-closed when no answerer is composed. |
124
+
125
+ All three gates apply uniformly to cross-session hits, the CJK fallback scan, and `session_id` reads — no bypass route.
126
+
89
127
  ## Known limitations
90
128
 
91
129
  - First search after startup walks the durable logs to build the index (the tool description warns the model); subsequent searches are incremental.
92
- - `unicode61` matches whole tokens/phrases, not substrings — `AI` does not match `BRAID`. CJK queries that get zero full-text hits fall back to an exact substring scan (`filterEvents`), and the hint reports when that path matched; a multi-word CJK phrase still has to survive the tokenizer's whole-run indexing.
130
+ - `unicode61` matches whole tokens/phrases, not substrings — `AI` does not match `BRAID`. CJK queries that get zero full-text hits fall back to a substring scan (`filterEvents`) whose whitespace-separated terms are ANDed, so `简历 模板` also recovers `简历模板`; the hint reports when that path matched.
93
131
  - One process must own the index file (single-writer SQLite, per the official backend).
94
132
  - Matches return transcript text verbatim — there is no credential or local-path redaction. A token or sensitive path pasted into an earlier session can be surfaced by a matching search. Default cwd scoping and `allowAllProjects: false` are the only containment; fingerprinting or redaction is future work.
95
133
 
package/README.zh.md CHANGED
@@ -4,7 +4,32 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/dsh-session-recall)](https://www.npmjs.com/package/dsh-session-recall) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
6
 
7
- DeepSeek Harness 的跨会话全文回忆插件:注册模型可调用的 `recall` 工具,让 agent 能**检索自己过往的会话原文**——"上周修的那个 bug"、"简历选的什么字体"——全部通过可信的 `ctx.sessionQuery` 缝完成。
7
+ DeepSeek Harness 的**确定性跨会话全文检索**插件:注册模型可调用的 `recall` 工具,让 agent 能**检索自己过往的会话原文**——"上周修的那个 bug"、"简历选的什么字体"——全部通过可信的 `ctx.sessionQuery` 缝完成。
8
+
9
+ ## 项目定位
10
+
11
+ `dsh-session-recall` 是一个强调正确性与边界控制的**会话检索层**。
12
+
13
+ - 检索对象是原始会话日志,不是二次总结内容。
14
+ - 默认按 cwd 收敛权限范围,放宽范围必须显式声明。
15
+ - 追求可解释、可复现的检索行为,而不是"看起来更聪明"但有损的记忆抽取。
16
+
17
+ 如果你要做长期记忆编排,请用记忆框架;如果你要做可审计、有权限边界的历史检索,请用本插件。
18
+
19
+ ## 竞品视角
20
+
21
+ | 能力重心 | 记忆框架类插件 | 通用检索类插件 | `dsh-session-recall` |
22
+ |---|---|---|---|
23
+ | 检索对象 | 推导后的记忆结构 | 视实现而定 | **原始会话事件文本** |
24
+ | 范围控制 | 框架内约束 | 常较粗粒度 | **默认 cwd + 显式 `all_projects` 闸门** |
25
+ | CJK 体验 | 视实现而定 | 常受分词限制 | **FTS + CJK 零命中子串回退** |
26
+ | 输出契约 | 框架内部格式 | 不统一 | **类型化 `recall` 结果 + 稳定 hint** |
27
+
28
+ ## 路线图
29
+
30
+ - **P1:排序策略可配** —— 在 FTS 相关度之上增加时间衰减、会话 pin 权重。
31
+ - **P1:查询诊断元数据** —— 返回命中来源(fts/cjk-fallback/filters)与扫描预算。
32
+ - **P2:证据联动导出** —— 命中后可一键触发对应会话导出。
8
33
 
9
34
  ## 为什么做这个
10
35
 
@@ -33,7 +58,7 @@ recall({ query, session_id }) → 只搜指定会话内的事件
33
58
  recall({ query, limit, cursor }) → 翻页
34
59
  ```
35
60
 
36
- 每条命中带会话 id、标题(尽力补全)、日期、命中摘录;结果在 Web UI 里渲染成原生搜索卡片(`SearchMatchesResultView`)。因为 FTS 的 `unicode61` 分词器会把连续中文当成一个 token,短中文短语一旦嵌在长句里就匹配不到索引——所以 CJK 查询零命中时会自动回退到对会话文本的**精确子串扫描**(走 `sessionQuery.filterEvents` 的字面文本子句),hint 会说明这条回退路径是否命中。
61
+ 每条命中带会话 id、标题(尽力补全)、日期、命中摘录;结果在 Web UI 里渲染成原生搜索卡片(`SearchMatchesResultView`)。因为 FTS 的 `unicode61` 分词器会把连续中文当成一个 token,短中文短语一旦嵌在长句里就匹配不到索引——所以 CJK 查询零命中时会自动回退到对会话文本的子串扫描(走 `sessionQuery.filterEvents` 的字面文本子句),空格拆出的每个词都必须命中,因此 `简历 模板` 也能找回 `简历模板`;hint 会说明这条回退路径是否命中。
37
62
 
38
63
  ## 授权边界(官方明确留给工具层的责任)
39
64
 
@@ -86,10 +111,23 @@ dsh plugin --profile web add github:kittimzhe/dsh-session-recall
86
111
 
87
112
  所有失败都返回友好的 `hint` 而不是裸异常:索引未开启会说明需要哪两个配置键;游标失效会告诉模型不带游标重开一次;`session_id` 不存在会建议先做跨会话搜索。标题补全是尽力而为——标题批量读取失败只降级为"无标题"行,绝不让搜索失败。
88
113
 
114
+ ## 范围策略与脱敏(v0.4)
115
+
116
+ 部署级权限控制——模型能读回什么,由配置说了算:
117
+
118
+ | 配置 | 取值 | 默认 | 效果 |
119
+ |---|---|---|---|
120
+ | `redactionMode` | `off` / `mask` / `hash` | `off` | 对标题与摘录中疑似密钥的文本(Bearer 头、前缀式 API key、私钥块、邮箱)脱敏。`hash` 用确定性摘要 `#xxxxxxxx`(同一密钥同一标记)保持可比性。结果带 `redacted` 计数。 |
121
+ | `cwdAllowlist` | 路径列表 | (无) | 只检索这些目录下启动的会话;当前项目目录本身也必须在列表内。 |
122
+ | `cwdDenylist` | 路径列表 | (无) | 这些目录永不检索。deny 优先于 allow。 |
123
+ | `allProjectsPolicy` | `allow` / `deny` / `confirm` | `allow` | `deny` 忽略 `all_projects` 并向模型说明;`confirm` 走官方 `@deepseek-ai/dsh-user-approval` 接缝向用户请求批准——无应答者时 fail-closed。 |
124
+
125
+ 三道闸门统一作用于跨会话命中、CJK 回退扫描和 `session_id` 直读——没有绕行路径。
126
+
89
127
  ## 已知限制
90
128
 
91
129
  - 启动后第一次搜索会扫全量日志建索引(工具描述里已警告模型);之后增量更新。
92
- - `unicode61` 按完整 token/短语匹配,不支持子串——`AI` 匹配不到 `BRAID`。CJK 查询零命中时会回退到精确子串扫描(`filterEvents`),hint 会说明是否命中;多词中文短语仍受"连续中文=一个 token"的约束。
130
+ - `unicode61` 按完整 token/短语匹配,不支持子串——`AI` 匹配不到 `BRAID`。CJK 查询零命中时会回退到子串扫描(`filterEvents`),空格分隔的各词按 AND 语义都必须命中,因此 `简历 模板` 也能找回 `简历模板`;hint 会说明是否命中。
93
131
  - 索引文件单进程独占(官方后端的单写者 SQLite 约束)。
94
132
  - 命中结果按原文照摘,**没有任何凭据或本地路径脱敏**——更早的会话里粘贴过的 token 或敏感路径可能被检索出来。目前只有默认 cwd 收窄与 `allowAllProjects: false` 两道闸;指纹识别/脱敏是后续增强。
95
133
 
package/lib/index.d.ts CHANGED
@@ -1,10 +1,26 @@
1
- import { ToolDefinition } from "@deepseek-ai/dsh-tools";
1
+ import { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
2
2
  import { SessionEventResultFilter, SessionEventSearchDocument, SessionEventSearchPage, SessionEventSearchRequest, SessionRecord, SessionSearchExecContext, SessionSearchHit, SessionSearchPage, SessionSearchRequest, SessionTitleObservationResult } from "@deepseek-ai/dsh-session-query";
3
3
  import { JsonValue, SessionId } from "@deepseek-ai/dsh-session";
4
4
  import { Context } from "@deepseek-ai/cordis";
5
5
  import { ContentBlock } from "@deepseek-ai/dsh-llm";
6
+ //#region src/redact.d.ts
7
+ type RedactionMode = 'off' | 'mask' | 'hash';
8
+ declare const REDACTION_MODES: readonly RedactionMode[];
9
+ /**
10
+ * Redact one piece of text. Returns the new text plus how many replacements
11
+ * happened (0 when mode is `off`).
12
+ */
13
+ declare function redactText(text: string, mode: RedactionMode): {
14
+ text: string;
15
+ count: number;
16
+ };
17
+ /** Normalize an untrusted config value into a valid mode (default `off`). */
18
+ declare function normalizeRedactionMode(value: unknown): RedactionMode;
19
+ //#endregion
6
20
  //#region src/config.d.ts
7
- /** Plugin-row configuration for dsh-session-recall. */
21
+ /** What happens when the model passes `all_projects=true`. */
22
+ type AllProjectsPolicy = 'allow' | 'deny' | 'confirm';
23
+ declare const ALL_PROJECTS_POLICIES: readonly AllProjectsPolicy[];
8
24
  interface RecallConfig {
9
25
  /** Honor the tool's `all_projects` argument. Default `true`. */
10
26
  allowAllProjects?: boolean;
@@ -18,6 +34,18 @@ interface RecallConfig {
18
34
  cjkFallback?: boolean;
19
35
  /** Max sessions to scan on a cross-session CJK fallback. Default `50`. */
20
36
  cjkFallbackScanMax?: number;
37
+ /** Redact secret-looking text in snippets and titles. Default `'off'`. */
38
+ redactionMode?: RedactionMode;
39
+ /** When non-empty, only sessions started in these project directories are searchable. Default: no restriction. */
40
+ cwdAllowlist?: readonly string[];
41
+ /** Sessions started in these project directories are never searchable. Default: none. */
42
+ cwdDenylist?: readonly string[];
43
+ /**
44
+ * `all_projects` gate: `'allow'` (default, previous behavior), `'deny'`
45
+ * (ignored, with a model-facing hint), or `'confirm'` (the user must
46
+ * approve through the `@deepseek-ai/dsh-user-approval` seam; fail-closed).
47
+ */
48
+ allProjectsPolicy?: AllProjectsPolicy;
21
49
  }
22
50
  /** Validated, fully defaulted configuration. */
23
51
  interface NormalizedRecallConfig {
@@ -27,9 +55,15 @@ interface NormalizedRecallConfig {
27
55
  readonly cjkHint: boolean;
28
56
  readonly cjkFallback: boolean;
29
57
  readonly cjkFallbackScanMax: number;
58
+ readonly redactionMode: RedactionMode;
59
+ readonly cwdAllowlist: readonly string[];
60
+ readonly cwdDenylist: readonly string[];
61
+ readonly allProjectsPolicy: AllProjectsPolicy;
30
62
  }
31
63
  /** Default, clamp, and cross-check every optional field. */
32
64
  declare function normalizeRecallConfig(config?: RecallConfig): NormalizedRecallConfig;
65
+ /** Whether a session cwd is searchable under the allowlist/denylist policy. */
66
+ declare function cwdAllowed(cwd: string | null | undefined, cfg: NormalizedRecallConfig): boolean;
33
67
  //#endregion
34
68
  //#region src/types.d.ts
35
69
  /** Canonical value types for the `recall` tool's declared output. */
@@ -66,6 +100,8 @@ interface RecallResult {
66
100
  items: RecallItem[];
67
101
  nextCursor: string | null;
68
102
  hint: string | null;
103
+ /** How many secret-looking fields were redacted in this result (0 when redaction is off). */
104
+ redacted: number;
69
105
  }
70
106
  /** The typed model-facing arguments after schema validation. */
71
107
  interface RecallArgs {
@@ -86,11 +122,20 @@ interface RecallQueryEngine {
86
122
  filterEvents(sessionId: SessionId, filters: readonly SessionEventResultFilter[]): Promise<SessionEventSearchDocument[]>;
87
123
  }
88
124
  declare const RECALL_TOOL_DESCRIPTION: string;
125
+ /** The approval verdict vocabulary mirrored from `@deepseek-ai/dsh-user-approval`. */
126
+ type RecallApprovalVerdict = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable';
127
+ /**
128
+ * Optional user-approval seam for the `all_projects` gate (`allProjectsPolicy:
129
+ * 'confirm'`). Receives the tool run context (for the agent) and a
130
+ * human-readable reason; `'allowed-once'` is the only grant. Wired from
131
+ * `ctx.approval` in the plugin entry; fail-closed when absent.
132
+ */
133
+ type RecallApprover = (exec: ToolRunContext, reason: string) => Promise<RecallApprovalVerdict>;
89
134
  /**
90
135
  * Build the `recall` ToolDefinition around a concrete sessionQuery engine.
91
136
  * Pure construction — no registration happens here.
92
137
  */
93
- declare function createRecallTool(config?: RecallConfig, engine?: RecallQueryEngine): ToolDefinition;
138
+ declare function createRecallTool(config?: RecallConfig, engine?: RecallQueryEngine, approver?: RecallApprover): ToolDefinition;
94
139
  //#endregion
95
140
  //#region src/render.d.ts
96
141
  /**
@@ -140,4 +185,4 @@ declare const inject: string[];
140
185
  /** Plugin entry: mount the `recall` tool on the global tool registry. */
141
186
  declare function apply(ctx: Context, config?: RecallConfig): void;
142
187
  //#endregion
143
- export { type NormalizedRecallConfig, RECALL_TOOL_DESCRIPTION, type RecallArgs, type RecallBestMatch, type RecallConfig, type RecallItem, type RecallQueryEngine, type RecallResult, type RecallScope, apply, cjkFallbackHint, cjkZeroHitHint, clamp, createRecallTool, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, recallContentBlocks, recallPresentationMeta, renderRecallText, snippetAround };
188
+ export { ALL_PROJECTS_POLICIES, type AllProjectsPolicy, type NormalizedRecallConfig, RECALL_TOOL_DESCRIPTION, REDACTION_MODES, type RecallApprovalVerdict, type RecallApprover, type RecallArgs, type RecallBestMatch, type RecallConfig, type RecallItem, type RecallQueryEngine, type RecallResult, type RecallScope, type RedactionMode, apply, cjkFallbackHint, cjkZeroHitHint, clamp, createRecallTool, cwdAllowed, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, normalizeRedactionMode, recallContentBlocks, recallPresentationMeta, redactText, renderRecallText, snippetAround };
package/lib/index.js CHANGED
@@ -1,10 +1,87 @@
1
1
  import { defineTool } from "@deepseek-ai/dsh-tools";
2
2
  import { SessionSearchCursor } from "@deepseek-ai/dsh-session-query";
3
3
  import { SessionId } from "@deepseek-ai/dsh-session";
4
+ import { createHash } from "node:crypto";
5
+ //#region src/redact.ts
6
+ /**
7
+ * Redaction for recall output text (snippets and titles).
8
+ *
9
+ * Three modes: `off` (default, unchanged), `mask` (placeholder), `hash`
10
+ * (deterministic 8-hex digest — the same secret always hashes to the same
11
+ * marker, so equal markers prove equality without revealing content).
12
+ *
13
+ * Pattern set covers the credential shapes that actually show up in agent
14
+ * transcripts: bearer headers, prefixed API keys, private-key blocks, and
15
+ * email addresses. It is best-effort, not a secrecy guarantee.
16
+ */
17
+ const REDACTION_MODES = [
18
+ "off",
19
+ "mask",
20
+ "hash"
21
+ ];
22
+ const RULES = [
23
+ {
24
+ pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
25
+ mask: "[PRIVATE KEY REDACTED]"
26
+ },
27
+ {
28
+ pattern: /Bearer\s+[A-Za-z0-9\-._~+/]+=*/g,
29
+ mask: "Bearer [REDACTED]"
30
+ },
31
+ {
32
+ pattern: /\b(?:sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16})\b/g,
33
+ mask: "[REDACTED]"
34
+ },
35
+ {
36
+ pattern: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g,
37
+ mask: "[EMAIL]"
38
+ }
39
+ ];
40
+ function hashMarker(match) {
41
+ return `#${createHash("sha256").update(match).digest("hex").slice(0, 8)}`;
42
+ }
43
+ /**
44
+ * Redact one piece of text. Returns the new text plus how many replacements
45
+ * happened (0 when mode is `off`).
46
+ */
47
+ function redactText(text, mode) {
48
+ if (mode === "off" || text.length === 0) return {
49
+ text,
50
+ count: 0
51
+ };
52
+ let count = 0;
53
+ let out = text;
54
+ for (const rule of RULES) out = out.replace(rule.pattern, (match) => {
55
+ count += 1;
56
+ return mode === "mask" ? rule.mask : hashMarker(match);
57
+ });
58
+ return {
59
+ text: out,
60
+ count
61
+ };
62
+ }
63
+ /** Normalize an untrusted config value into a valid mode (default `off`). */
64
+ function normalizeRedactionMode(value) {
65
+ return typeof value === "string" && REDACTION_MODES.includes(value) ? value : "off";
66
+ }
67
+ //#endregion
68
+ //#region src/config.ts
69
+ /** Plugin-row configuration for dsh-session-recall. */
70
+ const ALL_PROJECTS_POLICIES = [
71
+ "allow",
72
+ "deny",
73
+ "confirm"
74
+ ];
4
75
  function intIn(value, fallback, lo, hi) {
5
76
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
6
77
  return Math.min(hi, Math.max(lo, Math.trunc(value)));
7
78
  }
79
+ function stringList(value) {
80
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [];
81
+ }
82
+ function normalizePolicy(value) {
83
+ return typeof value === "string" && ALL_PROJECTS_POLICIES.includes(value) ? value : "allow";
84
+ }
8
85
  /** Default, clamp, and cross-check every optional field. */
9
86
  function normalizeRecallConfig(config) {
10
87
  const defaultLimit = intIn(config?.defaultLimit, 5, 1, 10);
@@ -14,9 +91,20 @@ function normalizeRecallConfig(config) {
14
91
  maxLimit: Math.max(defaultLimit, intIn(config?.maxLimit, 10, 1, 25)),
15
92
  cjkHint: config?.cjkHint !== false,
16
93
  cjkFallback: config?.cjkFallback !== false,
17
- cjkFallbackScanMax: intIn(config?.cjkFallbackScanMax, 50, 1, 500)
94
+ cjkFallbackScanMax: intIn(config?.cjkFallbackScanMax, 50, 1, 500),
95
+ redactionMode: normalizeRedactionMode(config?.redactionMode),
96
+ cwdAllowlist: stringList(config?.cwdAllowlist),
97
+ cwdDenylist: stringList(config?.cwdDenylist),
98
+ allProjectsPolicy: normalizePolicy(config?.allProjectsPolicy)
18
99
  };
19
100
  }
101
+ /** Whether a session cwd is searchable under the allowlist/denylist policy. */
102
+ function cwdAllowed(cwd, cfg) {
103
+ if (cwd == null || cwd === "") return cfg.cwdAllowlist.length === 0;
104
+ if (cfg.cwdDenylist.includes(cwd)) return false;
105
+ if (cfg.cwdAllowlist.length > 0 && !cfg.cwdAllowlist.includes(cwd)) return false;
106
+ return true;
107
+ }
20
108
  //#endregion
21
109
  //#region src/util.ts
22
110
  /** Small pure helpers shared by the recall tool and its renderers. */
@@ -47,6 +135,10 @@ function hasCJK(text) {
47
135
  function normalizeQuery(text) {
48
136
  return text.trim().replaceAll(/\s+/g, " ");
49
137
  }
138
+ /** Split into whitespace-separated terms, dropping empty pieces. */
139
+ function splitTerms(text) {
140
+ return text.split(/\s+/u).filter((term) => term.length > 0);
141
+ }
50
142
  /** First line of `text` with control characters stripped, clipped to `limit` code points. */
51
143
  function firstLineClipped(text, limit) {
52
144
  const line = text.split("\n", 1)[0] ?? "";
@@ -155,9 +247,10 @@ function cjkZeroHitHint(query, zeroHits, enabled) {
155
247
  const RECALL_TOOL_DESCRIPTION = [
156
248
  "Search the FULL TEXT of past and current session transcripts on this machine (your own conversation history with this user).",
157
249
  "Use it when the user refers to earlier work (\"that bug we fixed last week\", \"the font we chose for my resume\") or when prior context was compacted away.",
158
- "Matches whole words/phrases for English and code identifiers; a zero-hit Chinese (CJK) query automatically falls back to an exact substring scan. Returns the best-matching event snippet per session plus the session id.",
250
+ "Matches whole words/phrases for English and code identifiers; a zero-hit Chinese (CJK) query automatically falls back to a substring scan in which every whitespace-separated term must match. Returns the best-matching event snippet per session plus the session id.",
159
251
  "Then use the read tool on files, or ask the user, to go deeper — this tool only points at history, it does not resume sessions.",
160
- "Scoping: by default only sessions started in the current project directory; pass all_projects=true to search everywhere.",
252
+ "Scoping: by default only sessions started in the current project directory; pass all_projects=true to search everywhere (the deployment may ignore it or require user approval).",
253
+ "When the deployment enables redaction, secret-looking text in snippets appears as [REDACTED] or a #hash marker — treat it as removed; do not try to reconstruct or echo it.",
161
254
  "The first search after startup may be slow while the index builds."
162
255
  ].join(" ");
163
256
  function errCode(error) {
@@ -188,7 +281,8 @@ function recallError(query, error) {
188
281
  hasMore: false,
189
282
  items: [],
190
283
  nextCursor: null,
191
- hint: friendlyError(error)
284
+ hint: friendlyError(error),
285
+ redacted: 0
192
286
  };
193
287
  }
194
288
  function brand(value) {
@@ -241,6 +335,23 @@ function eventItems(page, sessionId) {
241
335
  /** Snippet window kept consistent with the tool's text projection. */
242
336
  const CJK_SNIPPET_CHARS = 120;
243
337
  /**
338
+ * Decompose a query into ANDed literal-text clauses. A space-separated CJK
339
+ * query like "简历 模板" must match each term as a substring (so it recovers
340
+ * "简历模板"), not a whitespace-joined literal (which would require the space
341
+ * to be present verbatim). `highlight` is the first term, guaranteed present
342
+ * whenever the ANDed scan matches.
343
+ */
344
+ function cjkTextFilters(query) {
345
+ const terms = splitTerms(query);
346
+ return {
347
+ filters: terms.map((term) => ({
348
+ kind: "text",
349
+ text: term
350
+ })),
351
+ highlight: terms[0] ?? query
352
+ };
353
+ }
354
+ /**
244
355
  * CJK substring-scan fallback for zero-hit full-text searches. SQLite FTS5's
245
356
  * `unicode61` tokenizer treats an uninterrupted CJK run as one token, so a
246
357
  * short Chinese phrase inside a longer sentence never matches the index. The
@@ -249,16 +360,14 @@ const CJK_SNIPPET_CHARS = 120;
249
360
  * providers, so scanning each scoped session with it recovers the exact
250
361
  * substring matches the full-text index cannot see.
251
362
  */
252
- async function cjkScanSessions(engine, query, agentCwd, wantAll, scanMax, limit, signal) {
363
+ async function cjkScanSessions(engine, query, agentCwd, wantAll, cfg, scanMax, limit, signal) {
253
364
  const all = await engine.listSessions(signal);
254
- const candidates = !wantAll && agentCwd != null ? all.filter((record) => record.header.cwd === agentCwd) : all;
365
+ const candidates = (!wantAll && agentCwd != null ? all.filter((record) => record.header.cwd === agentCwd) : all).filter((record) => cwdAllowed(record.header.cwd, cfg));
255
366
  const items = [];
256
367
  for (const record of candidates.slice(0, scanMax)) {
257
368
  if (items.length >= limit) break;
258
- const docs = await engine.filterEvents(record.header.id, [{
259
- kind: "text",
260
- text: query
261
- }]);
369
+ const { filters, highlight } = cjkTextFilters(query);
370
+ const docs = await engine.filterEvents(record.header.id, filters);
262
371
  if (docs.length === 0) continue;
263
372
  const doc = docs[0];
264
373
  if (doc === void 0) continue;
@@ -274,7 +383,7 @@ async function cjkScanSessions(engine, query, agentCwd, wantAll, scanMax, limit,
274
383
  seq: doc.seq,
275
384
  type: doc.type,
276
385
  time: doc.time,
277
- snippet: snippetAround(doc.text, query, CJK_SNIPPET_CHARS)
386
+ snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
278
387
  }
279
388
  });
280
389
  }
@@ -324,15 +433,57 @@ const recallOutputSchema = {
324
433
  }
325
434
  },
326
435
  nextCursor: nullableString,
327
- hint: nullableString
436
+ hint: nullableString,
437
+ redacted: { type: "integer" }
328
438
  }
329
439
  };
330
440
  /**
331
441
  * Build the `recall` ToolDefinition around a concrete sessionQuery engine.
332
442
  * Pure construction — no registration happens here.
333
443
  */
334
- function createRecallTool(config, engine) {
444
+ function createRecallTool(config, engine, approver) {
335
445
  const cfg = normalizeRecallConfig(config);
446
+ /** Apply configured redaction to titles and snippets; report a model-facing note. */
447
+ function applyRedaction(items) {
448
+ if (cfg.redactionMode === "off") return {
449
+ items,
450
+ redacted: 0,
451
+ hint: null
452
+ };
453
+ let redacted = 0;
454
+ const out = items.map((item) => {
455
+ const title = item.title == null ? null : redactText(item.title, cfg.redactionMode);
456
+ const snippet = redactText(item.bestMatch.snippet, cfg.redactionMode);
457
+ redacted += (title?.count ?? 0) + snippet.count;
458
+ return {
459
+ ...item,
460
+ title: title == null ? null : title.text,
461
+ bestMatch: {
462
+ ...item.bestMatch,
463
+ snippet: snippet.text
464
+ }
465
+ };
466
+ });
467
+ const hint = redacted > 0 ? `${redacted} secret-looking field(s) were redacted from this result (mode: ${cfg.redactionMode}).` : null;
468
+ return {
469
+ items: out,
470
+ redacted,
471
+ hint
472
+ };
473
+ }
474
+ function joinHints(...parts) {
475
+ const kept = parts.filter((part) => part != null && part !== "");
476
+ return kept.length > 0 ? kept.join(" ") : null;
477
+ }
478
+ /** Ask the approval seam whether this all_projects call may proceed. */
479
+ async function decideAllProjects(exec, query) {
480
+ if (approver == null) return "unavailable";
481
+ try {
482
+ return await approver(exec, `recall: search sessions from ALL project directories (query: "${query}")`);
483
+ } catch {
484
+ return "unavailable";
485
+ }
486
+ }
336
487
  return defineTool({
337
488
  name: "recall",
338
489
  description: RECALL_TOOL_DESCRIPTION,
@@ -372,12 +523,31 @@ function createRecallTool(config, engine) {
372
523
  if (query === "") return recallError(query, Object.assign(/* @__PURE__ */ new Error("empty query"), { code: "SESSION_QUERY_INVALID_QUERY" }));
373
524
  const limit = clamp(Math.trunc(args.limit ?? cfg.defaultLimit), 1, cfg.maxLimit);
374
525
  const agentCwd = exec.agent?.session.header?.cwd ?? null;
375
- const wantAll = args.all_projects === true && cfg.allowAllProjects;
526
+ const scopeHints = [];
527
+ let wantAll = false;
528
+ if (args.session_id == null && args.all_projects === true) {
529
+ if (!cfg.allowAllProjects || cfg.allProjectsPolicy === "deny") scopeHints.push("all_projects was ignored: cross-project search is disabled by this deployment. Searched the current project only.");
530
+ else if (cfg.allProjectsPolicy === "confirm") {
531
+ const verdict = await decideAllProjects(exec, query);
532
+ if (verdict === "allowed-once") wantAll = true;
533
+ else scopeHints.push(`all_projects was not approved (${verdict}); searched the current project only.`);
534
+ } else wantAll = true;
535
+ }
376
536
  const scope = {
377
537
  cwd: agentCwd,
378
538
  allProjects: wantAll,
379
539
  sessionId: args.session_id ?? null
380
540
  };
541
+ if (args.session_id == null && !wantAll && agentCwd != null && !cwdAllowed(agentCwd, cfg)) return {
542
+ query,
543
+ scope,
544
+ count: 0,
545
+ hasMore: false,
546
+ items: [],
547
+ nextCursor: null,
548
+ hint: "the current project directory is excluded by the recall scope policy (cwd allowlist/denylist). Ask the user to adjust the plugin configuration if this is unexpected.",
549
+ redacted: 0
550
+ };
381
551
  try {
382
552
  if (args.session_id != null && args.session_id !== "") {
383
553
  const sessionId = SessionId(args.session_id);
@@ -387,13 +557,21 @@ function createRecallTool(config, engine) {
387
557
  limit,
388
558
  cursor: brand(args.cursor)
389
559
  }, { signal: exec.signal });
560
+ if (!cwdAllowed(page.session.cwd ?? null, cfg)) return {
561
+ query,
562
+ scope,
563
+ count: 0,
564
+ hasMore: false,
565
+ items: [],
566
+ nextCursor: null,
567
+ hint: "that session belongs to a project directory excluded by the recall scope policy (cwd allowlist/denylist).",
568
+ redacted: 0
569
+ };
390
570
  let items = eventItems(page, sessionId);
391
571
  let hint = null;
392
572
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
393
- const docs = await engine.filterEvents(sessionId, [{
394
- kind: "text",
395
- text: query
396
- }]);
573
+ const { filters, highlight } = cjkTextFilters(query);
574
+ const docs = await engine.filterEvents(sessionId, filters);
397
575
  if (docs.length > 0) items = docs.slice(0, limit).map((doc) => ({
398
576
  sessionId,
399
577
  id8: id8(sessionId),
@@ -406,19 +584,21 @@ function createRecallTool(config, engine) {
406
584
  seq: doc.seq,
407
585
  type: doc.type,
408
586
  time: doc.time,
409
- snippet: snippetAround(doc.text, query, CJK_SNIPPET_CHARS)
587
+ snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
410
588
  }
411
589
  }));
412
590
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
413
591
  } else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
592
+ const red = applyRedaction(items);
414
593
  return {
415
594
  query,
416
595
  scope,
417
- count: items.length,
596
+ count: red.items.length,
418
597
  hasMore: page.nextCursor != null,
419
- items,
598
+ items: red.items,
420
599
  nextCursor: page.nextCursor ?? null,
421
- hint
600
+ hint: joinHints(hint, red.hint),
601
+ redacted: red.redacted
422
602
  };
423
603
  }
424
604
  const request = {
@@ -432,12 +612,12 @@ function createRecallTool(config, engine) {
432
612
  if (args.cursor != null && args.cursor !== "") request.cursor = brand(args.cursor);
433
613
  const page = await engine.searchSessions(request, { signal: exec.signal });
434
614
  const titles = await titlesFor(engine, page.items.map((hit) => hit.header.id), exec.signal);
435
- let items = toItems(page.items, titles);
615
+ let items = toItems(page.items, titles).filter((item) => cwdAllowed(item.cwd, cfg));
436
616
  let hint = null;
437
617
  let fallbackRan = false;
438
618
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
439
619
  fallbackRan = true;
440
- const scanned = await cjkScanSessions(engine, query, agentCwd, wantAll, cfg.cjkFallbackScanMax, limit, exec.signal);
620
+ const scanned = await cjkScanSessions(engine, query, agentCwd, wantAll, cfg, cfg.cjkFallbackScanMax, limit, exec.signal);
441
621
  const scanTitles = await titlesFor(engine, scanned.map((item) => item.sessionId), exec.signal);
442
622
  items = scanned.map((item) => ({
443
623
  ...item,
@@ -445,14 +625,16 @@ function createRecallTool(config, engine) {
445
625
  }));
446
626
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
447
627
  } else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
628
+ const red = applyRedaction(items);
448
629
  return {
449
630
  query,
450
631
  scope,
451
- count: items.length,
632
+ count: red.items.length,
452
633
  hasMore: !fallbackRan && page.nextCursor != null,
453
- items,
634
+ items: red.items,
454
635
  nextCursor: !fallbackRan ? page.nextCursor ?? null : null,
455
- hint
636
+ hint: joinHints(hint, ...scopeHints, red.hint),
637
+ redacted: red.redacted
456
638
  };
457
639
  } catch (error) {
458
640
  return recallError(query, error);
@@ -482,11 +664,33 @@ function createRecallTool(config, engine) {
482
664
  //#region src/index.ts
483
665
  const name = "session-recall";
484
666
  const inject = ["tools", "sessionQuery"];
667
+ /**
668
+ * Build the optional approval seam for `allProjectsPolicy: 'confirm'`: ask
669
+ * `ctx.approval` when the service is composed and the call carries an agent;
670
+ * fail closed (`'unavailable'`) otherwise. Never throws.
671
+ */
672
+ function makeApprover(ctx) {
673
+ const approval = ctx.approval;
674
+ if (approval == null || typeof approval.request !== "function") return void 0;
675
+ return async (exec, reason) => {
676
+ const agent = exec.agent;
677
+ if (agent == null) return "unavailable";
678
+ try {
679
+ return await approval.request({
680
+ agent,
681
+ toolName: "recall",
682
+ reason
683
+ });
684
+ } catch {
685
+ return "unavailable";
686
+ }
687
+ };
688
+ }
485
689
  /** Plugin entry: mount the `recall` tool on the global tool registry. */
486
690
  function apply(ctx, config) {
487
691
  ctx.effect(function* () {
488
- yield ctx.tools.register(createRecallTool(config, ctx.sessionQuery));
692
+ yield ctx.tools.register(createRecallTool(config, ctx.sessionQuery, makeApprover(ctx)));
489
693
  }, "session-recall lifecycle");
490
694
  }
491
695
  //#endregion
492
- export { RECALL_TOOL_DESCRIPTION, apply, cjkFallbackHint, cjkZeroHitHint, clamp, createRecallTool, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, recallContentBlocks, recallPresentationMeta, renderRecallText, snippetAround };
696
+ export { ALL_PROJECTS_POLICIES, RECALL_TOOL_DESCRIPTION, REDACTION_MODES, apply, cjkFallbackHint, cjkZeroHitHint, clamp, createRecallTool, cwdAllowed, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, normalizeRedactionMode, recallContentBlocks, recallPresentationMeta, redactText, renderRecallText, snippetAround };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-recall",
3
- "description": "Cross-session full-text recall for DeepSeek Harness: the model-facing `recall` tool searches past session transcripts through ctx.sessionQuery",
4
- "version": "0.2.0",
3
+ "description": "Deterministic cross-session transcript retrieval for DeepSeek Harness: the model-facing `recall` tool searches past session logs with explicit scope control",
4
+ "version": "0.4.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },