@nklisch/pi-enhanced 0.4.1 → 0.4.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.
@@ -3,20 +3,18 @@ import { join } from "node:path";
3
3
  import { createInterface } from "node:readline";
4
4
 
5
5
  import { ASTRA_MODEL_ID, ASTRA_PROVIDER } from "./activation.js";
6
+ import { resolveProjectIdentity } from "./scope.js";
6
7
 
7
- /** Past-session access. Pi stores sessions as JSONL trees at
8
- * `<agentDir>/sessions/<cwd-slug>/<timestamp>_<uuid>.jsonl`, with sub-agent
9
- * task sessions nested under a `tasks/` subdir (excluded here — they are
10
- * worker noise, and their parent session carries the durable signal). */
8
+ export interface SessionRevision {
9
+ mtimeMs: number;
10
+ size: number;
11
+ key: string;
12
+ }
11
13
 
12
- export interface SessionFileInfo {
14
+ export interface SessionFileInfo extends SessionRevision {
13
15
  path: string;
14
16
  id: string;
15
17
  cwd: string;
16
- mtimeMs: number;
17
- /** True when any assistant message in the file was produced by astra, or a
18
- * model_change switched to it. Assistant messages carry provider/model
19
- * directly, so sessions that were astra from the start are still caught. */
20
18
  astra: boolean;
21
19
  }
22
20
 
@@ -30,18 +28,29 @@ export interface SessionSearchHit {
30
28
 
31
29
  const HITS_PER_FILE_CAP = 50;
32
30
  const SUMMARIZED_EXCERPT = 200;
33
- const FULL_EXCERPT = 2000;
31
+ const FULL_EXCERPT = 2_000;
34
32
 
35
33
  async function* iterLines(path: string): AsyncGenerator<string> {
36
34
  const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
37
35
  for await (const line of rl) yield line;
38
36
  }
39
37
 
38
+ export function revisionFor(path: string): SessionRevision | undefined {
39
+ try {
40
+ const stat = statSync(path);
41
+ const mtimeMs = stat.mtimeMs;
42
+ const size = stat.size;
43
+ return { mtimeMs, size, key: `${mtimeMs}:${size}` };
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
40
49
  /** Enumerate top-level session files across all project dirs, newest first. */
41
- export function listSessionFiles(sessionsDir: string, maxAgeDays?: number): { path: string; mtimeMs: number }[] {
50
+ export function listSessionFiles(sessionsDir: string, maxAgeDays?: number, nowMs = Date.now()): Array<{ path: string } & SessionRevision> {
42
51
  if (!existsSync(sessionsDir)) return [];
43
- const cutoff = maxAgeDays === undefined ? null : Date.now() - maxAgeDays * 86_400_000;
44
- const files: { path: string; mtimeMs: number }[] = [];
52
+ const cutoff = maxAgeDays === undefined ? null : nowMs - maxAgeDays * 86_400_000;
53
+ const files: Array<{ path: string } & SessionRevision> = [];
45
54
  for (const dir of readdirSync(sessionsDir)) {
46
55
  const dirPath = join(sessionsDir, dir);
47
56
  try {
@@ -49,12 +58,12 @@ export function listSessionFiles(sessionsDir: string, maxAgeDays?: number): { pa
49
58
  for (const file of readdirSync(dirPath)) {
50
59
  if (!file.endsWith(".jsonl")) continue;
51
60
  const path = join(dirPath, file);
52
- const mtimeMs = statSync(path).mtimeMs;
53
- if (cutoff !== null && mtimeMs < cutoff) continue;
54
- files.push({ path, mtimeMs });
61
+ const revision = revisionFor(path);
62
+ if (!revision || (cutoff !== null && revision.mtimeMs < cutoff)) continue;
63
+ files.push({ path, ...revision });
55
64
  }
56
65
  } catch {
57
- continue; // unreadable dir: skip, never fail recall over it
66
+ // Recall remains available when one session directory is unreadable.
58
67
  }
59
68
  }
60
69
  return files.sort((a, b) => b.mtimeMs - a.mtimeMs);
@@ -69,38 +78,30 @@ function isAstraMarker(entry: Record<string, unknown>): boolean {
69
78
  return false;
70
79
  }
71
80
 
72
- /** Identify a session file: header id/cwd plus astra usage, streaming so large
73
- * files stop early once both identity and astra-ness are known. */
74
- export async function identifySession(path: string, mtimeMs: number): Promise<SessionFileInfo> {
81
+ export async function identifySession(path: string, revisionOrMtime: SessionRevision | number): Promise<SessionFileInfo> {
82
+ const revision = typeof revisionOrMtime === "number"
83
+ ? (revisionFor(path) ?? { mtimeMs: revisionOrMtime, size: 0, key: `${revisionOrMtime}:0` })
84
+ : revisionOrMtime;
75
85
  let id = "";
76
86
  let cwd = "";
77
87
  let astra = false;
78
88
  for await (const line of iterLines(path)) {
79
89
  if (id && cwd && astra) break;
80
90
  let entry: Record<string, unknown>;
81
- try {
82
- entry = JSON.parse(line) as Record<string, unknown>;
83
- } catch {
84
- continue;
85
- }
91
+ try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
86
92
  if (entry.type === "session") {
87
93
  id = String(entry.id ?? "");
88
94
  cwd = String(entry.cwd ?? "");
89
- } else if (isAstraMarker(entry)) {
90
- astra = true;
91
- }
95
+ } else if (isAstraMarker(entry)) astra = true;
92
96
  }
93
- return { path, id, cwd, mtimeMs, astra };
97
+ return { path, id, cwd, astra, ...revision };
94
98
  }
95
99
 
96
100
  function textOf(content: unknown): string {
97
101
  if (typeof content === "string") return content;
98
102
  if (!Array.isArray(content)) return "";
99
- return content
100
- .filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null)
101
- .map((b) => (typeof b.text === "string" ? b.text : ""))
102
- .filter(Boolean)
103
- .join(" ");
103
+ return content.filter((block): block is Record<string, unknown> => typeof block === "object" && block !== null)
104
+ .map((block) => typeof block.text === "string" ? block.text : "").filter(Boolean).join(" ");
104
105
  }
105
106
 
106
107
  function truncate(text: string, cap: number): string {
@@ -108,71 +109,68 @@ function truncate(text: string, cap: number): string {
108
109
  return clean.length <= cap ? clean : `${clean.slice(0, cap)}…`;
109
110
  }
110
111
 
111
- /** Search astra sessions for a query. Summarized by default: tool hits return
112
- * name + truncated args/results, message hits return short excerpts. `full`
113
- * raises the excerpt cap. Only astra sessions are searched — other models'
114
- * sessions are noise for this tool and may carry unrelated secrets. */
115
112
  export async function searchAstraSessions(
116
113
  sessionsDir: string,
117
114
  query: string,
118
- options: { full?: boolean; limit?: number; maxAgeDays?: number } = {},
115
+ options: { full?: boolean; limit?: number; maxAgeDays?: number; projectId?: string; recallScope?: "current" | "all" } = {},
119
116
  ): Promise<SessionSearchHit[]> {
120
117
  const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
121
118
  if (terms.length === 0) return [];
122
- const limit = options.limit ?? 10;
119
+ const limit = Math.max(1, options.limit ?? 10);
123
120
  const cap = options.full ? FULL_EXCERPT : SUMMARIZED_EXCERPT;
124
121
  const hits: SessionSearchHit[] = [];
125
-
126
- for (const { path, mtimeMs } of listSessionFiles(sessionsDir, options.maxAgeDays)) {
127
- if (hits.length >= limit) break;
128
- const info = await identifySession(path, mtimeMs);
122
+ const sessions: SessionFileInfo[] = [];
123
+ for (const file of listSessionFiles(sessionsDir, options.maxAgeDays)) {
124
+ const info = await identifySession(file.path, file);
129
125
  if (!info.astra) continue;
126
+ if ((options.recallScope ?? "current") !== "all" &&
127
+ (!info.cwd || options.projectId === undefined || resolveProjectIdentity(info.cwd) !== options.projectId)) continue;
128
+ sessions.push(info);
129
+ }
130
+ sessions.sort((a, b) => {
131
+ const projectRank = Number(Boolean(options.projectId && b.cwd) && resolveProjectIdentity(b.cwd) === options.projectId) -
132
+ Number(Boolean(options.projectId && a.cwd) && resolveProjectIdentity(a.cwd) === options.projectId);
133
+ return projectRank || b.mtimeMs - a.mtimeMs;
134
+ });
130
135
 
136
+ for (const info of sessions) {
137
+ if (hits.length >= limit) break;
131
138
  const fileHits: SessionSearchHit[] = [];
132
- for await (const line of iterLines(path)) {
139
+ for await (const line of iterLines(info.path)) {
133
140
  if (fileHits.length >= HITS_PER_FILE_CAP) break;
134
- if (!terms.every((t) => line.toLowerCase().includes(t))) continue;
141
+ if (!terms.every((term) => line.toLowerCase().includes(term))) continue;
135
142
  let entry: Record<string, unknown>;
136
- try {
137
- entry = JSON.parse(line) as Record<string, unknown>;
138
- } catch {
139
- continue;
140
- }
143
+ try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
141
144
  if (entry.type !== "message") continue;
142
145
  const msg = entry.message as Record<string, unknown>;
143
146
  const role = msg.role as string;
144
147
  const timestamp = String(entry.timestamp ?? "");
145
148
  const base = { sessionId: info.id, project: info.cwd, timestamp };
146
149
  if (role === "user" || role === "assistant") {
147
- const content = msg.content;
148
- if (Array.isArray(content)) {
149
- for (const block of content as Record<string, unknown>[]) {
150
+ if (Array.isArray(msg.content)) {
151
+ for (const block of msg.content as Record<string, unknown>[]) {
150
152
  if (block.type === "toolCall") {
151
153
  const args = truncate(JSON.stringify(block.arguments ?? {}), cap);
152
- if (terms.every((t) => `${String(block.name)} ${args}`.toLowerCase().includes(t))) {
154
+ if (terms.every((term) => `${String(block.name)} ${args}`.toLowerCase().includes(term))) {
153
155
  fileHits.push({ ...base, kind: "toolCall", excerpt: `${String(block.name)}(${args})` });
154
156
  }
155
157
  } else if (block.type === "text" && typeof block.text === "string") {
156
- const text = block.text as string;
157
- if (terms.every((t) => text.toLowerCase().includes(t))) {
158
+ const text = block.text;
159
+ if (terms.every((term) => text.toLowerCase().includes(term))) {
158
160
  fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
159
161
  }
160
162
  }
161
163
  }
162
164
  } else {
163
- const text = textOf(content);
164
- if (terms.every((t) => text.toLowerCase().includes(t))) {
165
+ const text = textOf(msg.content);
166
+ if (terms.every((term) => text.toLowerCase().includes(term))) {
165
167
  fileHits.push({ ...base, kind: role as "user" | "assistant", excerpt: truncate(text, cap) });
166
168
  }
167
169
  }
168
170
  } else if (role === "toolResult") {
169
171
  const text = textOf(msg.content);
170
- if (terms.every((t) => `${String(msg.toolName ?? "")} ${text}`.toLowerCase().includes(t))) {
171
- fileHits.push({
172
- ...base,
173
- kind: "toolResult",
174
- excerpt: `${String(msg.toolName ?? "tool")} → ${truncate(text, cap)}`,
175
- });
172
+ if (terms.every((term) => `${String(msg.toolName ?? "")} ${text}`.toLowerCase().includes(term))) {
173
+ fileHits.push({ ...base, kind: "toolResult", excerpt: `${String(msg.toolName ?? "tool")} → ${truncate(text, cap)}` });
176
174
  }
177
175
  }
178
176
  }
@@ -181,45 +179,59 @@ export async function searchAstraSessions(
181
179
  return hits.slice(0, limit);
182
180
  }
183
181
 
184
- /** Compact transcript of one session for the distiller: user/assistant text
185
- * plus tool-call names with truncated args, tool results reduced to a short
186
- * marker. Full results are deliberately excluded — they are the largest and
187
- * least durable content, and the biggest secret-resurfacing vector. */
182
+ function compactEntry(entry: Record<string, unknown>): string {
183
+ if (entry.type !== "message") return "";
184
+ const msg = entry.message as Record<string, unknown>;
185
+ if (msg.role === "user") return `USER: ${truncate(textOf(msg.content), 1_500)}`;
186
+ if (msg.role === "assistant") {
187
+ const blocks = Array.isArray(msg.content) ? msg.content as Record<string, unknown>[] : [];
188
+ const content = blocks.map((block) => {
189
+ if (block.type === "text") return truncate(String(block.text ?? ""), 1_500);
190
+ if (block.type === "toolCall") return `[tool: ${String(block.name)} ${truncate(JSON.stringify(block.arguments ?? {}), 200)}]`;
191
+ return "";
192
+ }).filter(Boolean).join(" ");
193
+ return content ? `ASSISTANT: ${content}` : "";
194
+ }
195
+ if (msg.role === "toolResult") {
196
+ return `[result: ${String(msg.toolName ?? "tool")}${msg.isError ? " (error)" : ""}] ${truncate(textOf(msg.content), 200)}`;
197
+ }
198
+ return "";
199
+ }
200
+
201
+ /**
202
+ * Keep a small opening for problem context and devote the rest of the budget
203
+ * to the latest conversation, where final decisions and corrections live.
204
+ */
188
205
  export async function readSessionDigest(path: string, capBytes = 60_000): Promise<string> {
189
- const parts: string[] = [];
190
- let size = 0;
206
+ const openingCap = Math.max(1_000, Math.floor(capBytes * 0.2));
207
+ const tailCap = Math.max(1_000, capBytes - openingCap - 80);
208
+ const opening: string[] = [];
209
+ const tail: string[] = [];
210
+ let openingSize = 0;
211
+ let tailSize = 0;
212
+ let openingClosed = false;
213
+ let omitted = false;
214
+
191
215
  for await (const line of iterLines(path)) {
192
- if (size >= capBytes) {
193
- parts.push("… [transcript truncated]");
194
- break;
195
- }
196
216
  let entry: Record<string, unknown>;
197
- try {
198
- entry = JSON.parse(line) as Record<string, unknown>;
199
- } catch {
217
+ try { entry = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
218
+ const chunk = compactEntry(entry);
219
+ if (!chunk) continue;
220
+ if (!openingClosed && openingSize + chunk.length + 1 <= openingCap) {
221
+ opening.push(chunk);
222
+ openingSize += chunk.length + 1;
200
223
  continue;
201
224
  }
202
- if (entry.type !== "message") continue;
203
- const msg = entry.message as Record<string, unknown>;
204
- let chunk = "";
205
- if (msg.role === "user") chunk = `USER: ${truncate(textOf(msg.content), 1500)}`;
206
- else if (msg.role === "assistant") {
207
- const content = Array.isArray(msg.content) ? (msg.content as Record<string, unknown>[]) : [];
208
- const texts = content
209
- .map((b) => {
210
- if (b.type === "text") return truncate(String(b.text ?? ""), 1500);
211
- if (b.type === "toolCall") return `[tool: ${String(b.name)} ${truncate(JSON.stringify(b.arguments ?? {}), 200)}]`;
212
- return "";
213
- })
214
- .filter(Boolean)
215
- .join(" ");
216
- chunk = texts ? `ASSISTANT: ${texts}` : "";
217
- } else if (msg.role === "toolResult") {
218
- chunk = `[result: ${String(msg.toolName ?? "tool")}${msg.isError ? " (error)" : ""}] ${truncate(textOf(msg.content), 200)}`;
225
+ // Once a chunk overflows the opening budget, all later entries belong to
226
+ // the tail so their chronology cannot jump ahead of an older tail entry.
227
+ openingClosed = true;
228
+ tail.push(chunk);
229
+ tailSize += chunk.length + 1;
230
+ while (tailSize > tailCap && tail.length > 1) {
231
+ tailSize -= (tail.shift()?.length ?? 0) + 1;
232
+ omitted = true;
219
233
  }
220
- if (!chunk) continue;
221
- size += chunk.length;
222
- parts.push(chunk);
223
234
  }
224
- return parts.join("\n");
235
+ if (tail.length === 0) return opening.join("\n");
236
+ return [...opening, ...(omitted ? ["… [earlier transcript omitted; latest decisions retained] …"] : []), ...tail].join("\n");
225
237
  }