dsh-session-recall 0.5.1 → 0.7.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
@@ -21,7 +21,7 @@ If you need agent memory orchestration, use a memory framework; if you need boun
21
21
  | Capability focus | Memory frameworks | Generic transcript search | `dsh-session-recall` |
22
22
  |---|---|---|---|
23
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** |
24
+ | Scope control | Framework-specific | Often coarse | **cwd-scoped default + explicit `all_projects`, `since_days`, `tools`, `errors_only` gate** |
25
25
  | CJK behavior | Framework-specific | Often tokenizer-limited | **FTS + CJK zero-hit substring fallback** |
26
26
  | Output contract | Usually framework-native | Varies | **Typed `recall` result with stable fields/hints** |
27
27
 
package/lib/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
2
- import { SessionEventResultFilter, SessionEventSearchDocument, SessionEventSearchPage, SessionEventSearchRequest, SessionRecord, SessionSearchExecContext, SessionSearchHit, SessionSearchPage, SessionSearchRequest, SessionTitleObservationResult } from "@deepseek-ai/dsh-session-query";
2
+ import { SessionEventResultFilter, SessionEventSearchDocument, SessionEventSearchPage, SessionEventSearchRequest, SessionLineageTrace, 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";
@@ -50,6 +50,12 @@ interface RecallConfig {
50
50
  recencyHalfLifeDays?: number;
51
51
  /** Sessions started in these project directories rank first. Default: none. */
52
52
  pinnedCwds?: readonly string[];
53
+ /**
54
+ * Restrict recall results to sessions in the calling agent's own lineage
55
+ * (ancestors + self + descendants). Default `true` — other agents in the
56
+ * same project are invisible unless this is set to `false`.
57
+ */
58
+ callerTreeOnly?: boolean;
53
59
  }
54
60
  /** Validated, fully defaulted configuration. */
55
61
  interface NormalizedRecallConfig {
@@ -65,6 +71,7 @@ interface NormalizedRecallConfig {
65
71
  readonly allProjectsPolicy: AllProjectsPolicy;
66
72
  readonly recencyHalfLifeDays: number | undefined;
67
73
  readonly pinnedCwds: readonly string[];
74
+ readonly callerTreeOnly: boolean;
68
75
  }
69
76
  /** Default, clamp, and cross-check every optional field. */
70
77
  declare function normalizeRecallConfig(config?: RecallConfig): NormalizedRecallConfig;
@@ -129,6 +136,12 @@ interface RecallArgs {
129
136
  all_projects?: boolean;
130
137
  limit?: number;
131
138
  cursor?: string;
139
+ /** Only return sessions/entries newer than this many days. */
140
+ since_days?: number;
141
+ /** Only return tool-related events whose tool name matches one of these (substring, case-insensitive). */
142
+ tools?: readonly string[];
143
+ /** Only return failed tool calls (tool results carrying an error). */
144
+ errors_only?: boolean;
132
145
  }
133
146
  //#endregion
134
147
  //#region src/rank.d.ts
@@ -186,6 +199,8 @@ interface RecallQueryEngine {
186
199
  readTitleSnapshots(sessionIds: readonly string[], signal?: AbortSignal): Promise<SessionTitleObservationResult[]>;
187
200
  listSessions(signal?: AbortSignal): Promise<SessionRecord[]>;
188
201
  filterEvents(sessionId: SessionId, filters: readonly SessionEventResultFilter[]): Promise<SessionEventSearchDocument[]>;
202
+ /** Trace the caller's session lineage for caller-authorization gating. */
203
+ traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace>;
189
204
  }
190
205
  declare const RECALL_TOOL_DESCRIPTION: string;
191
206
  /** The approval verdict vocabulary mirrored from `@deepseek-ai/dsh-user-approval`. */
package/lib/index.js CHANGED
@@ -97,7 +97,8 @@ function normalizeRecallConfig(config) {
97
97
  cwdDenylist: stringList(config?.cwdDenylist),
98
98
  allProjectsPolicy: normalizePolicy(config?.allProjectsPolicy),
99
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)
100
+ pinnedCwds: stringList(config?.pinnedCwds),
101
+ callerTreeOnly: config?.callerTreeOnly !== false
101
102
  };
102
103
  }
103
104
  /** Whether a session cwd is searchable under the allowlist/denylist policy. */
@@ -296,13 +297,55 @@ function cjkZeroHitHint(query, zeroHits, enabled) {
296
297
  * calling agent's own project cwd unless the model explicitly widens the
297
298
  * scope and the deployment allows it.
298
299
  */
300
+ /** Whether a best-match event type is a tool result (vs user/assistant message). */
301
+ function isToolResultType(type) {
302
+ return type === "tool/result" || type.startsWith("tool/");
303
+ }
304
+ /** Apply the dimensional post-filters (sinceDays / tools / errorsOnly) to recall items. */
305
+ function applyDimensionFilters(items, args, now = Date.now()) {
306
+ let out = [...items];
307
+ if (args.since_days !== void 0 && args.since_days > 0) {
308
+ const cutoff = now - args.since_days * 864e5;
309
+ out = out.filter((item) => {
310
+ return Math.max(item.createdAt ?? 0, item.bestMatch?.time ?? 0) >= cutoff;
311
+ });
312
+ }
313
+ if (args.tools !== void 0 && args.tools.length > 0) {
314
+ const needles = args.tools.map((t) => t.toLowerCase());
315
+ out = out.filter((item) => {
316
+ if (!isToolResultType(item.bestMatch?.type ?? "")) return false;
317
+ const snippet = (item.bestMatch?.snippet ?? "").toLowerCase();
318
+ return needles.some((n) => snippet.includes(n));
319
+ });
320
+ }
321
+ if (args.errors_only === true) out = out.filter((item) => {
322
+ if ((item.bestMatch?.type ?? "") !== "tool/result") return false;
323
+ const snippet = item.bestMatch?.snippet ?? "";
324
+ return snippet.includes("[error]") || /\berror\b|\bfailed\b/i.test(snippet);
325
+ });
326
+ return out;
327
+ }
328
+ /** Walk a lineage trace and collect every session id in the tree. */
329
+ function collectLineageIds(trace) {
330
+ const ids = /* @__PURE__ */ new Set();
331
+ ids.add(trace.target.header.id);
332
+ for (const ancestor of trace.ancestors) ids.add(ancestor.header.id);
333
+ const walk = (nodes) => {
334
+ for (const node of nodes) {
335
+ ids.add(node.session.header.id);
336
+ walk(node.descendants);
337
+ }
338
+ };
339
+ walk(trace.descendants);
340
+ return ids;
341
+ }
299
342
  const RECALL_TOOL_DESCRIPTION = [
300
343
  "Search the FULL TEXT of past and current session transcripts on this machine (your own conversation history with this user).",
301
344
  "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.",
302
345
  "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.",
303
346
  "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.",
304
347
  "To save a full evidence report of a hit session, call the transcript_export tool (from dsh-session-export) with its sessionId when available.",
305
- "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).",
348
+ "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). Narrow further with since_days (only sessions newer than N days), tools (only tool events whose tool name matches, e.g. tools=[\"bash\"]), or errors_only=true (only failed tool calls).",
306
349
  "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.",
307
350
  "The first search after startup may be slow while the index builds."
308
351
  ].join(" ");
@@ -585,6 +628,19 @@ function createRecallTool(config, engine, approver) {
585
628
  cursor: {
586
629
  type: "string",
587
630
  description: "Opaque continuation cursor from a previous recall result with hasMore=true."
631
+ },
632
+ since_days: {
633
+ type: "integer",
634
+ description: "Only return sessions newer than this many days (0 = no time filter)."
635
+ },
636
+ tools: {
637
+ type: "array",
638
+ items: { type: "string" },
639
+ description: "Only return tool events whose tool name contains one of these substrings (case-insensitive), e.g. [\"bash\",\"read\"]."
640
+ },
641
+ errors_only: {
642
+ type: "boolean",
643
+ description: "Only return failed tool calls (tool results carrying an error)."
588
644
  }
589
645
  },
590
646
  output: {
@@ -598,6 +654,11 @@ function createRecallTool(config, engine, approver) {
598
654
  if (query === "") return recallError(query, Object.assign(/* @__PURE__ */ new Error("empty query"), { code: "SESSION_QUERY_INVALID_QUERY" }));
599
655
  const limit = clamp(Math.trunc(args.limit ?? cfg.defaultLimit), 1, cfg.maxLimit);
600
656
  const agentCwd = exec.agent?.session.header?.cwd ?? null;
657
+ const callerSessionId = exec.agent?.session?.id;
658
+ let allowedIds = null;
659
+ if (cfg.callerTreeOnly && callerSessionId != null) try {
660
+ allowedIds = collectLineageIds(await engine.traceSession(SessionId(callerSessionId), exec.signal));
661
+ } catch {}
601
662
  const scopeHints = [];
602
663
  let wantAll = false;
603
664
  if (args.session_id == null && args.all_projects === true) {
@@ -633,6 +694,17 @@ function createRecallTool(config, engine, approver) {
633
694
  limit,
634
695
  cursor: brand(args.cursor)
635
696
  }, { signal: exec.signal });
697
+ if (allowedIds != null && !allowedIds.has(args.session_id)) return {
698
+ query,
699
+ scope,
700
+ count: 0,
701
+ hasMore: false,
702
+ items: [],
703
+ nextCursor: null,
704
+ hint: "that session is outside your conversation lineage. Search without session_id to find sessions in your own tree, or ask the user to disable callerTreeOnly.",
705
+ redacted: 0,
706
+ diagnostics: null
707
+ };
636
708
  if (!cwdAllowed(page.session.cwd ?? null, cfg)) return {
637
709
  query,
638
710
  scope,
@@ -644,7 +716,7 @@ function createRecallTool(config, engine, approver) {
644
716
  redacted: 0,
645
717
  diagnostics: null
646
718
  };
647
- let items = eventItems(page, sessionId);
719
+ let items = applyDimensionFilters(eventItems(page, sessionId), args);
648
720
  let hint = null;
649
721
  let diagnostics = {
650
722
  source: "fts",
@@ -659,7 +731,7 @@ function createRecallTool(config, engine, approver) {
659
731
  scanBudget: cfg.cjkFallbackScanMax,
660
732
  ranked: false
661
733
  };
662
- if (docs.length > 0) items = docs.slice(0, limit).map((doc) => ({
734
+ if (docs.length > 0) items = applyDimensionFilters(docs.slice(0, limit).map((doc) => ({
663
735
  sessionId,
664
736
  id8: id8(sessionId),
665
737
  title: null,
@@ -673,7 +745,7 @@ function createRecallTool(config, engine, approver) {
673
745
  time: doc.time,
674
746
  snippet: snippetAround(doc.text, highlight, CJK_SNIPPET_CHARS)
675
747
  }
676
- }));
748
+ })), args);
677
749
  hint = items.length > 0 ? cjkFallbackHint(items.length, cfg.cjkHint) : cjkZeroHitHint(query, true, cfg.cjkHint);
678
750
  } else if (items.length === 0) hint = cjkZeroHitHint(query, true, cfg.cjkHint);
679
751
  const red = applyRedaction(items);
@@ -706,6 +778,8 @@ function createRecallTool(config, engine, approver) {
706
778
  };
707
779
  const ranked = rankingActive(rankingOptions);
708
780
  let items = rankItems(toItems(page.items, titles).filter((item) => cwdAllowed(item.cwd, cfg)), rankingOptions);
781
+ if (allowedIds != null) items = items.filter((item) => allowedIds.has(item.sessionId));
782
+ items = applyDimensionFilters(items, args);
709
783
  let hint = null;
710
784
  let fallbackRan = false;
711
785
  let diagnostics = {
@@ -716,7 +790,7 @@ function createRecallTool(config, engine, approver) {
716
790
  fallbackRan = true;
717
791
  const scan = await cjkScanSessions(engine, query, agentCwd, wantAll, cfg, cfg.cjkFallbackScanMax, limit, exec.signal);
718
792
  const scanTitles = await titlesFor(engine, scan.items.map((item) => item.sessionId), exec.signal);
719
- items = rankItems(scan.items.map((item) => ({
793
+ items = rankItems(scan.items.filter((item) => allowedIds == null || allowedIds.has(item.sessionId)).map((item) => ({
720
794
  ...item,
721
795
  title: scanTitles.get(item.sessionId) ?? null
722
796
  })), rankingOptions);
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.5.1",
4
+ "version": "0.7.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },