@henryqw/pi-session-recall 2.1.1 → 4.0.1

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
@@ -14,13 +14,13 @@ pi install npm:@henryqw/pi-session-recall
14
14
 
15
15
  ## Use
16
16
 
17
- Start discovery with a distinctive query:
17
+ Start a progressive search with a distinctive keyword query:
18
18
 
19
19
  ```json
20
20
  { "query": "database migration rollback" }
21
21
  ```
22
22
 
23
- `session_search` returns ranked sessions. The top result includes nearby messages and session bookends.
23
+ Discovery returns ranked metadata and snippets. Use a result's `path` and `matchMessageId` to scroll for the relevant messages.
24
24
 
25
25
  Prepare a repository-scoped pattern-mining sample with one call:
26
26
 
@@ -34,12 +34,12 @@ Prepare a repository-scoped pattern-mining sample with one call:
34
34
 
35
35
  Use `scope:"all"` for cross-repository work. It still returns the corpus when repository inventory is unavailable.
36
36
 
37
- Use IDs from a discovery result to ask for more context:
37
+ Use the discovery result's `path` and `matchMessageId` for a follow-up scroll:
38
38
 
39
39
  ```json
40
40
  {
41
- "sessionId": "<returned sessionId>",
42
- "aroundMessageId": "<returned message entryId>",
41
+ "sessionId": "<result path>",
42
+ "aroundMessageId": "<result matchMessageId>",
43
43
  "window": 10
44
44
  }
45
45
  ```
@@ -51,12 +51,12 @@ The follow-up returns up to ten messages before and after that anchor on the sel
51
51
  | `session_search` | tool | Search, inspect, or prepare a bounded mining corpus from past sessions. |
52
52
  | `pi-session-pattern-miner` | skill | Find repeated work and choose the smallest useful automation. |
53
53
 
54
- BM25 is a text-ranking method. Hydrated results include messages read from saved session files.
54
+ BM25 is a text-ranking method. Discovery returns index metadata; READ and SCROLL retrieve messages from saved session files.
55
55
 
56
56
  | Mode | Call | Result |
57
57
  | --- | --- | --- |
58
58
  | Pattern preparation | `operation:"prepare-pattern-miner"` + `scope:"repository"` or `scope:"all"` | Up to ten recent lineage-unique sessions plus repository inventory. The default limit is 10. Repository scope includes exact and descendant `cwd` values, filters before the limit, and excludes the current session file. |
59
- | Discovery | `query` | BM25-ranked top sessions. Adaptive retrieval uses user and assistant text for windows, bookends, anchors, and counts. It omits tool-result messages and sets `toolResultsOmitted:true` when it removes one. Lower hits still include their indexed anchor. Use `detail:"full"` to hydrate every hit with tool-result messages included. |
59
+ | Discovery | `query` | BM25-ranked metadata and snippets. Each result includes `path` and `matchMessageId` for a follow-up SCROLL. Start with keywords, then narrow the query before scrolling. |
60
60
  | Scroll | `sessionId` + `aroundMessageId` | Raw message roles, including tool results, within ±`window` ([1,20]) of the anchor. Re-anchor on the last or first message ID to scroll. Across forks, pass the previous response's `branchTip`; `aroundMessageId` only centers the window and must lie on that branch. |
61
61
  | Read | `sessionId` | Raw message roles, including tool results, from the session. Large sessions return head 20 + tail 10. Oversized content is bounded to 50k characters and marked with `contentTruncated`. |
62
62
  | Browse | no args | Recent sessions with path, name, cwd, started date, and preview. |
@@ -98,7 +98,7 @@ Inspect the current candidate files before assigning ownership. Abstain if targe
98
98
 
99
99
  ## Flow
100
100
 
101
- ![Flowchart showing session_search routing: preparation, discovery, and browse use the index; scroll and read retrieve saved transcripts directly.](./docs/session-search-routing.svg)
101
+ ![Flowchart showing progressive keyword discovery returning path and matchMessageId for direct transcript scroll.](./docs/session-search-routing.svg)
102
102
 
103
103
  Search makes no model calls.
104
104
 
@@ -112,7 +112,7 @@ Search makes no model calls.
112
112
 
113
113
  ### Context and sync
114
114
 
115
- Hits inside the current session's live context are suppressed. Compacted-away or inactive-branch history stays discoverable. Forked sessions collapse into their parent when both match.
115
+ Discovery excludes the current session file when its path is available. Forked sessions collapse into their parent when both match.
116
116
 
117
117
  Before browse, discovery, or pattern preparation, the extension lazily syncs the index from the session tree.
118
118
 
@@ -120,9 +120,9 @@ Pattern preparation reports `sync.walkComplete`, `sync.backlogRemaining`, and `s
120
120
 
121
121
  ### Retrieval safety
122
122
 
123
- Adaptive discovery leaves tool-result messages out of returned context. Use `detail:"full"`, READ, or SCROLL when you explicitly need them.
123
+ Discovery returns short snippets from indexed user and assistant transcript text. Use a result's `path` and `matchMessageId` with SCROLL for targeted context; READ and SCROLL retrieve raw messages, including tool-result messages when present.
124
124
 
125
- Pattern preparation returns only non-empty user and assistant text. It never returns thinking blocks or tool-result content. Each session keeps citation and lineage metadata even when its hydration fails.
125
+ Pattern preparation returns only non-empty user and assistant text. It never returns thinking blocks or tool-result content. Preparation keeps citation and lineage metadata when a source cannot be read.
126
126
 
127
127
  Historical tool output may contain secrets or other sensitive data. Raw retrieval places that output in active model context.
128
128
 
@@ -139,7 +139,7 @@ The index and transcript reads stay local. Transcripts are read in place. Return
139
139
  Pin the previous release:
140
140
 
141
141
  ```bash
142
- pi install npm:@henryqw/pi-session-recall@2.0.0
142
+ pi install npm:@henryqw/pi-session-recall@2.1.1
143
143
  ```
144
144
 
145
145
  No index migration or cleanup is needed.
@@ -156,7 +156,7 @@ Session directories whose encoded path starts with `--tmp-` or `--private-tmp-`
156
156
 
157
157
  Session files over 32 MiB are excluded from indexing and hydration. Discovery cannot newly find them.
158
158
 
159
- READ and SCROLL return an explicit size error. A stale discovery hit from before a file grew returns metadata with empty messages and that error.
159
+ READ and SCROLL return an explicit size error. Discovery returns the indexed metadata even when a source file changes afterward.
160
160
 
161
161
  Pattern preparation runs one sync pass. A positive backlog or incomplete walk limits the sample and sets `sync.complete:false`. A total sync or required repository-inventory failure returns an explicit tool error.
162
162
 
@@ -164,7 +164,7 @@ Repository scope fails outside Git or when repository inventory fails. All scope
164
164
 
165
165
  Outside Git, all scope sets `inventory.available:false` with `reason:"not-a-git-repository"`. On a repository inventory error, it uses `reason:"inventory-failed"`. Cancellation always aborts the call instead of returning unavailable inventory.
166
166
 
167
- Preparation rejects `query`, session cursors, `window`, or `detail` in the same call. It also rejects `scope` without the operation.
167
+ Preparation rejects `query`, session cursors, or `window` in the same call. It also rejects `scope` without the operation.
168
168
 
169
169
  Preparation output stays within 50,000 serialized characters. Inventory uses at most 10,000 characters. Its `omittedCounts` report only omitted collection entries, and `inventory.truncated` reports those omissions.
170
170
 
@@ -15,8 +15,8 @@
15
15
  <body>
16
16
  <figure>
17
17
  <svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="session-search-routing-title session-search-routing-desc">
18
- <title id="session-search-routing-title">session_search routing</title>
19
- <desc id="session-search-routing-desc">Flowchart routing session_search requests between indexed preparation, discovery, and browse calls and direct transcript scroll and read calls.</desc>
18
+ <title id="session-search-routing-title">session_search progressive search and scroll</title>
19
+ <desc id="session-search-routing-desc">Flowchart routing progressive keyword discovery to metadata results, then using each result path and matchMessageId for direct transcript scroll.</desc>
20
20
  <defs>
21
21
  <style>
22
22
  .eyebrow { fill: #6a7282; font: 500 8px 'Geist Mono', monospace; letter-spacing: .16em; }
@@ -107,9 +107,9 @@
107
107
 
108
108
  <rect x="576" y="400" width="200" height="96" rx="20" fill="#f0eee9" stroke="#101828" stroke-width="1"/>
109
109
  <text class="node-name" x="676" y="428">Discovery</text>
110
- <text class="node-sub" x="676" y="452">query · lazy sync + FTS5</text>
111
- <text class="node-sub" x="676" y="468">adaptive output omits tool results</text>
112
- <text class="node-sub" x="676" y="484">detail:&quot;full&quot; includes tool results</text>
110
+ <text class="node-sub" x="676" y="452">keyword query · lazy sync + FTS5</text>
111
+ <text class="node-sub" x="676" y="468">metadata + snippets only</text>
112
+ <text class="node-sub" x="676" y="484">path + matchMessageId → SCROLL</text>
113
113
 
114
114
  <rect x="328" y="544" width="192" height="96" rx="20" fill="#f0eee9" stroke="#101828" stroke-width="1"/>
115
115
  <text class="node-name" x="424" y="572">Browse</text>
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0" encoding="UTF-8"?>
2
2
  <svg width="1280" height="720" viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="session-search-routing-title session-search-routing-desc">
3
- <title id="session-search-routing-title">session_search routing</title>
4
- <desc id="session-search-routing-desc">Flowchart routing session_search requests between indexed preparation, discovery, and browse calls and direct transcript scroll and read calls.</desc>
3
+ <title id="session-search-routing-title">session_search progressive search and scroll</title>
4
+ <desc id="session-search-routing-desc">Flowchart routing progressive keyword discovery to metadata results, then using each result path and matchMessageId for direct transcript scroll.</desc>
5
5
  <defs>
6
6
  <style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&amp;family=Geist:wght@400;500;600&amp;family=Geist+Mono:wght@400;500;600&amp;display=swap');</style>
7
7
  <style>
@@ -93,9 +93,9 @@
93
93
 
94
94
  <rect x="576" y="400" width="200" height="96" rx="20" fill="#f0eee9" stroke="#101828" stroke-width="1"/>
95
95
  <text class="node-name" x="676" y="428">Discovery</text>
96
- <text class="node-sub" x="676" y="452">query · lazy sync + FTS5</text>
97
- <text class="node-sub" x="676" y="468">adaptive output omits tool results</text>
98
- <text class="node-sub" x="676" y="484">detail:&quot;full&quot; includes tool results</text>
96
+ <text class="node-sub" x="676" y="452">keyword query · lazy sync + FTS5</text>
97
+ <text class="node-sub" x="676" y="468">metadata + snippets only</text>
98
+ <text class="node-sub" x="676" y="484">path + matchMessageId → SCROLL</text>
99
99
 
100
100
  <rect x="328" y="544" width="192" height="96" rx="20" fill="#f0eee9" stroke="#101828" stroke-width="1"/>
101
101
  <text class="node-name" x="424" y="572">Browse</text>
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Hydration: direct JSONL parsing of Pi session files (never SessionManager.open —
3
- * it rewrites legacy files), branch-aware windows/bookends, READ head/tail.
3
+ * it rewrites legacy files), branch-aware windows, READ head/tail.
4
4
  * Pure functions over file paths; no SQLite access.
5
5
  */
6
6
  /// <reference types="node" />
@@ -9,13 +9,8 @@ import type { WindowMessage } from "./types.ts";
9
9
 
10
10
  export interface WindowResult {
11
11
  messages: WindowMessage[];
12
- /** Every message on the resolved branch, root→tip chronological. Callers
13
- * derive bookends from this instead of re-parsing the transcript. */
14
- branchMessages: WindowMessage[];
15
12
  messagesBefore: number;
16
13
  messagesAfter: number;
17
- /** Present only when discovery filtering removed at least one tool result. */
18
- toolResultsOmitted?: true;
19
14
  /** Tip of the branch the window was resolved on — pass back as branchTip to
20
15
  * keep scrolling on this branch across forks. May be a non-message entry id. */
21
16
  branchTip: string;
@@ -139,11 +134,7 @@ function branch(entriesById: Map<string, Entry>, entryId: string): Entry[] {
139
134
  }
140
135
 
141
136
  /** Deepest entry in file order whose ancestry contains tipId (tipId itself if none). */
142
- function deepestDescendant(
143
- entriesById: Map<string, Entry>,
144
- entries: Entry[],
145
- anchorId: string,
146
- ): string {
137
+ function deepestDescendant(entries: Entry[], anchorId: string): string {
147
138
  const onBranch = new Set([anchorId]);
148
139
  let tip = anchorId;
149
140
  for (const e of entries) {
@@ -207,7 +198,7 @@ export function getWindow(
207
198
  sessionPath: string,
208
199
  anchorEntryId: string,
209
200
  windowN: number,
210
- opts?: { branchTip?: string; userAssistantTextOnly?: boolean },
201
+ opts?: { branchTip?: string },
211
202
  ): WindowResult {
212
203
  const n = Math.max(0, Math.min(50, windowN));
213
204
  const entries = parseSessionEntries(sessionPath);
@@ -222,32 +213,24 @@ export function getWindow(
222
213
  // Explicit branch selection: resolve the branch from its tip (deepest
223
214
  // descendant), independent of where the window centers.
224
215
  resolveMessageCursor(entriesById, opts.branchTip); // unknown tip → throw
225
- tip = deepestDescendant(entriesById, entries, opts.branchTip);
216
+ tip = deepestDescendant(entries, opts.branchTip);
226
217
  if (!branch(entriesById, tip).some((e) => e.id === messageAnchor.id)) {
227
218
  throw new Error(`anchor entry ${anchorEntryId} is not on branch ${opts.branchTip}`);
228
219
  }
229
220
  } else {
230
- tip = deepestDescendant(entriesById, entries, anchorEntryId);
221
+ tip = deepestDescendant(entries, anchorEntryId);
231
222
  }
232
- const rawMessages = branchMessages(entriesById, tip);
233
- const toolResultsOmitted = opts?.userAssistantTextOnly && rawMessages.some((m) => m.role === "toolResult")
234
- ? true
235
- : undefined;
236
- const msgs = opts?.userAssistantTextOnly
237
- ? rawMessages.filter((m) => (m.role === "user" || m.role === "assistant") && m.content.length > 0)
238
- : rawMessages;
223
+ const msgs = branchMessages(entriesById, tip);
239
224
  const idx = msgs.findIndex((m) => m.entryId === anchorEntryIdMsg);
240
- if (idx < 0) throw new Error(`anchor entry ${anchorEntryId} is not a user/assistant text message`);
225
+ if (idx < 0) throw new Error(`anchor entry ${anchorEntryId} is not a message`);
241
226
  const start = Math.max(0, idx - n);
242
227
  const end = Math.min(msgs.length - 1, idx + n);
243
228
  return {
244
- branchMessages: msgs,
245
229
  messages: msgs.slice(start, end + 1).map((m) =>
246
230
  m.entryId === anchorEntryIdMsg ? { ...m, anchor: true } : m,
247
231
  ),
248
232
  messagesBefore: idx,
249
233
  messagesAfter: msgs.length - 1 - idx,
250
- ...(toolResultsOmitted ? { toolResultsOmitted } : {}),
251
234
  branchTip: tip,
252
235
  };
253
236
  }
@@ -15,7 +15,7 @@ export const DEFAULT_SYNC_CAP = 50;
15
15
  const MAX_SYNC_CAP = DEFAULT_SYNC_CAP * 10;
16
16
  const MAX_TEXT_CHARS = 20000;
17
17
  const SCAN_LIMIT = 300;
18
- /** Best-ranked candidate retained per session after live-entry filtering. */
18
+ /** Best-ranked candidate retained per session after SQL prefiltering. */
19
19
  const ROWS_PER_FILE = 1;
20
20
 
21
21
  const SCHEMA_SQL = `
@@ -519,19 +519,13 @@ function getBacklog(db: DatabaseSync): number {
519
519
 
520
520
  export interface SearchOptions {
521
521
  limit?: number;
522
- /** Entry ids on the current session's live branch — hits here are skipped. */
523
- currentLiveEntryIds?: Set<string>;
524
- /** Path of the current session file. */
522
+ /** Path of the current session file — the whole file is skipped. */
525
523
  currentSessionPath?: string;
526
524
  }
527
525
 
528
- // Live-entry suppression happens INSIDE SQL (before ROW_NUMBER/caps) so
529
- // inactive matches on the current file compete only against each other, and
530
- // there is no pre-partition LIMIT: one verbose session cannot starve others.
531
- // live_filter is a per-connection TEMP table populated from SearchOptions.
532
- const LIVE_FILTER_SQL = `NOT EXISTS (
533
- SELECT 1 FROM live_filter lf WHERE lf.path = m.path AND lf.entry_id = m.entry_id
534
- )`;
526
+ // Current-session exclusion happens INSIDE SQL (before ROW_NUMBER/caps), so
527
+ // the excluded file cannot consume the per-file rank, lineage, or scan limit.
528
+ const CURRENT_SESSION_FILTER_SQL = "(? IS NULL OR m.path <> ?)";
535
529
  const LINEAGE_FILTER_SQL = `NOT EXISTS (
536
530
  SELECT 1 FROM ranked parent
537
531
  WHERE parent.path = r.parent_session
@@ -549,7 +543,7 @@ WITH matches AS (
549
543
  bm25(session_fts) AS score
550
544
  FROM session_fts JOIN messages m ON m.rowid = session_fts.rowid
551
545
  LEFT JOIN sessions s ON s.path = m.path
552
- WHERE session_fts MATCH ? AND ${LIVE_FILTER_SQL}
546
+ WHERE session_fts MATCH ? AND ${CURRENT_SESSION_FILTER_SQL}
553
547
  ), ranked AS (
554
548
  SELECT *, ROW_NUMBER() OVER (PARTITION BY path ORDER BY score, rid) AS rn
555
549
  FROM matches
@@ -587,22 +581,7 @@ export function searchIndex(
587
581
  const limit = opts?.limit ?? 3;
588
582
  const db = openDb(dbPath);
589
583
  try {
590
- // Per-connection TEMP table of (path, entry_id) pairs to suppress —
591
- // parameterized, immune to SQLite variable limits. Primary key indexes
592
- // the correlated NOT EXISTS probe and dedupes inserts.
593
- db.exec("CREATE TEMP TABLE IF NOT EXISTS live_filter (path TEXT NOT NULL, entry_id TEXT NOT NULL, PRIMARY KEY (path, entry_id))");
594
- if (opts?.currentSessionPath && opts?.currentLiveEntryIds?.size) {
595
- const ins = db.prepare("INSERT INTO live_filter(path, entry_id) VALUES (?, ?)");
596
- db.exec("BEGIN");
597
- try {
598
- for (const id of opts.currentLiveEntryIds!) ins.run(opts.currentSessionPath!, id);
599
- db.exec("COMMIT");
600
- } catch (err) {
601
- db.exec("ROLLBACK");
602
- throw err;
603
- }
604
- }
605
-
584
+ const currentSessionPath = opts?.currentSessionPath ?? null;
606
585
  const plan = buildFtsQueryPlan(query);
607
586
  if (plan.ftsCandidates.length === 0 && !plan.forceLike) {
608
587
  return { hits: [], backlogRemaining: getBacklog(db) };
@@ -628,7 +607,7 @@ export function searchIndex(
628
607
  let parseFailed = false;
629
608
  for (const cand of plan.ftsCandidates) {
630
609
  try {
631
- rows = db.prepare(BASE_SELECT).all(cand) as any;
610
+ rows = db.prepare(BASE_SELECT).all(cand, currentSessionPath, currentSessionPath) as any;
632
611
  // First/raw success defines the result even with zero rows. After a
633
612
  // parse error, an empty recovery candidate keeps trying later ones.
634
613
  if (!parseFailed || rows.length > 0) {
@@ -652,21 +631,21 @@ export function searchIndex(
652
631
  ROW_NUMBER() OVER (PARTITION BY m.path ORDER BY m.rowid DESC) AS rn,
653
632
  COUNT(*) OVER (PARTITION BY m.path) AS matches
654
633
  FROM messages m LEFT JOIN sessions s ON s.path = m.path
655
- WHERE ${likePlan.where} AND ${LIVE_FILTER_SQL}
634
+ WHERE ${likePlan.where} AND ${CURRENT_SESSION_FILTER_SQL}
656
635
  )
657
636
  SELECT path, entry_id, role, timestamp, head, tail, cwd, name, started_at
658
637
  FROM ranked r
659
638
  WHERE rn <= ${ROWS_PER_FILE}
660
639
  AND ${LINEAGE_FILTER_SQL}
661
640
  ORDER BY matches DESC, started_at DESC, path
662
- LIMIT ${SCAN_LIMIT}`).all(...likePlan.params) as any;
641
+ LIMIT ${SCAN_LIMIT}`).all(...likePlan.params, currentSessionPath, currentSessionPath) as any;
663
642
  // Snippets anchor on operand terms only — operator words like OR would
664
643
  // otherwise match common substrings and hide the real match.
665
644
  for (const r of rows as any[]) r.snip = likeSnippet((r as any).head ?? "", (r as any).tail ?? "", likePlan.terms);
666
645
  }
667
646
 
668
- // Live-entry and one-hop lineage suppression already happened in SQL,
669
- // before the scan limit, so fork rows cannot starve unrelated matches.
647
+ // Current-file and one-hop lineage suppression already happened in SQL,
648
+ // before the scan limit, so excluded or fork rows cannot starve matches.
670
649
  const seenFiles = new Set<string>();
671
650
  const hits: SearchHit[] = [];
672
651
  let rankCounter = 0;
@@ -266,16 +266,15 @@ interface ToolParams {
266
266
  branchTip?: string;
267
267
  window?: number;
268
268
  limit?: number;
269
- detail?: "adaptive" | "full";
270
269
  }
271
270
 
272
- const DESCRIPTION = `Search past Pi sessions locally with FTS5; returns stored messages.
271
+ const DESCRIPTION = `Search past Pi sessions locally with FTS5; returns indexed metadata and snippets.
273
272
 
274
273
  - \`operation: "prepare-pattern-miner"\` + \`scope\`: prepare one bounded corpus and repository inventory.
275
274
  - \`query\`: discover matches. Prefer distinctive identifiers or uncommon terms; multi-word queries are AND. Use \`OR\`/\`NOT\` for Boolean queries and quotes only when exact wording is known.
276
275
  - \`sessionId\` + \`aroundMessageId\`: scroll ±\`window\`; retain \`branchTip\` across forks.
277
276
  - \`sessionId\` alone: read; no args: browse recent sessions.
278
- - Discovery is adaptive; use \`detail: "full"\` to hydrate every result.`;
277
+ - Discovery returns metadata and snippets. Use a result's \`path\` and \`matchMessageId\` for a follow-up scroll.`;
279
278
 
280
279
  export default function (pi: ExtensionAPI): void {
281
280
  // Best-effort sync at startup, deferred so the synchronous walk + SQLite
@@ -307,8 +306,7 @@ export default function (pi: ExtensionAPI): void {
307
306
  branchTip: Type.Optional(Type.String({ description: "Branch tip entry id from a previous response — selects which branch of a forked session to scroll; aroundMessageId must lie on it." })),
308
307
  window: Type.Optional(Type.Number({ description: "Scroll window radius, [1,20], default 5." })),
309
308
  limit: Type.Optional(Type.Number({ description: "Max results, [1,10]. Defaults to 10 for preparation and 3 otherwise." })),
310
- detail: Type.Optional(StringEnum(["adaptive", "full"] as const)),
311
- }),
309
+ }, { additionalProperties: false }),
312
310
  renderResult(result, { expanded }, theme) {
313
311
  const output = result.content.find((part) => part.type === "text")?.text ?? "";
314
312
  const styledOutput = theme.fg("toolOutput", output);
@@ -333,7 +331,7 @@ export default function (pi: ExtensionAPI): void {
333
331
  throw new Error("scope requires operation: prepare-pattern-miner.");
334
332
  }
335
333
  if (rawParams.operation === "prepare-pattern-miner") {
336
- const incompatible = (["query", "sessionId", "aroundMessageId", "branchTip", "window", "detail"] as const)
334
+ const incompatible = (["query", "sessionId", "aroundMessageId", "branchTip", "window"] as const)
337
335
  .filter((key) => rawParams[key] !== undefined);
338
336
  if (incompatible.length > 0) {
339
337
  throw new Error(`prepare-pattern-miner does not accept: ${incompatible.join(", ")}.`);
@@ -365,7 +363,6 @@ export default function (pi: ExtensionAPI): void {
365
363
  branchTip: rawParams.branchTip != null ? String(rawParams.branchTip) : undefined,
366
364
  window: rawParams.window,
367
365
  limit: rawParams.limit,
368
- detail: rawParams.detail,
369
366
  };
370
367
  let sessionId = params.sessionId?.trim() || undefined;
371
368
  const anchor = params.aroundMessageId?.trim() || undefined;
@@ -445,135 +442,40 @@ export default function (pi: ExtensionAPI): void {
445
442
 
446
443
  // --- DISCOVERY ---
447
444
  const limit = clamp(params.limit, 1, 10, 3);
448
- const full = params.detail === "full";
449
445
 
450
- // Current-session guard: suppress hits on the live branch.
451
- let liveIds: Set<string> | undefined;
446
+ // Exclude the whole current file when the session manager provides it.
447
+ // A missing or failing manager leaves discovery usable without exclusion.
452
448
  let currentSessionPath: string | undefined;
453
449
  try {
454
- currentSessionPath = ctx.sessionManager.getSessionFile();
455
- liveIds = new Set(
456
- ctx.sessionManager
457
- .buildContextEntries()
458
- .filter((e) => e.type === "message")
459
- .map((e) => e.id),
460
- );
450
+ currentSessionPath = ctx.sessionManager.getSessionFile() ?? undefined;
461
451
  } catch {
462
- // Guard unavailable → degrade gracefully, no suppression.
452
+ // Guard unavailable → continue without exclusion.
463
453
  }
464
454
 
465
455
  const { hits, backlogRemaining } = searchIndex(dbPath(), params.query, {
466
456
  limit,
467
- currentLiveEntryIds: liveIds,
468
457
  currentSessionPath,
469
458
  });
470
459
 
471
460
  const resultQuery = params.query!.trim().slice(0, MAX_QUERY_CHARS);
472
- // Reserve the complete response envelope and divide remaining space
473
- // across hits so the first hydrated result cannot starve later metadata.
474
- // The same warning-bearing envelope is reused for the final result so the
475
- // reservation matches what is returned (and textResult's trimming keeps
476
- // top-level non-array keys like syncWarning).
477
- const envelope: Record<string, unknown> = {
461
+ const results = hits.map((hit) => ({
462
+ path: hit.path,
463
+ snippet: hit.snippet,
464
+ rank: hit.rank,
465
+ matchMessageId: hit.entryId,
466
+ role: hit.role,
467
+ timestamp: hit.timestamp,
468
+ cwd: hit.cwd,
469
+ name: hit.name,
470
+ startedAt: hit.startedAt,
471
+ }));
472
+ return textResult({
478
473
  mode: "discovery",
479
474
  query: resultQuery,
480
- results: [],
475
+ results,
481
476
  backlogRemaining,
482
477
  ...(syncWarning ? { syncWarning } : {}),
483
- };
484
- let used = JSON.stringify(envelope).length + Math.max(0, hits.length - 1);
485
- const results = hits.map((hit, index) => {
486
- const remaining = Math.floor((OUTPUT_CHAR_BUDGET - used) / (hits.length - index));
487
- const meta = {
488
- path: hit.path,
489
- snippet: hit.snippet,
490
- rank: hit.rank,
491
- matchMessageId: hit.entryId,
492
- role: hit.role,
493
- timestamp: hit.timestamp,
494
- cwd: hit.cwd,
495
- name: hit.name,
496
- startedAt: hit.startedAt,
497
- };
498
- const hydrateFull = full || hit.rank === 0;
499
- // Every hit is sized against the cumulative remaining budget: keep
500
- // as-is when it fits, else truncate to the largest uniform cap that
501
- // fits across messages and bookends, else metadata-only.
502
- // contentTruncated signals either case.
503
- const fitOrTruncate = (
504
- hitObj: Record<string, unknown>,
505
- messages: WindowMessage[],
506
- bookends?: { start: WindowMessage[]; end: WindowMessage[] },
507
- ): Record<string, unknown> => {
508
- const out: Record<string, unknown> = { ...hitObj };
509
- if (JSON.stringify(out).length > remaining) {
510
- const pools = bookends ? [messages, bookends.start, bookends.end] : [messages];
511
- const maxLen = Math.max(...pools.flatMap((a) => a.map((m) => m.content.length)), 0);
512
- Object.assign(
513
- out,
514
- boundContent(
515
- (cap) => ({
516
- ...hitObj,
517
- messages: cap === null ? [] : truncateContent(messages, cap),
518
- ...(bookends && cap !== null
519
- ? { bookends: { start: truncateContent(bookends.start, cap), end: truncateContent(bookends.end, cap) } }
520
- : bookends
521
- ? { bookends: { start: [], end: [] } }
522
- : {}),
523
- contentTruncated: true,
524
- }),
525
- maxLen,
526
- remaining,
527
- ),
528
- );
529
- }
530
- used += JSON.stringify(out).length;
531
- return out;
532
- };
533
- const hydrationFallback = (error: unknown) =>
534
- fitOrTruncate(
535
- { ...meta, detail: hydrateFull ? "full" : "compact", messages: [], bookends: { start: [], end: [] }, messagesBefore: 0, messagesAfter: 0, error: (error instanceof Error ? error.message : String(error)).slice(0, 512) },
536
- [],
537
- );
538
- if (!hydrateFull) {
539
- // Compact hits still carry the matched anchor message.
540
- try {
541
- const win = getWindow(hit.path, hit.entryId, 0, { userAssistantTextOnly: true });
542
- // Mark when the fixed compact cap already removed content, so a
543
- // hit that still fits the budget isn't mistaken for complete.
544
- const overCompactCap = win.messages.some((m) => m.content.length > 2000);
545
- return fitOrTruncate({ ...meta, detail: "compact", ...(overCompactCap ? { contentTruncated: true } : {}), ...(win.toolResultsOmitted ? { toolResultsOmitted: true } : {}), messages: truncateContent(win.messages, 2000), bookends: { start: [], end: [] }, messagesBefore: win.messagesBefore, messagesAfter: win.messagesAfter }, win.messages);
546
- } catch (error) {
547
- return hydrationFallback(error);
548
- }
549
- }
550
- try {
551
- // One bounded snapshot feeds both window and branch bookends.
552
- const win = getWindow(hit.path, hit.entryId, 5, full ? undefined : { userAssistantTextOnly: true });
553
- // Same branch as the anchor — following the file's final leaf
554
- // would attach unrelated sibling messages.
555
- const bookends = { start: win.branchMessages.slice(0, 3), end: win.branchMessages.slice(-3) };
556
- return fitOrTruncate(
557
- {
558
- ...meta,
559
- detail: "full" as const,
560
- ...(win.toolResultsOmitted ? { toolResultsOmitted: true } : {}),
561
- messages: win.messages,
562
- bookends,
563
- messagesBefore: win.messagesBefore,
564
- messagesAfter: win.messagesAfter,
565
- },
566
- win.messages,
567
- bookends,
568
- );
569
- } catch (error) {
570
- // Session file unreadable/moved since indexing → anchor-only.
571
- return hydrationFallback(error);
572
- }
573
478
  });
574
-
575
- const result: Record<string, unknown> = { ...envelope, results };
576
- return textResult(result);
577
479
  } catch (error) {
578
480
  const message = error instanceof Error ? error.message : String(error);
579
481
  return textResult({ success: false, error: message });
@@ -9,7 +9,7 @@ export interface WindowMessage {
9
9
  role: string;
10
10
  content: string;
11
11
  timestamp: string;
12
- /** True when this message is the anchor of a scroll/discovery window. */
12
+ /** True when this message is the anchor of a scroll window. */
13
13
  anchor?: boolean;
14
14
  }
15
15
 
@@ -28,7 +28,7 @@ export interface PreparationSessionRow extends SessionRow {
28
28
  lineageId: string;
29
29
  }
30
30
 
31
- /** One FTS discovery hit before hydration. */
31
+ /** One metadata-only FTS discovery hit. */
32
32
  export interface SearchHit {
33
33
  path: string;
34
34
  entryId: string;
package/package.json CHANGED
@@ -1,57 +1,62 @@
1
1
  {
2
- "name": "@henryqw/pi-session-recall",
3
- "version": "2.1.1",
4
- "description": "Local FTS5 search over past Pi sessions plus a skill for turning recurring work into deterministic automation.",
5
- "keywords": [
6
- "pi-package",
7
- "pi",
8
- "session-recall",
9
- "fts5",
10
- "recall"
11
- ],
12
- "type": "module",
13
- "engines": {
14
- "node": ">=22.19.0"
15
- },
16
- "license": "MIT",
17
- "files": [
18
- "LICENSE",
19
- "extensions",
20
- "skills",
21
- "docs",
22
- "README.md"
23
- ],
24
- "scripts": {
25
- "test": "node --test test/*.test.ts",
26
- "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
27
- "pack:check": "npm pack --dry-run"
28
- },
29
- "peerDependencies": {
30
- "@earendil-works/pi-ai": "^0.85.1",
31
- "@earendil-works/pi-coding-agent": "^0.85.1",
32
- "@earendil-works/pi-tui": "^0.85.1",
33
- "typebox": "^1.3.15"
34
- },
35
- "repository": {
36
- "type": "git",
37
- "url": "git+https://github.com/HenryQW/pi-harness.git",
38
- "directory": "extensions/pi-session-recall"
39
- },
40
- "bugs": {
41
- "url": "https://github.com/HenryQW/pi-harness/issues"
42
- },
43
- "publishConfig": {
44
- "access": "public"
45
- },
46
- "pi": {
47
- "extensions": [
48
- "./extensions/session-recall.ts"
49
- ],
50
- "skills": [
51
- "./skills"
52
- ]
53
- },
54
- "dependencies": {
55
- "@henryqw/pi-config-store": "^1.0.0"
56
- }
2
+ "name": "@henryqw/pi-session-recall",
3
+ "version": "4.0.1",
4
+ "description": "Local FTS5 search over past Pi sessions plus a skill for turning recurring work into deterministic automation.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi",
8
+ "session-recall",
9
+ "fts5",
10
+ "recall"
11
+ ],
12
+ "type": "module",
13
+ "engines": {
14
+ "node": ">=22.19.0"
15
+ },
16
+ "license": "MIT",
17
+ "files": [
18
+ "LICENSE",
19
+ "extensions",
20
+ "skills",
21
+ "docs",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "test": "node --test test/*.test.ts",
26
+ "typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
27
+ "pack:check": "npm pack --dry-run"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-ai": ">=0.86.0 <0.88.0",
31
+ "@earendil-works/pi-coding-agent": ">=0.86.0 <0.88.0",
32
+ "@earendil-works/pi-tui": ">=0.86.0 <0.88.0",
33
+ "typebox": "^1.3.15"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/HenryQW/pi-harness.git",
38
+ "directory": "extensions/pi-session-recall"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/HenryQW/pi-harness/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "pi": {
47
+ "extensions": [
48
+ "./extensions/session-recall.ts"
49
+ ],
50
+ "skills": [
51
+ "./skills"
52
+ ]
53
+ },
54
+ "dependencies": {
55
+ "@henryqw/pi-config-store": "^2.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@earendil-works/pi-ai": "0.87.0",
59
+ "@earendil-works/pi-coding-agent": "0.87.0",
60
+ "@earendil-works/pi-tui": "0.87.0"
61
+ }
57
62
  }