dsh-session-recall 0.4.0 → 0.5.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
@@ -1,8 +1,8 @@
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
- [![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)
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)](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
8
 
@@ -27,8 +27,6 @@ If you need agent memory orchestration, use a memory framework; if you need boun
27
27
 
28
28
  ## Roadmap
29
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
30
  - **P2: evidence handoff** — one-click bridge to session export for matched sessions.
33
31
 
34
32
  ## Why
@@ -120,10 +118,14 @@ Deployment-level controls for what the model may read back:
120
118
  | `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
119
  | `cwdAllowlist` | list of paths | (none) | Only sessions started in these directories are searchable; the calling cwd itself must be listed. |
122
120
  | `cwdDenylist` | list of paths | (none) | These directories are never searchable. Deny wins over allow. |
121
+ | `recencyHalfLifeDays` | days (e.g. `30`) | (off) | Re-rank cross-session hits: backend rank × exponential recency decay over the match time. Unset or `<= 0` keeps backend order. Per result page. |
122
+ | `pinnedCwds` | list of paths | (none) | Sessions from these project directories rank first, as a group. |
123
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
124
 
125
125
  All three gates apply uniformly to cross-session hits, the CJK fallback scan, and `session_id` reads — no bypass route.
126
126
 
127
+ Every result also carries a `diagnostics` object (v0.5): which engine produced the matches (`fts` / `cjk-fallback` / `session-scan`), how many sessions a fallback scan visited against its budget, and whether re-ranking was applied — so callers can tell *why* they got what they got.
128
+
127
129
  ## Known limitations
128
130
 
129
131
  - First search after startup walks the durable logs to build the index (the tool description warns the model); subsequent searches are incremental.
@@ -162,4 +164,4 @@ npm run bundle # tsdown → lib/
162
164
 
163
165
  ## License
164
166
 
165
- [MIT](LICENSE)
167
+ [MIT](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
package/README.zh.md CHANGED
@@ -1,8 +1,8 @@
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
- [![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)
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)](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
6
6
 
7
7
  DeepSeek Harness 的**确定性跨会话全文检索**插件:注册模型可调用的 `recall` 工具,让 agent 能**检索自己过往的会话原文**——"上周修的那个 bug"、"简历选的什么字体"——全部通过可信的 `ctx.sessionQuery` 缝完成。
8
8
 
@@ -27,8 +27,6 @@ DeepSeek Harness 的**确定性跨会话全文检索**插件:注册模型可
27
27
 
28
28
  ## 路线图
29
29
 
30
- - **P1:排序策略可配** —— 在 FTS 相关度之上增加时间衰减、会话 pin 权重。
31
- - **P1:查询诊断元数据** —— 返回命中来源(fts/cjk-fallback/filters)与扫描预算。
32
30
  - **P2:证据联动导出** —— 命中后可一键触发对应会话导出。
33
31
 
34
32
  ## 为什么做这个
@@ -120,10 +118,14 @@ dsh plugin --profile web add github:kittimzhe/dsh-session-recall
120
118
  | `redactionMode` | `off` / `mask` / `hash` | `off` | 对标题与摘录中疑似密钥的文本(Bearer 头、前缀式 API key、私钥块、邮箱)脱敏。`hash` 用确定性摘要 `#xxxxxxxx`(同一密钥同一标记)保持可比性。结果带 `redacted` 计数。 |
121
119
  | `cwdAllowlist` | 路径列表 | (无) | 只检索这些目录下启动的会话;当前项目目录本身也必须在列表内。 |
122
120
  | `cwdDenylist` | 路径列表 | (无) | 这些目录永不检索。deny 优先于 allow。 |
121
+ | `recencyHalfLifeDays` | 天数(如 `30`) | (关闭) | 跨会话命中重排:后端名次 × 命中时间上的指数衰减。不设或 `<= 0` 保持后端顺序。按结果页生效。 |
122
+ | `pinnedCwds` | 路径列表 | (无) | 这些项目目录的会话作为一组排在最前。 |
123
123
  | `allProjectsPolicy` | `allow` / `deny` / `confirm` | `allow` | `deny` 忽略 `all_projects` 并向模型说明;`confirm` 走官方 `@deepseek-ai/dsh-user-approval` 接缝向用户请求批准——无应答者时 fail-closed。 |
124
124
 
125
125
  三道闸门统一作用于跨会话命中、CJK 回退扫描和 `session_id` 直读——没有绕行路径。
126
126
 
127
+ 每个结果还带 `diagnostics` 对象(v0.5):命中由哪个引擎产生(`fts` / `cjk-fallback` / `session-scan`)、回退扫描访问了几个会话(对照预算)、是否做了重排——让调用方知道「为什么是这些结果」。
128
+
127
129
  ## 已知限制
128
130
 
129
131
  - 启动后第一次搜索会扫全量日志建索引(工具描述里已警告模型);之后增量更新。
@@ -162,4 +164,4 @@ npm run bundle # tsdown → lib/
162
164
 
163
165
  ## 许可
164
166
 
165
- [MIT](LICENSE)
167
+ [MIT](https://github.com/kittimzhe/dsh-session-recall/blob/main/LICENSE)
package/lib/index.d.ts CHANGED
@@ -46,6 +46,10 @@ interface RecallConfig {
46
46
  * approve through the `@deepseek-ai/dsh-user-approval` seam; fail-closed).
47
47
  */
48
48
  allProjectsPolicy?: AllProjectsPolicy;
49
+ /** Re-rank cross-session hits with exponential recency decay (half-life in days). Default: off. */
50
+ recencyHalfLifeDays?: number;
51
+ /** Sessions started in these project directories rank first. Default: none. */
52
+ pinnedCwds?: readonly string[];
49
53
  }
50
54
  /** Validated, fully defaulted configuration. */
51
55
  interface NormalizedRecallConfig {
@@ -59,6 +63,8 @@ interface NormalizedRecallConfig {
59
63
  readonly cwdAllowlist: readonly string[];
60
64
  readonly cwdDenylist: readonly string[];
61
65
  readonly allProjectsPolicy: AllProjectsPolicy;
66
+ readonly recencyHalfLifeDays: number | undefined;
67
+ readonly pinnedCwds: readonly string[];
62
68
  }
63
69
  /** Default, clamp, and cross-check every optional field. */
64
70
  declare function normalizeRecallConfig(config?: RecallConfig): NormalizedRecallConfig;
@@ -91,6 +97,17 @@ interface RecallScope {
91
97
  allProjects: boolean;
92
98
  sessionId: string | null;
93
99
  }
100
+ /** How the matches in a result were produced (v0.5 diagnostics). */
101
+ interface RecallDiagnostics {
102
+ /** Which engine produced the matches. */
103
+ readonly source: 'fts' | 'cjk-fallback' | 'session-scan';
104
+ /** Sessions visited by the fallback scan, when it ran. */
105
+ readonly scanned?: number;
106
+ /** The fallback scan budget (`cjkFallbackScanMax`), when a scan ran. */
107
+ readonly scanBudget?: number;
108
+ /** True when re-ranking (recency decay / pinning) was applied to this page. */
109
+ readonly ranked: boolean;
110
+ }
94
111
  /** The `recall` tool's canonical JSON value, validated against the output schema. */
95
112
  interface RecallResult {
96
113
  query: string;
@@ -102,6 +119,8 @@ interface RecallResult {
102
119
  hint: string | null;
103
120
  /** How many secret-looking fields were redacted in this result (0 when redaction is off). */
104
121
  redacted: number;
122
+ /** How this result was produced; null when the call failed before searching. */
123
+ diagnostics: RecallDiagnostics | null;
105
124
  }
106
125
  /** The typed model-facing arguments after schema validation. */
107
126
  interface RecallArgs {
@@ -112,6 +131,53 @@ interface RecallArgs {
112
131
  cursor?: string;
113
132
  }
114
133
  //#endregion
134
+ //#region src/rank.d.ts
135
+ /**
136
+ * Result re-ranking for cross-session recall hits.
137
+ *
138
+ * The FTS backend returns hits in its own relevance order but exposes no
139
+ * numeric score. Two optional, composable controls re-rank that order:
140
+ *
141
+ * - **Recency decay** (`recencyHalfLifeDays`): each hit's weight is its
142
+ * backend rank position (1/rank, a monotone relevance proxy) multiplied by
143
+ * an exponential decay `0.5^(ageDays / halfLife)` over the match time. A hit
144
+ * one half-life old needs roughly twice the relevance rank to keep its
145
+ * place — "the bug we fixed last week" beats an older, equally-ranked match.
146
+ *
147
+ * - **Session pinning** (`pinnedCwds`): hits from pinned project directories
148
+ * float to the top as a group, preserving their internal order.
149
+ *
150
+ * Sorting is stable (ties keep backend order) and per result page: the
151
+ * backend pages lazily, so ranking is applied to the page in hand — a
152
+ * documented limitation, not a global rerank.
153
+ *
154
+ * @module dsh-session-recall/rank
155
+ */
156
+ /** The slice of a recall item ranking needs (structural, for testability). */
157
+ interface RankableItem {
158
+ readonly cwd: string | null;
159
+ readonly createdAt: number;
160
+ readonly bestMatch: {
161
+ readonly time: number;
162
+ };
163
+ }
164
+ interface RankingOptions {
165
+ /** Exponential recency half-life in days; unset disables decay re-ranking. */
166
+ readonly recencyHalfLifeDays?: number;
167
+ /** Project directories whose hits float to the top. */
168
+ readonly pinnedCwds?: readonly string[];
169
+ /** Clock override for tests; defaults to Date.now(). */
170
+ readonly now?: number;
171
+ }
172
+ /** True when any ranking control is active. */
173
+ declare function rankingActive(options: RankingOptions): boolean;
174
+ /**
175
+ * Re-rank one page of hits. Returns the same item references in a new order;
176
+ * the input is never mutated. With no controls active the input order is
177
+ * returned unchanged.
178
+ */
179
+ declare function rankItems<T extends RankableItem>(items: readonly T[], options?: RankingOptions): T[];
180
+ //#endregion
115
181
  //#region src/tool.d.ts
116
182
  /** The ctx.sessionQuery surface this tool consumes (structural, for testability). */
117
183
  interface RecallQueryEngine {
@@ -185,4 +251,4 @@ declare const inject: string[];
185
251
  /** Plugin entry: mount the `recall` tool on the global tool registry. */
186
252
  declare function apply(ctx: Context, config?: RecallConfig): void;
187
253
  //#endregion
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 };
254
+ export { ALL_PROJECTS_POLICIES, type AllProjectsPolicy, type NormalizedRecallConfig, RECALL_TOOL_DESCRIPTION, REDACTION_MODES, type RankableItem, type RankingOptions, type RecallApprovalVerdict, type RecallApprover, type RecallArgs, type RecallBestMatch, type RecallConfig, type RecallDiagnostics, 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, rankItems, rankingActive, recallContentBlocks, recallPresentationMeta, redactText, renderRecallText, snippetAround };
package/lib/index.js CHANGED
@@ -95,7 +95,9 @@ function normalizeRecallConfig(config) {
95
95
  redactionMode: normalizeRedactionMode(config?.redactionMode),
96
96
  cwdAllowlist: stringList(config?.cwdAllowlist),
97
97
  cwdDenylist: stringList(config?.cwdDenylist),
98
- allProjectsPolicy: normalizePolicy(config?.allProjectsPolicy)
98
+ allProjectsPolicy: normalizePolicy(config?.allProjectsPolicy),
99
+ recencyHalfLifeDays: typeof config?.recencyHalfLifeDays === "number" && Number.isFinite(config.recencyHalfLifeDays) && config.recencyHalfLifeDays > 0 ? Math.min(3650, Math.trunc(config.recencyHalfLifeDays)) : void 0,
100
+ pinnedCwds: stringList(config?.pinnedCwds)
99
101
  };
100
102
  }
101
103
  /** Whether a session cwd is searchable under the allowlist/denylist policy. */
@@ -106,6 +108,43 @@ function cwdAllowed(cwd, cfg) {
106
108
  return true;
107
109
  }
108
110
  //#endregion
111
+ //#region src/rank.ts
112
+ /** True when any ranking control is active. */
113
+ function rankingActive(options) {
114
+ return (options.recencyHalfLifeDays ?? 0) > 0 || (options.pinnedCwds?.length ?? 0) > 0;
115
+ }
116
+ /** One item's decayed weight; rank positions are 1-based. */
117
+ function weight(item, rank, halfLifeDays, now) {
118
+ const relevance = 1 / rank;
119
+ if (halfLifeDays === void 0 || halfLifeDays <= 0) return relevance;
120
+ const matchTime = item.bestMatch.time > 0 ? item.bestMatch.time : item.createdAt;
121
+ const ageDays = Math.max(0, (now - matchTime) / 864e5);
122
+ return relevance * Math.pow(.5, ageDays / halfLifeDays);
123
+ }
124
+ /**
125
+ * Re-rank one page of hits. Returns the same item references in a new order;
126
+ * the input is never mutated. With no controls active the input order is
127
+ * returned unchanged.
128
+ */
129
+ function rankItems(items, options) {
130
+ if (options === void 0 || !rankingActive(options)) return [...items];
131
+ const now = options.now ?? Date.now();
132
+ const halfLife = options.recencyHalfLifeDays;
133
+ const pinned = new Set(options.pinnedCwds ?? []);
134
+ const decorated = items.map((item, index) => ({
135
+ item,
136
+ index,
137
+ pinned: item.cwd !== null && pinned.has(item.cwd),
138
+ score: weight(item, index + 1, halfLife, now)
139
+ }));
140
+ decorated.sort((a, b) => {
141
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
142
+ if (b.score !== a.score) return b.score - a.score;
143
+ return a.index - b.index;
144
+ });
145
+ return decorated.map((entry) => entry.item);
146
+ }
147
+ //#endregion
109
148
  //#region src/util.ts
110
149
  /** Small pure helpers shared by the recall tool and its renderers. */
111
150
  /** Clamp `n` into the inclusive `[lo, hi]` range. */
@@ -185,6 +224,8 @@ function renderRecallText(result) {
185
224
  const lines = [];
186
225
  const noun = result.scope.sessionId != null ? "events" : "sessions";
187
226
  lines.push(`Recall "${result.query}" — ${result.count} ${noun}${scopeParts.length > 0 ? ` (${scopeParts.join(", ")})` : ""}`);
227
+ const diag = diagnosticsLine(result.diagnostics);
228
+ if (diag !== null) lines.push(diag);
188
229
  if (result.items.length === 0) lines.push("No matches.");
189
230
  else result.items.forEach((item, i) => {
190
231
  lines.push(`${i + 1}. ${sessionLabel(result, i)}`);
@@ -194,6 +235,17 @@ function renderRecallText(result) {
194
235
  if (result.hint != null) lines.push(`Hint: ${result.hint}`);
195
236
  return lines.join("\n");
196
237
  }
238
+ /** One compact diagnostics line, only when it carries information. */
239
+ function diagnosticsLine(d) {
240
+ if (d == null) return null;
241
+ const parts = [];
242
+ if (d.source !== "fts") {
243
+ const scan = d.scanned !== void 0 ? ` (scanned ${d.scanned}${d.scanBudget !== void 0 ? ` of ${d.scanBudget}` : ""})` : "";
244
+ parts.push(`matched via ${d.source}${scan}`);
245
+ }
246
+ if (d.ranked) parts.push("re-ranked: recency decay + pinned projects");
247
+ return parts.length > 0 ? `Diagnostics: ${parts.join("; ")}` : null;
248
+ }
197
249
  /** Content-block projection required by the tool's output declaration. */
198
250
  function recallContentBlocks(result) {
199
251
  return [{
@@ -282,7 +334,8 @@ function recallError(query, error) {
282
334
  items: [],
283
335
  nextCursor: null,
284
336
  hint: friendlyError(error),
285
- redacted: 0
337
+ redacted: 0,
338
+ diagnostics: null
286
339
  };
287
340
  }
288
341
  function brand(value) {
@@ -362,9 +415,9 @@ function cjkTextFilters(query) {
362
415
  */
363
416
  async function cjkScanSessions(engine, query, agentCwd, wantAll, cfg, scanMax, limit, signal) {
364
417
  const all = await engine.listSessions(signal);
365
- const candidates = (!wantAll && agentCwd != null ? all.filter((record) => record.header.cwd === agentCwd) : all).filter((record) => cwdAllowed(record.header.cwd, cfg));
418
+ const page = (!wantAll && agentCwd != null ? all.filter((record) => record.header.cwd === agentCwd) : all).filter((record) => cwdAllowed(record.header.cwd, cfg)).slice(0, scanMax);
366
419
  const items = [];
367
- for (const record of candidates.slice(0, scanMax)) {
420
+ for (const record of page) {
368
421
  if (items.length >= limit) break;
369
422
  const { filters, highlight } = cjkTextFilters(query);
370
423
  const docs = await engine.filterEvents(record.header.id, filters);
@@ -387,7 +440,11 @@ async function cjkScanSessions(engine, query, agentCwd, wantAll, cfg, scanMax, l
387
440
  }
388
441
  });
389
442
  }
390
- return items;
443
+ return {
444
+ items,
445
+ scanned: page.length,
446
+ budget: scanMax
447
+ };
391
448
  }
392
449
  const nullableString = { oneOf: [{ type: "string" }, { type: "null" }] };
393
450
  const recallOutputSchema = {
@@ -434,7 +491,24 @@ const recallOutputSchema = {
434
491
  },
435
492
  nextCursor: nullableString,
436
493
  hint: nullableString,
437
- redacted: { type: "integer" }
494
+ redacted: { type: "integer" },
495
+ diagnostics: { oneOf: [{ type: "null" }, {
496
+ type: "object",
497
+ additionalProperties: false,
498
+ properties: {
499
+ source: {
500
+ type: "string",
501
+ enum: [
502
+ "fts",
503
+ "cjk-fallback",
504
+ "session-scan"
505
+ ]
506
+ },
507
+ scanned: { type: "integer" },
508
+ scanBudget: { type: "integer" },
509
+ ranked: { type: "boolean" }
510
+ }
511
+ }] }
438
512
  }
439
513
  };
440
514
  /**
@@ -546,7 +620,8 @@ function createRecallTool(config, engine, approver) {
546
620
  items: [],
547
621
  nextCursor: null,
548
622
  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
623
+ redacted: 0,
624
+ diagnostics: null
550
625
  };
551
626
  try {
552
627
  if (args.session_id != null && args.session_id !== "") {
@@ -565,13 +640,24 @@ function createRecallTool(config, engine, approver) {
565
640
  items: [],
566
641
  nextCursor: null,
567
642
  hint: "that session belongs to a project directory excluded by the recall scope policy (cwd allowlist/denylist).",
568
- redacted: 0
643
+ redacted: 0,
644
+ diagnostics: null
569
645
  };
570
646
  let items = eventItems(page, sessionId);
571
647
  let hint = null;
648
+ let diagnostics = {
649
+ source: "fts",
650
+ ranked: false
651
+ };
572
652
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
573
653
  const { filters, highlight } = cjkTextFilters(query);
574
654
  const docs = await engine.filterEvents(sessionId, filters);
655
+ diagnostics = {
656
+ source: "session-scan",
657
+ scanned: 1,
658
+ scanBudget: cfg.cjkFallbackScanMax,
659
+ ranked: false
660
+ };
575
661
  if (docs.length > 0) items = docs.slice(0, limit).map((doc) => ({
576
662
  sessionId,
577
663
  id8: id8(sessionId),
@@ -598,7 +684,8 @@ function createRecallTool(config, engine, approver) {
598
684
  items: red.items,
599
685
  nextCursor: page.nextCursor ?? null,
600
686
  hint: joinHints(hint, red.hint),
601
- redacted: red.redacted
687
+ redacted: red.redacted,
688
+ diagnostics
602
689
  };
603
690
  }
604
691
  const request = {
@@ -612,17 +699,32 @@ function createRecallTool(config, engine, approver) {
612
699
  if (args.cursor != null && args.cursor !== "") request.cursor = brand(args.cursor);
613
700
  const page = await engine.searchSessions(request, { signal: exec.signal });
614
701
  const titles = await titlesFor(engine, page.items.map((hit) => hit.header.id), exec.signal);
615
- let items = toItems(page.items, titles).filter((item) => cwdAllowed(item.cwd, cfg));
702
+ const rankingOptions = {
703
+ recencyHalfLifeDays: cfg.recencyHalfLifeDays,
704
+ pinnedCwds: cfg.pinnedCwds
705
+ };
706
+ const ranked = rankingActive(rankingOptions);
707
+ let items = rankItems(toItems(page.items, titles).filter((item) => cwdAllowed(item.cwd, cfg)), rankingOptions);
616
708
  let hint = null;
617
709
  let fallbackRan = false;
710
+ let diagnostics = {
711
+ source: "fts",
712
+ ranked
713
+ };
618
714
  if (items.length === 0 && hasCJK(query) && cfg.cjkFallback) {
619
715
  fallbackRan = true;
620
- const scanned = await cjkScanSessions(engine, query, agentCwd, wantAll, cfg, cfg.cjkFallbackScanMax, limit, exec.signal);
621
- const scanTitles = await titlesFor(engine, scanned.map((item) => item.sessionId), exec.signal);
622
- items = scanned.map((item) => ({
716
+ const scan = await cjkScanSessions(engine, query, agentCwd, wantAll, cfg, cfg.cjkFallbackScanMax, limit, exec.signal);
717
+ const scanTitles = await titlesFor(engine, scan.items.map((item) => item.sessionId), exec.signal);
718
+ items = rankItems(scan.items.map((item) => ({
623
719
  ...item,
624
720
  title: scanTitles.get(item.sessionId) ?? null
625
- }));
721
+ })), rankingOptions);
722
+ diagnostics = {
723
+ source: "cjk-fallback",
724
+ scanned: scan.scanned,
725
+ scanBudget: scan.budget,
726
+ ranked
727
+ };
626
728
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
627
729
  } else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
628
730
  const red = applyRedaction(items);
@@ -634,7 +736,8 @@ function createRecallTool(config, engine, approver) {
634
736
  items: red.items,
635
737
  nextCursor: !fallbackRan ? page.nextCursor ?? null : null,
636
738
  hint: joinHints(hint, ...scopeHints, red.hint),
637
- redacted: red.redacted
739
+ redacted: red.redacted,
740
+ diagnostics
638
741
  };
639
742
  } catch (error) {
640
743
  return recallError(query, error);
@@ -693,4 +796,4 @@ function apply(ctx, config) {
693
796
  }, "session-recall lifecycle");
694
797
  }
695
798
  //#endregion
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 };
799
+ export { ALL_PROJECTS_POLICIES, RECALL_TOOL_DESCRIPTION, REDACTION_MODES, apply, cjkFallbackHint, cjkZeroHitHint, clamp, createRecallTool, cwdAllowed, firstLineClipped, formatDate, hasCJK, id8, inject, name, normalizeQuery, normalizeRecallConfig, normalizeRedactionMode, rankItems, rankingActive, recallContentBlocks, recallPresentationMeta, redactText, renderRecallText, snippetAround };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-recall",
3
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",
4
+ "version": "0.5.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },