@henryqw/pi-session-recall 2.1.1 → 4.0.2

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,10 +1,6 @@
1
1
  # `@henryqw/pi-session-recall`
2
2
 
3
- Find decisions and context in past Pi sessions through a local FTS5 index.
4
-
5
- Saved transcripts are not injected on every turn. The active tool registration still adds standing prompt cost through its schema, descriptions, and guideline. Returned content enters active model context.
6
-
7
- The bundled `pi-session-pattern-miner` skill prepares one bounded sample. The model then finds repeated work that may deserve automation.
3
+ Search past Pi sessions locally to find prior decisions and context without injecting saved transcripts on every turn. The active tool registration still adds standing prompt cost through its schema, descriptions, and guideline. Returned content enters active model context.
8
4
 
9
5
  ## Install
10
6
 
@@ -12,74 +8,76 @@ The bundled `pi-session-pattern-miner` skill prepares one bounded sample. The mo
12
8
  pi install npm:@henryqw/pi-session-recall
13
9
  ```
14
10
 
11
+ Requires Node.js 22.19.0 or later.
12
+
15
13
  ## Use
16
14
 
17
- Start discovery with a distinctive query:
15
+ Ask the agent to search with distinctive keywords, for example:
18
16
 
19
17
  ```json
20
18
  { "query": "database migration rollback" }
21
19
  ```
22
20
 
23
- `session_search` returns ranked sessions. The top result includes nearby messages and session bookends.
21
+ Discovery returns ranked metadata and snippets. Use a result's `path` and `matchMessageId` to scroll to the relevant messages.
24
22
 
25
- Prepare a repository-scoped pattern-mining sample with one call:
23
+ | Surface | Type | Purpose |
24
+ | --- | --- | --- |
25
+ | `session_search` | tool | Agent tool to browse, search, read, scroll, or prepare a bounded pattern-mining corpus. |
26
+ | `/skill:pi-session-pattern-miner` | skill | Agent workflow, invoked by a user, to find repeated work and choose the smallest useful automation. |
26
27
 
27
- ```json
28
- {
29
- "operation": "prepare-pattern-miner",
30
- "scope": "repository",
31
- "limit": 10
32
- }
33
- ```
28
+ The tool supports these modes. Agents use `session_search`; users can invoke the skill with `/skill:pi-session-pattern-miner`.
34
29
 
35
- Use `scope:"all"` for cross-repository work. It still returns the corpus when repository inventory is unavailable.
30
+ | Mode | Call | Result |
31
+ | --- | --- | --- |
32
+ | Pattern preparation | `operation:"prepare-pattern-miner"` + `scope:"repository"` or `scope:"all"` | Up to ten recent lineage-unique sessions plus repository inventory. `limit` defaults to 10 and is clamped to [1,10]. Repository scope includes exact and descendant `cwd` values, filters before the limit, and excludes the current session file. |
33
+ | 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. `limit` defaults to 3 and is clamped to [1,10]. |
34
+ | Scroll | `sessionId` + `aroundMessageId` | Raw message roles, including tool results, within ±`window` ([1,20], default 5) of the anchor on the selected branch. Re-anchor on the last or first message ID to scroll. Across forks, pass the previous response's `branchTip`; `aroundMessageId` must lie on that branch. |
35
+ | 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`. |
36
+ | Browse | no args | Recent sessions with path, name, cwd, started date, and preview. `limit` defaults to 3 and is clamped to [1,10]. |
36
37
 
37
- Use IDs from a discovery result to ask for more context:
38
+ BM25 is a text-ranking method. Discovery returns index metadata; READ and SCROLL retrieve messages from saved session files.
39
+
40
+ To scroll directly to a discovery hit, pass its `path` and `matchMessageId`:
38
41
 
39
42
  ```json
40
43
  {
41
- "sessionId": "<returned sessionId>",
42
- "aroundMessageId": "<returned message entryId>",
44
+ "sessionId": "<result path>",
45
+ "aroundMessageId": "<result matchMessageId>",
43
46
  "window": 10
44
47
  }
45
48
  ```
46
49
 
47
- The follow-up returns up to ten messages before and after that anchor on the selected branch.
50
+ This returns up to ten messages before and after the anchor on the selected branch.
48
51
 
49
- | Surface | Type | Purpose |
50
- | --- | --- | --- |
51
- | `session_search` | tool | Search, inspect, or prepare a bounded mining corpus from past sessions. |
52
- | `pi-session-pattern-miner` | skill | Find repeated work and choose the smallest useful automation. |
52
+ Start pattern mining with one preparation call:
53
53
 
54
- BM25 is a text-ranking method. Hydrated results include messages read from saved session files.
54
+ ```json
55
+ {
56
+ "operation": "prepare-pattern-miner",
57
+ "scope": "repository",
58
+ "limit": 10
59
+ }
60
+ ```
55
61
 
56
- | Mode | Call | Result |
57
- | --- | --- | --- |
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. |
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
- | 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
- | Browse | no args | Recent sessions with path, name, cwd, started date, and preview. |
62
+ Use `scope:"all"` for cross-repository work. It still returns the corpus when repository inventory is unavailable.
63
63
 
64
64
  In the interactive TUI, the collapsed tool block shows the last five visual lines and the earlier-line count. Press `Ctrl+O` to expand the full bounded response. The model always receives the complete tool result.
65
65
 
66
- ### Skills
66
+ ### Pattern-miner skill
67
67
 
68
- Run `/skill:pi-session-pattern-miner` to find repeated workflows in past sessions. The skill makes one preparation call before interpretation.
68
+ Invoke `/skill:pi-session-pattern-miner` to find repeated workflows in past sessions. The skill makes exactly one preparation call before interpretation.
69
69
 
70
- It treats one lineage as one source. It requires two independent examples before recommending automation. A requested topic gets a focused confirmation search even when the prepared sample does not contain it.
70
+ It treats one lineage as one source and requires two independent examples before recommending automation. A requested topic gets a focused confirmation search even when the prepared sample does not contain it.
71
71
 
72
72
  After clustering, the skill always checks current candidate-relevant package manifests, scripts, skills, and instructions. It abstains if it cannot check them safely.
73
73
 
74
74
  ### Repository inventory
75
75
 
76
- Repository inventory contains discovery hints. It is never proof that a file owns a workflow. It does not verify the current worktree.
76
+ Repository inventory contains discovery hints. It is never proof that a file owns a workflow and does not verify the current worktree.
77
77
 
78
78
  Package scripts, executable paths, and instruction paths come from stage-0 entries in the Git index. Package content comes from indexed blobs. Git reads the objects locally in one check batch and one content batch. Lazy object fetching and replacement refs are disabled. Inventory never opens working-tree package paths.
79
79
 
80
- Executables need index mode `100755`. Instructions need a recognized name and a regular-file index mode.
81
-
82
- Skills come from Pi's effective command registry. Their canonical source paths must stay inside the repository.
80
+ Executables need index mode `100755`. Instructions need a recognized name and a regular-file index mode. Skills come from Pi's effective command registry, and their canonical source paths must stay inside the repository.
83
81
 
84
82
  Available inventory includes this provenance:
85
83
 
@@ -92,13 +90,11 @@ Available inventory includes this provenance:
92
90
  }
93
91
  ```
94
92
 
95
- It also sets `worktreeVerified:false`. Staged adds, changes, and deletes affect the snapshot. Unstaged changes, deletions, mode changes, symlinks, and untracked files do not.
96
-
97
- Inspect the current candidate files before assigning ownership. Abstain if targeted current-file checks cannot be done safely.
93
+ It also sets `worktreeVerified:false`. Staged adds, changes, and deletes affect the snapshot. Unstaged changes, deletions, mode changes, symlinks, and untracked files do not. Inspect current candidate files before assigning ownership; abstain if targeted current-file checks cannot be done safely.
98
94
 
99
95
  ## Flow
100
96
 
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)
97
+ ![Flowchart showing progressive keyword discovery returning path and matchMessageId for direct transcript scroll.](./docs/session-search-routing.svg)
102
98
 
103
99
  Search makes no model calls.
104
100
 
@@ -112,38 +108,24 @@ Search makes no model calls.
112
108
 
113
109
  ### Context and sync
114
110
 
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.
111
+ Discovery excludes the current session file when its path is available. Forked sessions collapse into their parent when both match.
116
112
 
117
- Before browse, discovery, or pattern preparation, the extension lazily syncs the index from the session tree.
118
-
119
- Pattern preparation reports `sync.walkComplete`, `sync.backlogRemaining`, and `sync.complete`. `sync.complete` is true only after a complete walk with no backlog.
113
+ Before browse, discovery, or pattern preparation, the extension lazily syncs the index from the session tree. Pattern preparation reports `sync.walkComplete`, `sync.backlogRemaining`, and `sync.complete`; `sync.complete` is true only after a complete walk with no backlog.
120
114
 
121
115
  ### Retrieval safety
122
116
 
123
- Adaptive discovery leaves tool-result messages out of returned context. Use `detail:"full"`, READ, or SCROLL when you explicitly need them.
117
+ 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
118
 
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.
119
+ Pattern preparation returns only non-empty user and assistant text. It never returns thinking blocks or tool-result content, and preserves citation and lineage metadata when a source cannot be read.
126
120
 
127
121
  Historical tool output may contain secrets or other sensitive data. Raw retrieval places that output in active model context.
128
122
 
129
123
  ## State and storage
130
124
 
131
- The extension maintains the derived SQLite search index at `~/.pi/agent/config/pi-session-recall/index.db`.
132
-
133
- This is derived state. Delete it and it rebuilds from your session files.
125
+ The extension maintains its derived SQLite search index at `~/.pi/agent/config/pi-session-recall/index.db`. Delete this derived state to rebuild it from your session files; no migration or recovery machinery is needed.
134
126
 
135
127
  The index and transcript reads stay local. Transcripts are read in place. Returned content follows the data path of your configured model provider.
136
128
 
137
- ## Roll back
138
-
139
- Pin the previous release:
140
-
141
- ```bash
142
- pi install npm:@henryqw/pi-session-recall@2.0.0
143
- ```
144
-
145
- No index migration or cleanup is needed.
146
-
147
129
  ## Limits and recovery
148
130
 
149
131
  Lazy index sync can fail while walking the session tree. Results still come from the current index and can be partly updated or stale. Files found before failure may have new content, while rows for files the walk did not reach stay stale.
@@ -154,17 +136,13 @@ Lazy index sync can fail while walking the session tree. Results still come from
154
136
 
155
137
  Session directories whose encoded path starts with `--tmp-` or `--private-tmp-` are never indexed. These sessions run from `/tmp` or `/private/tmp`.
156
138
 
157
- Session files over 32 MiB are excluded from indexing and hydration. Discovery cannot newly find them.
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.
139
+ Session files over 32 MiB are excluded from indexing and hydration, so discovery cannot newly find them. READ and SCROLL return an explicit size error. Discovery returns indexed metadata even when a source file changes afterward.
160
140
 
161
141
  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
142
 
163
- Repository scope fails outside Git or when repository inventory fails. All scope still returns its corpus in both cases.
164
-
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.
143
+ Repository scope fails outside Git or when repository inventory fails. All scope still returns its corpus in both cases. 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
144
 
167
- Preparation rejects `query`, session cursors, `window`, or `detail` in the same call. It also rejects `scope` without the operation.
145
+ Preparation rejects `query`, session cursors, or `window` in the same call. It also rejects `scope` without the operation.
168
146
 
169
147
  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
148
 
@@ -173,3 +151,13 @@ Inventory fails if the Git root output exceeds 4 KiB or the raw index listing ex
173
151
  Inventory accepts at most 512 package manifests. Each indexed manifest can be at most 1 MiB, and their declared sizes can total at most 16 MiB. One bounded batch checks all blob sizes before one bounded batch reads their content. Blob order, type, size, UTF-8, and exact output framing are checked before manifest data is parsed.
174
152
 
175
153
  Session and top-level `contentTruncated` report transcript budget trimming only. Session `truncated` reports omitted middle messages.
154
+
155
+ ### Rollback
156
+
157
+ To return to the older 2.1.1 behavior, pin that release:
158
+
159
+ ```bash
160
+ pi install npm:@henryqw/pi-session-recall@2.1.1
161
+ ```
162
+
163
+ No index migration or cleanup is needed.
@@ -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.2",
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
  }