dsh-session-recall 0.3.0 → 0.4.1
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 +42 -4
- package/README.zh.md +42 -4
- package/lib/index.d.ts +49 -4
- package/lib/index.js +205 -18
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
# dsh-session-recall
|
|
2
2
|
|
|
3
|
-
English | [中文](README.zh.md)
|
|
3
|
+
English | [中文](https://github.com/kittimzhe/dsh-session-recall/blob/main/README.zh.md)
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/dsh-session-recall) [](LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-session-recall) [](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
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.
|
|
@@ -124,4 +162,4 @@ npm run bundle # tsdown → lib/
|
|
|
124
162
|
|
|
125
163
|
## License
|
|
126
164
|
|
|
127
|
-
[MIT](LICENSE)
|
|
165
|
+
[MIT](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
|
package/README.zh.md
CHANGED
|
@@ -1,10 +1,35 @@
|
|
|
1
1
|
# dsh-session-recall
|
|
2
2
|
|
|
3
|
-
[English](README.md) | 中文
|
|
3
|
+
[English](https://github.com/kittimzhe/dsh-session-recall/blob/main/README.md) | 中文
|
|
4
4
|
|
|
5
|
-
[](https://www.npmjs.com/package/dsh-session-recall) [](LICENSE)
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-session-recall) [](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
|
|
6
6
|
|
|
7
|
-
DeepSeek Harness
|
|
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
|
- 启动后第一次搜索会扫全量日志建索引(工具描述里已警告模型);之后增量更新。
|
|
@@ -124,4 +162,4 @@ npm run bundle # tsdown → lib/
|
|
|
124
162
|
|
|
125
163
|
## 许可
|
|
126
164
|
|
|
127
|
-
[MIT](LICENSE)
|
|
165
|
+
[MIT](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
|
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
|
-
/**
|
|
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
|
|
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": "
|
|
4
|
-
"version": "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.1",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|