dsh-session-recall 0.2.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`). Because the FTS `unicode61` tokenizer indexes an uninterrupted CJK run as a single token, a short Chinese phrase inside a longer sentence would otherwise never match the index — so a zero-hit CJK query automatically falls back to an exact substring scan over session text (the `sessionQuery.filterEvents` literal text clause), and the hint reports when that path matched.
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
 
@@ -89,7 +89,7 @@ Every failure returns a friendly `hint` instead of a raw exception: a disabled i
89
89
  ## Known limitations
90
90
 
91
91
  - First search after startup walks the durable logs to build the index (the tool description warns the model); subsequent searches are incremental.
92
- - `unicode61` matches whole tokens/phrases, not substrings — `AI` does not match `BRAID`. CJK queries that get zero full-text hits fall back to an exact substring scan (`filterEvents`), and the hint reports when that path matched; a multi-word CJK phrase still has to survive the tokenizer's whole-run indexing.
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.
93
93
  - One process must own the index file (single-writer SQLite, per the official backend).
94
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
95
 
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`)。因为 FTS 的 `unicode61` 分词器会把连续中文当成一个 token,短中文短语一旦嵌在长句里就匹配不到索引——所以 CJK 查询零命中时会自动回退到对会话文本的**精确子串扫描**(走 `sessionQuery.filterEvents` 的字面文本子句),hint 会说明这条回退路径是否命中。
36
+ 每条命中带会话 id、标题(尽力补全)、日期、命中摘录;结果在 Web UI 里渲染成原生搜索卡片(`SearchMatchesResultView`)。因为 FTS 的 `unicode61` 分词器会把连续中文当成一个 token,短中文短语一旦嵌在长句里就匹配不到索引——所以 CJK 查询零命中时会自动回退到对会话文本的子串扫描(走 `sessionQuery.filterEvents` 的字面文本子句),空格拆出的每个词都必须命中,因此 `简历 模板` 也能找回 `简历模板`;hint 会说明这条回退路径是否命中。
37
37
 
38
38
  ## 授权边界(官方明确留给工具层的责任)
39
39
 
@@ -89,7 +89,7 @@ dsh plugin --profile web add github:kittimzhe/dsh-session-recall
89
89
  ## 已知限制
90
90
 
91
91
  - 启动后第一次搜索会扫全量日志建索引(工具描述里已警告模型);之后增量更新。
92
- - `unicode61` 按完整 token/短语匹配,不支持子串——`AI` 匹配不到 `BRAID`。CJK 查询零命中时会回退到精确子串扫描(`filterEvents`),hint 会说明是否命中;多词中文短语仍受"连续中文=一个 token"的约束。
92
+ - `unicode61` 按完整 token/短语匹配,不支持子串——`AI` 匹配不到 `BRAID`。CJK 查询零命中时会回退到子串扫描(`filterEvents`),空格分隔的各词按 AND 语义都必须命中,因此 `简历 模板` 也能找回 `简历模板`;hint 会说明是否命中。
93
93
  - 索引文件单进程独占(官方后端的单写者 SQLite 约束)。
94
94
  - 命中结果按原文照摘,**没有任何凭据或本地路径脱敏**——更早的会话里粘贴过的 token 或敏感路径可能被检索出来。目前只有默认 cwd 收窄与 `allowAllProjects: false` 两道闸;指纹识别/脱敏是后续增强。
95
95
 
package/lib/index.js CHANGED
@@ -47,6 +47,10 @@ function hasCJK(text) {
47
47
  function normalizeQuery(text) {
48
48
  return text.trim().replaceAll(/\s+/g, " ");
49
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
+ }
50
54
  /** First line of `text` with control characters stripped, clipped to `limit` code points. */
51
55
  function firstLineClipped(text, limit) {
52
56
  const line = text.split("\n", 1)[0] ?? "";
@@ -155,7 +159,7 @@ function cjkZeroHitHint(query, zeroHits, enabled) {
155
159
  const RECALL_TOOL_DESCRIPTION = [
156
160
  "Search the FULL TEXT of past and current session transcripts on this machine (your own conversation history with this user).",
157
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.",
158
- "Matches whole words/phrases for English and code identifiers; a zero-hit Chinese (CJK) query automatically falls back to an exact substring scan. Returns the best-matching event snippet per session plus the session id.",
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.",
159
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.",
160
164
  "Scoping: by default only sessions started in the current project directory; pass all_projects=true to search everywhere.",
161
165
  "The first search after startup may be slow while the index builds."
@@ -241,6 +245,23 @@ function eventItems(page, sessionId) {
241
245
  /** Snippet window kept consistent with the tool's text projection. */
242
246
  const CJK_SNIPPET_CHARS = 120;
243
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
+ /**
244
265
  * CJK substring-scan fallback for zero-hit full-text searches. SQLite FTS5's
245
266
  * `unicode61` tokenizer treats an uninterrupted CJK run as one token, so a
246
267
  * short Chinese phrase inside a longer sentence never matches the index. The
@@ -255,10 +276,8 @@ async function cjkScanSessions(engine, query, agentCwd, wantAll, scanMax, limit,
255
276
  const items = [];
256
277
  for (const record of candidates.slice(0, scanMax)) {
257
278
  if (items.length >= limit) break;
258
- const docs = await engine.filterEvents(record.header.id, [{
259
- kind: "text",
260
- text: query
261
- }]);
279
+ const { filters, highlight } = cjkTextFilters(query);
280
+ const docs = await engine.filterEvents(record.header.id, filters);
262
281
  if (docs.length === 0) continue;
263
282
  const doc = docs[0];
264
283
  if (doc === void 0) continue;
@@ -274,7 +293,7 @@ async function cjkScanSessions(engine, query, agentCwd, wantAll, scanMax, limit,
274
293
  seq: doc.seq,
275
294
  type: doc.type,
276
295
  time: doc.time,
277
- snippet: snippetAround(doc.text, query, CJK_SNIPPET_CHARS)
296
+ snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
278
297
  }
279
298
  });
280
299
  }
@@ -390,10 +409,8 @@ function createRecallTool(config, engine) {
390
409
  let items = eventItems(page, sessionId);
391
410
  let hint = null;
392
411
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
393
- const docs = await engine.filterEvents(sessionId, [{
394
- kind: "text",
395
- text: query
396
- }]);
412
+ const { filters, highlight } = cjkTextFilters(query);
413
+ const docs = await engine.filterEvents(sessionId, filters);
397
414
  if (docs.length > 0) items = docs.slice(0, limit).map((doc) => ({
398
415
  sessionId,
399
416
  id8: id8(sessionId),
@@ -406,7 +423,7 @@ function createRecallTool(config, engine) {
406
423
  seq: doc.seq,
407
424
  type: doc.type,
408
425
  time: doc.time,
409
- snippet: snippetAround(doc.text, query, CJK_SNIPPET_CHARS)
426
+ snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
410
427
  }
411
428
  }));
412
429
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
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.2.0",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },