dsh-session-recall 0.1.0 → 0.3.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 +26 -3
- package/README.zh.md +26 -3
- package/lib/index.d.ts +24 -5
- package/lib/index.js +130 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ recall({ query, session_id }) → search the events of one session
|
|
|
33
33
|
recall({ query, limit, cursor }) → page through results
|
|
34
34
|
```
|
|
35
35
|
|
|
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`).
|
|
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 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
37
|
|
|
38
38
|
## Scoping (the authorization gap)
|
|
39
39
|
|
|
@@ -77,7 +77,9 @@ Plugin row config (all optional):
|
|
|
77
77
|
allowAllProjects: true # honor the tool's all_projects argument
|
|
78
78
|
defaultLimit: 5 # page size when the model omits limit (1..10)
|
|
79
79
|
maxLimit: 10 # largest accepted page size (1..25)
|
|
80
|
-
cjkHint: true # zero-hit
|
|
80
|
+
cjkHint: true # explain CJK zero-hit results
|
|
81
|
+
cjkFallback: true # CJK zero-hit → exact substring scan over session text
|
|
82
|
+
cjkFallbackScanMax: 50 # max sessions scanned per cross-session fallback (1..500)
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
## Failure behavior
|
|
@@ -87,8 +89,29 @@ Every failure returns a friendly `hint` instead of a raw exception: a disabled i
|
|
|
87
89
|
## Known limitations
|
|
88
90
|
|
|
89
91
|
- First search after startup walks the durable logs to build the index (the tool description warns the model); subsequent searches are incremental.
|
|
90
|
-
- `unicode61` matches whole tokens/phrases, not substrings — `AI` does not match `BRAID`.
|
|
92
|
+
- `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.
|
|
91
93
|
- One process must own the index file (single-writer SQLite, per the official backend).
|
|
94
|
+
- 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
|
+
|
|
96
|
+
## Benchmark
|
|
97
|
+
|
|
98
|
+
Measured on a real headless profile (Node 25, Apple Silicon, warm filesystem cache).
|
|
99
|
+
|
|
100
|
+
| Corpus | |
|
|
101
|
+
|---|---|
|
|
102
|
+
| Sessions | 31 |
|
|
103
|
+
| Events in the durable logs | 187,706 (~104 MB uncompressed, 49.7 MB zstd) |
|
|
104
|
+
| Indexed text events | 8,187 |
|
|
105
|
+
| FTS index on disk | 15 MB |
|
|
106
|
+
|
|
107
|
+
| Query | Hits | Warm latency (FTS5 `MATCH`) |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| EN `font` | 100 (capped) | 1.1 ms |
|
|
110
|
+
| EN `resume template` | 46 | 0.6 ms |
|
|
111
|
+
| CN `字体` | 29 | 0.3 ms |
|
|
112
|
+
| CN `简历 模板` | 10 | 0.1 ms |
|
|
113
|
+
|
|
114
|
+
Warm searches run sub-millisecond to ~1.5 ms against the on-disk index. Cold start: scanning the 49.7 MB of session logs takes ~4.7 s (decompress + line scan) and inserting the 8,187 text events into a fresh FTS5 table takes ~190 ms; the first `recall` in a fresh profile completes within the tool's 10 s timeout. After that, restarts reuse the persisted index with incremental reconciliation.
|
|
92
115
|
|
|
93
116
|
## Development
|
|
94
117
|
|
package/README.zh.md
CHANGED
|
@@ -33,7 +33,7 @@ recall({ query, session_id }) → 只搜指定会话内的事件
|
|
|
33
33
|
recall({ query, limit, cursor }) → 翻页
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
每条命中带会话 id、标题(尽力补全)、日期、命中摘录;结果在 Web UI 里渲染成原生搜索卡片(`SearchMatchesResultView
|
|
36
|
+
每条命中带会话 id、标题(尽力补全)、日期、命中摘录;结果在 Web UI 里渲染成原生搜索卡片(`SearchMatchesResultView`)。因为 FTS 的 `unicode61` 分词器会把连续中文当成一个 token,短中文短语一旦嵌在长句里就匹配不到索引——所以 CJK 查询零命中时会自动回退到对会话文本的子串扫描(走 `sessionQuery.filterEvents` 的字面文本子句),空格拆出的每个词都必须命中,因此 `简历 模板` 也能找回 `简历模板`;hint 会说明这条回退路径是否命中。
|
|
37
37
|
|
|
38
38
|
## 授权边界(官方明确留给工具层的责任)
|
|
39
39
|
|
|
@@ -77,7 +77,9 @@ dsh plugin --profile web add github:kittimzhe/dsh-session-recall
|
|
|
77
77
|
allowAllProjects: true # 是否允许工具的 all_projects 参数
|
|
78
78
|
defaultLimit: 5 # 模型省略 limit 时的页大小(1..10)
|
|
79
79
|
maxLimit: 10 # 最大页大小(1..25)
|
|
80
|
-
cjkHint: true # CJK
|
|
80
|
+
cjkHint: true # CJK 零命中的提示开关
|
|
81
|
+
cjkFallback: true # CJK 零命中 → 对会话文本做精确子串扫描
|
|
82
|
+
cjkFallbackScanMax: 50 # 跨会话回退时最多扫描的会话数(1..500)
|
|
81
83
|
```
|
|
82
84
|
|
|
83
85
|
## 失败行为
|
|
@@ -87,8 +89,29 @@ dsh plugin --profile web add github:kittimzhe/dsh-session-recall
|
|
|
87
89
|
## 已知限制
|
|
88
90
|
|
|
89
91
|
- 启动后第一次搜索会扫全量日志建索引(工具描述里已警告模型);之后增量更新。
|
|
90
|
-
- `unicode61` 按完整 token/短语匹配,不支持子串——`AI` 匹配不到 `BRAID`。CJK
|
|
92
|
+
- `unicode61` 按完整 token/短语匹配,不支持子串——`AI` 匹配不到 `BRAID`。CJK 查询零命中时会回退到子串扫描(`filterEvents`),空格分隔的各词按 AND 语义都必须命中,因此 `简历 模板` 也能找回 `简历模板`;hint 会说明是否命中。
|
|
91
93
|
- 索引文件单进程独占(官方后端的单写者 SQLite 约束)。
|
|
94
|
+
- 命中结果按原文照摘,**没有任何凭据或本地路径脱敏**——更早的会话里粘贴过的 token 或敏感路径可能被检索出来。目前只有默认 cwd 收窄与 `allowAllProjects: false` 两道闸;指纹识别/脱敏是后续增强。
|
|
95
|
+
|
|
96
|
+
## 基准
|
|
97
|
+
|
|
98
|
+
真机 headless profile 实测(Node 25,Apple Silicon,暖文件缓存)。
|
|
99
|
+
|
|
100
|
+
| 语料 | |
|
|
101
|
+
|---|---|
|
|
102
|
+
| 会话数 | 31 |
|
|
103
|
+
| 日志总事件 | 187,706(解压后约 104 MB,zstd 压缩后 49.7 MB) |
|
|
104
|
+
| 已索引文本事件 | 8,187 |
|
|
105
|
+
| FTS 索引体积 | 15 MB |
|
|
106
|
+
|
|
107
|
+
| 查询 | 命中 | 暖查询耗时(FTS5 `MATCH`) |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| 英文 `font` | 100(上限) | 1.1 ms |
|
|
110
|
+
| 英文 `resume template` | 46 | 0.6 ms |
|
|
111
|
+
| 中文 `字体` | 29 | 0.3 ms |
|
|
112
|
+
| 中文 `简历 模板` | 10 | 0.1 ms |
|
|
113
|
+
|
|
114
|
+
暖查询(索引已在盘上)耗时 0.1~1.5 ms。冷启动首建:扫描 49.7 MB 日志约 4.7 s(解压 + 逐行扫描),把 8,187 条文本事件写入全新 FTS5 表约 190 ms;全新 profile 的首次 `recall` 在工具 10 s 超时内完成。此后重启复用持久化索引、增量 reconcile。
|
|
92
115
|
|
|
93
116
|
## 开发
|
|
94
117
|
|
package/lib/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ToolDefinition } from "@deepseek-ai/dsh-tools";
|
|
2
|
-
import { SessionEventSearchPage, SessionEventSearchRequest, SessionSearchExecContext, SessionSearchHit, SessionSearchPage, SessionSearchRequest, SessionTitleObservationResult } from "@deepseek-ai/dsh-session-query";
|
|
3
|
-
import { JsonValue } from "@deepseek-ai/dsh-session";
|
|
2
|
+
import { SessionEventResultFilter, SessionEventSearchDocument, SessionEventSearchPage, SessionEventSearchRequest, SessionRecord, SessionSearchExecContext, SessionSearchHit, SessionSearchPage, SessionSearchRequest, SessionTitleObservationResult } from "@deepseek-ai/dsh-session-query";
|
|
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
6
|
//#region src/config.d.ts
|
|
@@ -12,8 +12,12 @@ interface RecallConfig {
|
|
|
12
12
|
defaultLimit?: number;
|
|
13
13
|
/** Largest accepted page size. Default `10`. */
|
|
14
14
|
maxLimit?: number;
|
|
15
|
-
/**
|
|
15
|
+
/** Explain CJK zero-hit results with a hint line. Default `true`. */
|
|
16
16
|
cjkHint?: boolean;
|
|
17
|
+
/** Fall back to an exact substring scan when a CJK query gets no full-text hits. Default `true`. */
|
|
18
|
+
cjkFallback?: boolean;
|
|
19
|
+
/** Max sessions to scan on a cross-session CJK fallback. Default `50`. */
|
|
20
|
+
cjkFallbackScanMax?: number;
|
|
17
21
|
}
|
|
18
22
|
/** Validated, fully defaulted configuration. */
|
|
19
23
|
interface NormalizedRecallConfig {
|
|
@@ -21,6 +25,8 @@ interface NormalizedRecallConfig {
|
|
|
21
25
|
readonly defaultLimit: number;
|
|
22
26
|
readonly maxLimit: number;
|
|
23
27
|
readonly cjkHint: boolean;
|
|
28
|
+
readonly cjkFallback: boolean;
|
|
29
|
+
readonly cjkFallbackScanMax: number;
|
|
24
30
|
}
|
|
25
31
|
/** Default, clamp, and cross-check every optional field. */
|
|
26
32
|
declare function normalizeRecallConfig(config?: RecallConfig): NormalizedRecallConfig;
|
|
@@ -76,6 +82,8 @@ interface RecallQueryEngine {
|
|
|
76
82
|
searchSessions(request: SessionSearchRequest, exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionSearchHit>>;
|
|
77
83
|
searchEvents(request: SessionEventSearchRequest, exec?: SessionSearchExecContext): Promise<SessionEventSearchPage>;
|
|
78
84
|
readTitleSnapshots(sessionIds: readonly string[], signal?: AbortSignal): Promise<SessionTitleObservationResult[]>;
|
|
85
|
+
listSessions(signal?: AbortSignal): Promise<SessionRecord[]>;
|
|
86
|
+
filterEvents(sessionId: SessionId, filters: readonly SessionEventResultFilter[]): Promise<SessionEventSearchDocument[]>;
|
|
79
87
|
}
|
|
80
88
|
declare const RECALL_TOOL_DESCRIPTION: string;
|
|
81
89
|
/**
|
|
@@ -94,7 +102,12 @@ declare function renderRecallText(result: RecallResult): string;
|
|
|
94
102
|
declare function recallContentBlocks(result: RecallResult): ContentBlock[];
|
|
95
103
|
/** Replayable presentation payload consumed by `presentResult` on the UI plane. */
|
|
96
104
|
declare function recallPresentationMeta(result: RecallResult): JsonValue;
|
|
97
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Hint shown when the CJK substring fallback succeeded — explains why the
|
|
107
|
+
* full-text index missed the query and reports how many sessions matched.
|
|
108
|
+
*/
|
|
109
|
+
declare function cjkFallbackHint(matched: number, enabled: boolean): string | null;
|
|
110
|
+
/** The zero-hit hint, shown only when both the full-text and substring paths miss. */
|
|
98
111
|
declare function cjkZeroHitHint(query: string, zeroHits: boolean, enabled: boolean): string | null;
|
|
99
112
|
//#endregion
|
|
100
113
|
//#region src/util.d.ts
|
|
@@ -114,6 +127,12 @@ declare function hasCJK(text: string): boolean;
|
|
|
114
127
|
declare function normalizeQuery(text: string): string;
|
|
115
128
|
/** First line of `text` with control characters stripped, clipped to `limit` code points. */
|
|
116
129
|
declare function firstLineClipped(text: string, limit: number): string;
|
|
130
|
+
/**
|
|
131
|
+
* Single-line snippet clipped around the first case-insensitive occurrence of
|
|
132
|
+
* `query`, with ellipses at either end when text was cut. Falls back to a
|
|
133
|
+
* head clip when the query is empty or absent.
|
|
134
|
+
*/
|
|
135
|
+
declare function snippetAround(text: string, query: string, limit: number): string;
|
|
117
136
|
//#endregion
|
|
118
137
|
//#region src/index.d.ts
|
|
119
138
|
declare const name = "session-recall";
|
|
@@ -121,4 +140,4 @@ declare const inject: string[];
|
|
|
121
140
|
/** Plugin entry: mount the `recall` tool on the global tool registry. */
|
|
122
141
|
declare function apply(ctx: Context, config?: RecallConfig): void;
|
|
123
142
|
//#endregion
|
|
124
|
-
export { type NormalizedRecallConfig, RECALL_TOOL_DESCRIPTION, type RecallArgs, type RecallBestMatch, type RecallConfig, type RecallItem, type RecallQueryEngine, type RecallResult, type RecallScope, apply, cjkZeroHitHint, clamp, createRecallTool, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, recallContentBlocks, recallPresentationMeta, renderRecallText };
|
|
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 };
|
package/lib/index.js
CHANGED
|
@@ -12,7 +12,9 @@ function normalizeRecallConfig(config) {
|
|
|
12
12
|
allowAllProjects: config?.allowAllProjects !== false,
|
|
13
13
|
defaultLimit,
|
|
14
14
|
maxLimit: Math.max(defaultLimit, intIn(config?.maxLimit, 10, 1, 25)),
|
|
15
|
-
cjkHint: config?.cjkHint !== false
|
|
15
|
+
cjkHint: config?.cjkHint !== false,
|
|
16
|
+
cjkFallback: config?.cjkFallback !== false,
|
|
17
|
+
cjkFallbackScanMax: intIn(config?.cjkFallbackScanMax, 50, 1, 500)
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
//#endregion
|
|
@@ -45,6 +47,10 @@ function hasCJK(text) {
|
|
|
45
47
|
function normalizeQuery(text) {
|
|
46
48
|
return text.trim().replaceAll(/\s+/g, " ");
|
|
47
49
|
}
|
|
50
|
+
/** Split into whitespace-separated terms, dropping empty pieces. */
|
|
51
|
+
function splitTerms(text) {
|
|
52
|
+
return text.split(/\s+/u).filter((term) => term.length > 0);
|
|
53
|
+
}
|
|
48
54
|
/** First line of `text` with control characters stripped, clipped to `limit` code points. */
|
|
49
55
|
function firstLineClipped(text, limit) {
|
|
50
56
|
const line = text.split("\n", 1)[0] ?? "";
|
|
@@ -56,6 +62,21 @@ function firstLineClipped(text, limit) {
|
|
|
56
62
|
}
|
|
57
63
|
return out;
|
|
58
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Single-line snippet clipped around the first case-insensitive occurrence of
|
|
67
|
+
* `query`, with ellipses at either end when text was cut. Falls back to a
|
|
68
|
+
* head clip when the query is empty or absent.
|
|
69
|
+
*/
|
|
70
|
+
function snippetAround(text, query, limit) {
|
|
71
|
+
const flat = text.replaceAll(/\s+/g, " ");
|
|
72
|
+
const q = normalizeQuery(query).toLowerCase();
|
|
73
|
+
const idx = q === "" ? -1 : flat.toLowerCase().indexOf(q);
|
|
74
|
+
if (idx < 0) return flat.slice(0, limit);
|
|
75
|
+
const pad = Math.floor(limit / 3);
|
|
76
|
+
const start = Math.max(0, idx - pad);
|
|
77
|
+
const end = Math.min(flat.length, start + limit);
|
|
78
|
+
return `${start > 0 ? "…" : ""}${flat.slice(start, end)}${end < flat.length ? "…" : ""}`;
|
|
79
|
+
}
|
|
59
80
|
//#endregion
|
|
60
81
|
//#region src/render.ts
|
|
61
82
|
const SNIPPET_CHARS = 120;
|
|
@@ -108,10 +129,18 @@ function recallPresentationMeta(result) {
|
|
|
108
129
|
}))
|
|
109
130
|
};
|
|
110
131
|
}
|
|
111
|
-
/**
|
|
132
|
+
/**
|
|
133
|
+
* Hint shown when the CJK substring fallback succeeded — explains why the
|
|
134
|
+
* full-text index missed the query and reports how many sessions matched.
|
|
135
|
+
*/
|
|
136
|
+
function cjkFallbackHint(matched, enabled) {
|
|
137
|
+
if (!enabled || matched <= 0) return null;
|
|
138
|
+
return `full-text search returned no CJK hits (SQLite FTS5 tokenizer "unicode61" does not segment CJK), so an exact substring scan over session text was used instead and matched ${matched} session(s).`;
|
|
139
|
+
}
|
|
140
|
+
/** The zero-hit hint, shown only when both the full-text and substring paths miss. */
|
|
112
141
|
function cjkZeroHitHint(query, zeroHits, enabled) {
|
|
113
142
|
if (!enabled || !zeroHits || !hasCJK(query)) return null;
|
|
114
|
-
return
|
|
143
|
+
return `no matches for this CJK query. Try a shorter phrase, or an English/code term.`;
|
|
115
144
|
}
|
|
116
145
|
//#endregion
|
|
117
146
|
//#region src/tool.ts
|
|
@@ -130,7 +159,7 @@ function cjkZeroHitHint(query, zeroHits, enabled) {
|
|
|
130
159
|
const RECALL_TOOL_DESCRIPTION = [
|
|
131
160
|
"Search the FULL TEXT of past and current session transcripts on this machine (your own conversation history with this user).",
|
|
132
161
|
"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.",
|
|
133
|
-
"Matches whole words/phrases
|
|
162
|
+
"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.",
|
|
134
163
|
"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.",
|
|
135
164
|
"Scoping: by default only sessions started in the current project directory; pass all_projects=true to search everywhere.",
|
|
136
165
|
"The first search after startup may be slow while the index builds."
|
|
@@ -213,6 +242,63 @@ function eventItems(page, sessionId) {
|
|
|
213
242
|
}
|
|
214
243
|
}));
|
|
215
244
|
}
|
|
245
|
+
/** Snippet window kept consistent with the tool's text projection. */
|
|
246
|
+
const CJK_SNIPPET_CHARS = 120;
|
|
247
|
+
/**
|
|
248
|
+
* Decompose a query into ANDed literal-text clauses. A space-separated CJK
|
|
249
|
+
* query like "简历 模板" must match each term as a substring (so it recovers
|
|
250
|
+
* "简历模板"), not a whitespace-joined literal (which would require the space
|
|
251
|
+
* to be present verbatim). `highlight` is the first term, guaranteed present
|
|
252
|
+
* whenever the ANDed scan matches.
|
|
253
|
+
*/
|
|
254
|
+
function cjkTextFilters(query) {
|
|
255
|
+
const terms = splitTerms(query);
|
|
256
|
+
return {
|
|
257
|
+
filters: terms.map((term) => ({
|
|
258
|
+
kind: "text",
|
|
259
|
+
text: term
|
|
260
|
+
})),
|
|
261
|
+
highlight: terms[0] ?? query
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* CJK substring-scan fallback for zero-hit full-text searches. SQLite FTS5's
|
|
266
|
+
* `unicode61` tokenizer treats an uninterrupted CJK run as one token, so a
|
|
267
|
+
* short Chinese phrase inside a longer sentence never matches the index. The
|
|
268
|
+
* official sessionQuery service's `filterEvents` text clause is a literal
|
|
269
|
+
* Unicode/case-insensitive regex scan that is deliberately independent of FTS
|
|
270
|
+
* providers, so scanning each scoped session with it recovers the exact
|
|
271
|
+
* substring matches the full-text index cannot see.
|
|
272
|
+
*/
|
|
273
|
+
async function cjkScanSessions(engine, query, agentCwd, wantAll, scanMax, limit, signal) {
|
|
274
|
+
const all = await engine.listSessions(signal);
|
|
275
|
+
const candidates = !wantAll && agentCwd != null ? all.filter((record) => record.header.cwd === agentCwd) : all;
|
|
276
|
+
const items = [];
|
|
277
|
+
for (const record of candidates.slice(0, scanMax)) {
|
|
278
|
+
if (items.length >= limit) break;
|
|
279
|
+
const { filters, highlight } = cjkTextFilters(query);
|
|
280
|
+
const docs = await engine.filterEvents(record.header.id, filters);
|
|
281
|
+
if (docs.length === 0) continue;
|
|
282
|
+
const doc = docs[0];
|
|
283
|
+
if (doc === void 0) continue;
|
|
284
|
+
items.push({
|
|
285
|
+
sessionId: record.header.id,
|
|
286
|
+
id8: id8(record.header.id),
|
|
287
|
+
title: null,
|
|
288
|
+
createdAt: record.header.createdAt,
|
|
289
|
+
cwd: record.header.cwd ?? null,
|
|
290
|
+
live: record.live,
|
|
291
|
+
persisted: record.persisted,
|
|
292
|
+
bestMatch: {
|
|
293
|
+
seq: doc.seq,
|
|
294
|
+
type: doc.type,
|
|
295
|
+
time: doc.time,
|
|
296
|
+
snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return items;
|
|
301
|
+
}
|
|
216
302
|
const nullableString = { oneOf: [{ type: "string" }, { type: "null" }] };
|
|
217
303
|
const recallOutputSchema = {
|
|
218
304
|
type: "object",
|
|
@@ -320,7 +406,28 @@ function createRecallTool(config, engine) {
|
|
|
320
406
|
limit,
|
|
321
407
|
cursor: brand(args.cursor)
|
|
322
408
|
}, { signal: exec.signal });
|
|
323
|
-
|
|
409
|
+
let items = eventItems(page, sessionId);
|
|
410
|
+
let hint = null;
|
|
411
|
+
if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
|
|
412
|
+
const { filters, highlight } = cjkTextFilters(query);
|
|
413
|
+
const docs = await engine.filterEvents(sessionId, filters);
|
|
414
|
+
if (docs.length > 0) items = docs.slice(0, limit).map((doc) => ({
|
|
415
|
+
sessionId,
|
|
416
|
+
id8: id8(sessionId),
|
|
417
|
+
title: null,
|
|
418
|
+
createdAt: page.session.createdAt,
|
|
419
|
+
cwd: page.session.cwd ?? null,
|
|
420
|
+
live: true,
|
|
421
|
+
persisted: false,
|
|
422
|
+
bestMatch: {
|
|
423
|
+
seq: doc.seq,
|
|
424
|
+
type: doc.type,
|
|
425
|
+
time: doc.time,
|
|
426
|
+
snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
|
|
427
|
+
}
|
|
428
|
+
}));
|
|
429
|
+
hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
|
|
430
|
+
} else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
|
|
324
431
|
return {
|
|
325
432
|
query,
|
|
326
433
|
scope,
|
|
@@ -328,7 +435,7 @@ function createRecallTool(config, engine) {
|
|
|
328
435
|
hasMore: page.nextCursor != null,
|
|
329
436
|
items,
|
|
330
437
|
nextCursor: page.nextCursor ?? null,
|
|
331
|
-
hint
|
|
438
|
+
hint
|
|
332
439
|
};
|
|
333
440
|
}
|
|
334
441
|
const request = {
|
|
@@ -342,15 +449,27 @@ function createRecallTool(config, engine) {
|
|
|
342
449
|
if (args.cursor != null && args.cursor !== "") request.cursor = brand(args.cursor);
|
|
343
450
|
const page = await engine.searchSessions(request, { signal: exec.signal });
|
|
344
451
|
const titles = await titlesFor(engine, page.items.map((hit) => hit.header.id), exec.signal);
|
|
345
|
-
|
|
452
|
+
let items = toItems(page.items, titles);
|
|
453
|
+
let hint = null;
|
|
454
|
+
let fallbackRan = false;
|
|
455
|
+
if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
|
|
456
|
+
fallbackRan = true;
|
|
457
|
+
const scanned = await cjkScanSessions(engine, query, agentCwd, wantAll, cfg.cjkFallbackScanMax, limit, exec.signal);
|
|
458
|
+
const scanTitles = await titlesFor(engine, scanned.map((item) => item.sessionId), exec.signal);
|
|
459
|
+
items = scanned.map((item) => ({
|
|
460
|
+
...item,
|
|
461
|
+
title: scanTitles.get(item.sessionId) ?? null
|
|
462
|
+
}));
|
|
463
|
+
hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
|
|
464
|
+
} else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
|
|
346
465
|
return {
|
|
347
466
|
query,
|
|
348
467
|
scope,
|
|
349
468
|
count: items.length,
|
|
350
|
-
hasMore: page.nextCursor != null,
|
|
469
|
+
hasMore: !fallbackRan && page.nextCursor != null,
|
|
351
470
|
items,
|
|
352
|
-
nextCursor: page.nextCursor ?? null,
|
|
353
|
-
hint
|
|
471
|
+
nextCursor: !fallbackRan ? page.nextCursor ?? null : null,
|
|
472
|
+
hint
|
|
354
473
|
};
|
|
355
474
|
} catch (error) {
|
|
356
475
|
return recallError(query, error);
|
|
@@ -387,4 +506,4 @@ function apply(ctx, config) {
|
|
|
387
506
|
}, "session-recall lifecycle");
|
|
388
507
|
}
|
|
389
508
|
//#endregion
|
|
390
|
-
export { RECALL_TOOL_DESCRIPTION, apply, cjkZeroHitHint, clamp, createRecallTool, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, recallContentBlocks, recallPresentationMeta, renderRecallText };
|
|
509
|
+
export { RECALL_TOOL_DESCRIPTION, apply, cjkFallbackHint, cjkZeroHitHint, clamp, createRecallTool, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, recallContentBlocks, recallPresentationMeta, renderRecallText, snippetAround };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-recall",
|
|
3
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.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|