dsh-session-recall 0.3.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
 
@@ -86,6 +111,19 @@ 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.
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
 
@@ -86,6 +111,19 @@ 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
  - 启动后第一次搜索会扫全量日志建索引(工具描述里已警告模型);之后增量更新。
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. */
@@ -161,7 +249,8 @@ const RECALL_TOOL_DESCRIPTION = [
161
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.",
162
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.",
163
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.",
164
- "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.",
165
254
  "The first search after startup may be slow while the index builds."
166
255
  ].join(" ");
167
256
  function errCode(error) {
@@ -192,7 +281,8 @@ function recallError(query, error) {
192
281
  hasMore: false,
193
282
  items: [],
194
283
  nextCursor: null,
195
- hint: friendlyError(error)
284
+ hint: friendlyError(error),
285
+ redacted: 0
196
286
  };
197
287
  }
198
288
  function brand(value) {
@@ -270,9 +360,9 @@ function cjkTextFilters(query) {
270
360
  * providers, so scanning each scoped session with it recovers the exact
271
361
  * substring matches the full-text index cannot see.
272
362
  */
273
- async function cjkScanSessions(engine, query, agentCwd, wantAll, scanMax, limit, signal) {
363
+ async function cjkScanSessions(engine, query, agentCwd, wantAll, cfg, scanMax, limit, signal) {
274
364
  const all = await engine.listSessions(signal);
275
- 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));
276
366
  const items = [];
277
367
  for (const record of candidates.slice(0, scanMax)) {
278
368
  if (items.length >= limit) break;
@@ -343,15 +433,57 @@ const recallOutputSchema = {
343
433
  }
344
434
  },
345
435
  nextCursor: nullableString,
346
- hint: nullableString
436
+ hint: nullableString,
437
+ redacted: { type: "integer" }
347
438
  }
348
439
  };
349
440
  /**
350
441
  * Build the `recall` ToolDefinition around a concrete sessionQuery engine.
351
442
  * Pure construction — no registration happens here.
352
443
  */
353
- function createRecallTool(config, engine) {
444
+ function createRecallTool(config, engine, approver) {
354
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
+ }
355
487
  return defineTool({
356
488
  name: "recall",
357
489
  description: RECALL_TOOL_DESCRIPTION,
@@ -391,12 +523,31 @@ function createRecallTool(config, engine) {
391
523
  if (query === "") return recallError(query, Object.assign(/* @__PURE__ */ new Error("empty query"), { code: "SESSION_QUERY_INVALID_QUERY" }));
392
524
  const limit = clamp(Math.trunc(args.limit ?? cfg.defaultLimit), 1, cfg.maxLimit);
393
525
  const agentCwd = exec.agent?.session.header?.cwd ?? null;
394
- 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
+ }
395
536
  const scope = {
396
537
  cwd: agentCwd,
397
538
  allProjects: wantAll,
398
539
  sessionId: args.session_id ?? null
399
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
+ };
400
551
  try {
401
552
  if (args.session_id != null && args.session_id !== "") {
402
553
  const sessionId = SessionId(args.session_id);
@@ -406,6 +557,16 @@ function createRecallTool(config, engine) {
406
557
  limit,
407
558
  cursor: brand(args.cursor)
408
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
+ };
409
570
  let items = eventItems(page, sessionId);
410
571
  let hint = null;
411
572
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
@@ -428,14 +589,16 @@ function createRecallTool(config, engine) {
428
589
  }));
429
590
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
430
591
  } else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
592
+ const red = applyRedaction(items);
431
593
  return {
432
594
  query,
433
595
  scope,
434
- count: items.length,
596
+ count: red.items.length,
435
597
  hasMore: page.nextCursor != null,
436
- items,
598
+ items: red.items,
437
599
  nextCursor: page.nextCursor ?? null,
438
- hint
600
+ hint: joinHints(hint, red.hint),
601
+ redacted: red.redacted
439
602
  };
440
603
  }
441
604
  const request = {
@@ -449,12 +612,12 @@ function createRecallTool(config, engine) {
449
612
  if (args.cursor != null && args.cursor !== "") request.cursor = brand(args.cursor);
450
613
  const page = await engine.searchSessions(request, { signal: exec.signal });
451
614
  const titles = await titlesFor(engine, page.items.map((hit) => hit.header.id), exec.signal);
452
- let items = toItems(page.items, titles);
615
+ let items = toItems(page.items, titles).filter((item) => cwdAllowed(item.cwd, cfg));
453
616
  let hint = null;
454
617
  let fallbackRan = false;
455
618
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
456
619
  fallbackRan = true;
457
- 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);
458
621
  const scanTitles = await titlesFor(engine, scanned.map((item) => item.sessionId), exec.signal);
459
622
  items = scanned.map((item) => ({
460
623
  ...item,
@@ -462,14 +625,16 @@ function createRecallTool(config, engine) {
462
625
  }));
463
626
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
464
627
  } else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
628
+ const red = applyRedaction(items);
465
629
  return {
466
630
  query,
467
631
  scope,
468
- count: items.length,
632
+ count: red.items.length,
469
633
  hasMore: !fallbackRan && page.nextCursor != null,
470
- items,
634
+ items: red.items,
471
635
  nextCursor: !fallbackRan ? page.nextCursor ?? null : null,
472
- hint
636
+ hint: joinHints(hint, ...scopeHints, red.hint),
637
+ redacted: red.redacted
473
638
  };
474
639
  } catch (error) {
475
640
  return recallError(query, error);
@@ -499,11 +664,33 @@ function createRecallTool(config, engine) {
499
664
  //#region src/index.ts
500
665
  const name = "session-recall";
501
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
+ }
502
689
  /** Plugin entry: mount the `recall` tool on the global tool registry. */
503
690
  function apply(ctx, config) {
504
691
  ctx.effect(function* () {
505
- yield ctx.tools.register(createRecallTool(config, ctx.sessionQuery));
692
+ yield ctx.tools.register(createRecallTool(config, ctx.sessionQuery, makeApprover(ctx)));
506
693
  }, "session-recall lifecycle");
507
694
  }
508
695
  //#endregion
509
- 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.3.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
  },