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 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`). Zero-hit CJK queries get a tokenizer hint: the FTS `unicode61` tokenizer indexes uninterrupted CJK runs as single tokens, so the tool teaches the model to retry with short space-separated keywords.
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 CJK tokenizer workaround hint
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`. The zero-hit CJK hint mitigates the worst case; a substring fallback via `filterEvents()` is a possible v2.
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`)。CJK 查询零命中时会给出分词提示:FTS 的 `unicode61` 分词器把连续中文当成一个 token,工具会教模型改用空格分隔的短关键词重试。
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 零命中提示缓解了最坏情况;基于 `filterEvents()` 的子串兜底是 v2 方向。
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
- /** Teach the model the CJK tokenizer workaround on zero hits. Default `true`. */
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
- /** The zero-hit CJK tokenizer hint, or `null` when not applicable. */
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
- /** The zero-hit CJK tokenizer hint, or `null` when not applicable. */
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 "no hits for a CJK query — the FTS unicode61 tokenizer indexes uninterrupted CJK runs as single tokens. Retry with short space-separated keywords (e.g. \"简历 模板\" instead of \"我的简历模板在这里\"), or an English term.";
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 (English and code identifiers work best); returns the best-matching event snippet per session plus the session id.",
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
- const items = eventItems(page, sessionId);
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: cjkZeroHitHint(query, items.length === 0, cfg.cjkHint)
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
- const items = toItems(page.items, titles);
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: cjkZeroHitHint(query, items.length === 0, cfg.cjkHint)
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.1.0",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },