pi-jar 0.1.1 → 0.1.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/src/delegate.ts CHANGED
@@ -15,7 +15,9 @@ export const MAX_DELEGATES = 4;
15
15
  export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
16
16
  const MAX_OUTPUT_CHARS = 12_000;
17
17
  const TIMEOUT_MS = 20 * 60_000;
18
- const STATUS_REFRESH_MS = 10_000;
18
+ const STATUS_REFRESH_MS = 20_000;
19
+ const LIVE_UPDATE_MS = 1000;
20
+ const MAX_EVENT_TEXT = 16_000;
19
21
 
20
22
  export type DelegateState = "queued" | "working" | "done" | "failed";
21
23
  export interface DelegateRun {
@@ -56,9 +58,20 @@ export function delegateArgs(task: string, model: string | undefined, thinking:
56
58
  return args;
57
59
  }
58
60
 
59
- const textOf = (message: { content?: unknown }) => Array.isArray(message.content)
60
- ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n")
61
- : typeof message.content === "string" ? message.content : "";
61
+ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
62
+ if (typeof message.content === "string") return message.content.slice(0, limit);
63
+ if (!Array.isArray(message.content)) return "";
64
+ let out = "";
65
+ for (const part of message.content as Array<{ type?: string; text?: string }>) {
66
+ if (part?.type !== "text" || typeof part.text !== "string" || !part.text) continue;
67
+ const separator = out ? "\n" : "";
68
+ const room = limit - out.length - separator.length;
69
+ if (room <= 0) break;
70
+ out += separator + part.text.slice(0, room);
71
+ if (part.text.length > room) break;
72
+ }
73
+ return out;
74
+ };
62
75
 
63
76
  /** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
64
77
  export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
@@ -171,20 +184,36 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
171
184
  } catch { /* status is decoration */ }
172
185
  }
173
186
  };
174
- const details = () => ({ runs: runs.map((run) => ({ ...run })), write });
175
- const update = () => {
187
+ const details = (includeOutput = true) => ({ runs: runs.map((run) => includeOutput ? { ...run } : { ...run, output: "" }), write });
188
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
189
+ let lastUpdateAt = 0;
190
+ const emitUpdate = () => {
191
+ updateTimer = undefined;
192
+ lastUpdateAt = Date.now();
176
193
  publish();
177
- onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details() });
194
+ onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
195
+ };
196
+ const update = () => {
197
+ if (!onUpdate) { publish(); return; }
198
+ const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
199
+ if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
200
+ if (!updateTimer) {
201
+ updateTimer = setTimeout(emitUpdate, wait);
202
+ updateTimer.unref?.();
203
+ }
204
+ };
205
+ const flushUpdate = () => {
206
+ if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
207
+ emitUpdate();
178
208
  };
179
209
  const refresh = setInterval(publish, STATUS_REFRESH_MS);
180
210
  refresh.unref?.();
181
211
  try {
182
- update();
183
212
  await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
184
213
  } finally {
185
214
  clearInterval(refresh);
186
215
  for (const run of runs) if (run.state !== "done") run.state = "failed";
187
- publish();
216
+ flushUpdate();
188
217
  }
189
218
  const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
190
219
  return { content: [{ type: "text", text: report }], details: details() };
package/src/diff-view.ts CHANGED
@@ -167,8 +167,8 @@ export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTrac
167
167
  }
168
168
  }
169
169
  if (!path) path = fallback.shift();
170
- if (path) tracker()?.markDirty(path);
171
- changed();
170
+ const failed = (event as { isError?: unknown }).isError === true;
171
+ if (path && !failed && tracker()?.markDirty(path)) changed();
172
172
  });
173
173
  const open = async (ctx: ExtensionContext) => {
174
174
  const current = tracker();
package/src/goal-loop.ts CHANGED
@@ -45,6 +45,7 @@ export class GoalLoop {
45
45
  private readonly pi: ExtensionAPI;
46
46
  private readonly options: GoalLoopOptions;
47
47
  private prompting = 0;
48
+ private contextDirty = true;
48
49
  private restoreRole: (() => Promise<void>) | undefined;
49
50
  private roleInUse: "implement" | "advisor" | undefined;
50
51
 
@@ -63,6 +64,7 @@ export class GoalLoop {
63
64
  const store = this.options.goals();
64
65
  if (!store?.isActive()) return;
65
66
  store.setStatus("paused", { reason });
67
+ this.contextDirty = true;
66
68
  if (ctx) { ctx.ui.notify("◎ Goal paused · " + reason + " · /goal resume", "info"); this.options.changed?.(ctx); }
67
69
  }
68
70
 
@@ -127,6 +129,7 @@ export class GoalLoop {
127
129
  if (goal.phase !== "audit") return { ok: false, message: "Cannot complete yet: finish your turn; pi-jar runs an audit pass before a goal can be completed." };
128
130
  if (!evidence.trim()) return { ok: false, message: "Provide concrete evidence (commands run, results, files) to complete the goal." };
129
131
  if (!store.setStatus("complete", { evidence })) return { ok: false, message: "Could not record goal completion." };
132
+ this.contextDirty = true;
130
133
  ctx.ui.notify("◎ Goal complete · " + goal.text, "info");
131
134
  this.options.changed?.(ctx);
132
135
  this.options.completed?.(ctx, { ...goal, status: "complete", evidence });
@@ -138,6 +141,7 @@ export class GoalLoop {
138
141
  const store = this.options.goals();
139
142
  if (!store) { ctx.ui.notify("Goal state is unavailable before the session starts", "warning"); return; }
140
143
  if (!store.set(text)) { ctx.ui.notify("Could not set goal; keep it concise and plain-text", "error"); return; }
144
+ this.contextDirty = true;
141
145
  this.options.changed?.(ctx);
142
146
  ctx.ui.notify("◎ Goal active · " + store.current()!.text, "info");
143
147
  if (this.options.planActive()) { this.pause(ctx, "plan mode is on"); return; }
@@ -174,6 +178,7 @@ export class GoalLoop {
174
178
  if (!goal || goal.status !== "active") return { content: [{ type: "text", text: "No active goal." }], details: { action: "block", ok: false } };
175
179
  const reason = (params.reason ?? "").trim() || "needs user input";
176
180
  this.options.goals()?.setStatus("paused", { reason });
181
+ this.contextDirty = true;
177
182
  ctx.ui.notify("◎ Goal blocked · " + reason, "warning");
178
183
  this.options.changed?.(ctx);
179
184
  return { content: [{ type: "text", text: "Goal paused for the user: " + reason }], details: { action: "block", ok: true }, terminate: true };
@@ -198,7 +203,7 @@ export class GoalLoop {
198
203
  const verb = raw.toLowerCase();
199
204
  if (/^(?:clear|off|none|drop)$/.test(verb)) {
200
205
  await this.restore();
201
- if (store.clear()) { this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
206
+ if (store.clear()) { this.contextDirty = true; this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
202
207
  return;
203
208
  }
204
209
  if (verb === "pause") { this.pause(ctx, "paused by you"); return; }
@@ -208,6 +213,7 @@ export class GoalLoop {
208
213
  if (this.options.planActive()) { ctx.ui.notify("Leave plan mode before resuming the goal", "warning"); return; }
209
214
  store.setStatus("active");
210
215
  store.setRound(0, goal.phase);
216
+ this.contextDirty = true;
211
217
  this.options.changed?.(ctx);
212
218
  this.pi.sendUserMessage("Resume work on the active goal: " + goal.text, { deliverAs: "followUp" });
213
219
  return;
@@ -257,21 +263,37 @@ export class GoalLoop {
257
263
  const goal = this.goal();
258
264
  if (!goal || goal.status === "complete") return;
259
265
  if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
266
+ this.contextDirty = true;
260
267
  return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
261
268
  });
262
269
 
263
- // Keep only the newest goal context and continuation so repeated rounds do not bloat the prompt.
270
+ // Keep only the newest goal context and continuation. Scan in place first and allocate a new
271
+ // message array only when stale pi-jar messages actually need pruning.
264
272
  this.pi.on("context", async (event) => {
273
+ if (!this.contextDirty) return;
265
274
  const goal = this.goal();
266
- const seen = new Set<string>();
267
- const messages = [...event.messages].reverse().filter((raw) => {
275
+ const active = !!goal && goal.status !== "complete";
276
+ let latestContext = -1;
277
+ let latestContinuation = -1;
278
+ let needsPrune = false;
279
+ for (let index = event.messages.length - 1; index >= 0; index--) {
280
+ const type = (event.messages[index] as { customType?: string }).customType;
281
+ if (type === CONTEXT_TYPE) {
282
+ if (!active || latestContext >= 0) needsPrune = true;
283
+ else latestContext = index;
284
+ } else if (type === CONTINUATION_TYPE) {
285
+ if (!active || latestContinuation >= 0) needsPrune = true;
286
+ else latestContinuation = index;
287
+ }
288
+ }
289
+ this.contextDirty = false;
290
+ if (!needsPrune) return;
291
+ return { messages: event.messages.filter((raw, index) => {
268
292
  const type = (raw as { customType?: string }).customType;
269
- if (type !== CONTEXT_TYPE && type !== CONTINUATION_TYPE) return true;
270
- if (!goal || goal.status === "complete" || seen.has(type)) return false;
271
- seen.add(type);
293
+ if (type === CONTEXT_TYPE) return active && index === latestContext;
294
+ if (type === CONTINUATION_TYPE) return active && index === latestContinuation;
272
295
  return true;
273
- }).reverse();
274
- return { messages };
296
+ }) };
275
297
  });
276
298
 
277
299
  this.pi.on("agent_before_settle", async (event, ctx) => {
@@ -295,6 +317,7 @@ export class GoalLoop {
295
317
  store.setRound(round, audit ? "audit" : "implement");
296
318
  await this.useRole(audit ? "advisor" : "implement", ctx);
297
319
  this.options.changed?.(ctx);
320
+ this.contextDirty = true;
298
321
  return {
299
322
  entries: [{ type: "custom_message", customType: CONTINUATION_TYPE, display: false,
300
323
  content: audit ? this.auditMessage(goal, round) : this.implementMessage(goal, round) }],
@@ -308,6 +331,8 @@ export class GoalLoop {
308
331
  this.options.changed?.(ctx);
309
332
  });
310
333
 
334
+ this.pi.on("session_start", () => { this.contextDirty = true; });
335
+ this.pi.on("session_tree", () => { this.contextDirty = true; });
311
336
  this.pi.on("session_shutdown", async () => { await this.restore(); });
312
337
  }
313
338
  }
package/src/goals.ts CHANGED
@@ -72,11 +72,22 @@ export class GoalStore {
72
72
 
73
73
  restore(branch: readonly unknown[]): void {
74
74
  this.value = undefined;
75
- for (const raw of branch) {
75
+ // Collect only goal events while walking backward; stop at the newest valid set/clear
76
+ // checkpoint, then replay that tiny list forward.
77
+ const replay: unknown[] = [];
78
+ for (let index = branch.length - 1; index >= 0; index--) {
79
+ const raw = branch[index];
76
80
  if (!raw || typeof raw !== "object") continue;
77
81
  const entry = raw as Record<string, unknown>;
78
- if (entry.type === "custom" && entry.customType === GOAL_ENTRY) this.apply(entry.data);
82
+ if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY || !entry.data || typeof entry.data !== "object") continue;
83
+ const data = entry.data as Record<string, unknown>;
84
+ replay.push(data);
85
+ const validCheckpoint = data.op === "clear" && data.v === 1
86
+ || data.op === "set" && ((data.v === 1 && validGoal(data.text))
87
+ || (data.v === 2 && validId(data.id) && validGoal(data.text)));
88
+ if (validCheckpoint) break;
79
89
  }
90
+ for (let index = replay.length - 1; index >= 0; index--) this.apply(replay[index]);
80
91
  }
81
92
 
82
93
  private commit(event: GoalEvent): boolean {
package/src/history-ui.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
4
+ import { sessionBranch } from "./session-branch.ts";
4
5
 
5
6
  /** Separate, read-only view. Pi's native transcript and session file are never modified. */
6
7
  export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
7
8
  if (!ctx.hasUI || ctx.mode !== "tui") return;
8
9
  // Capture the active branch ONCE on open. Paging and search operate on this snapshot.
9
- const snapshot = createHistorySnapshot(ctx.sessionManager.getBranch());
10
+ const snapshot = createHistorySnapshot(sessionBranch(ctx) as ReturnType<typeof ctx.sessionManager.getBranch>);
10
11
  await ctx.ui.custom<void>((tui, theme, _keys, done) => {
11
12
  let current = historyPage(snapshot);
12
13
  let selected = Math.max(0, current.items.length - 1);
@@ -4,6 +4,7 @@ import { openPanel, type PanelTab } from "./panel.ts";
4
4
  import type { Quota } from "./quota.ts";
5
5
  import type { SideUsage } from "./side-model.ts";
6
6
  import { collectUsage, usageLines } from "./usage-view.ts";
7
+ import { sessionBranch } from "./session-branch.ts";
7
8
 
8
9
  /** Pi's default compaction reserve, used when its settings cannot be read. */
9
10
  const DEFAULT_RESERVE = 16_384;
@@ -52,14 +53,25 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
52
53
  });
53
54
  pi.on("session_start", () => { parts = undefined; });
54
55
  const tabs = (ctx: ExtensionContext): PanelTab[] => {
55
- const context = contextFor(pi, ctx, parts);
56
+ // These panels can scan a large branch/projection. Snapshot each expensive view once per
57
+ // panel open, lazily on first render, so scrolling/resizing does not repeat O(history) work.
58
+ let usage: ReturnType<typeof collectUsage> | undefined;
59
+ let context: ReturnType<typeof contextFor> | undefined;
56
60
  return [
57
- { name: "Usage", render: (width, fg) => usageLines({
58
- stats: collectUsage(ctx.sessionManager.getBranch() as never, deps.side.all()),
59
- ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
60
- ...(deps.quota(ctx) ? { quota: deps.quota(ctx)! } : {}),
61
- quotaEnabled: deps.quotaEnabled(), now: Date.now() }, width, fg) },
62
- { name: "Context", render: (width, fg) => contextLines(context, width, fg) }
61
+ { name: "Usage", render: (width, fg) => {
62
+ usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
63
+ const quota = deps.quota(ctx);
64
+ return usageLines({
65
+ stats: usage,
66
+ ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
67
+ ...(quota ? { quota } : {}),
68
+ quotaEnabled: deps.quotaEnabled(), now: Date.now()
69
+ }, width, fg);
70
+ } },
71
+ { name: "Context", render: (width, fg) => {
72
+ context ??= contextFor(pi, ctx, parts);
73
+ return contextLines(context, width, fg);
74
+ } }
63
75
  ];
64
76
  };
65
77
  const plain = (_color: string, text: string) => text;
package/src/plan.ts CHANGED
@@ -9,6 +9,7 @@ import { type ModelRoleManager } from "./model-roles.ts";
9
9
  import { openPlanView } from "./plan-view.ts";
10
10
  import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSteps, validatePlanDocument } from "./plan-utils.ts";
11
11
  import { cleanText } from "./status.ts";
12
+ import { sessionBranch } from "./session-branch.ts";
12
13
  import { type TodoStore } from "./tasks.ts";
13
14
 
14
15
  export const PLAN_ENTRY = "pi-jar.plan";
@@ -135,6 +136,7 @@ export class PlanMode {
135
136
  try { this.ensureDirectory(ctx); }
136
137
  catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
137
138
  this.enabled = true;
139
+ this.contextDirty = true;
138
140
  this.pendingReview = false;
139
141
  this.reminders = 0;
140
142
  this.toolsBefore = this.pi.getActiveTools();
@@ -149,6 +151,7 @@ export class PlanMode {
149
151
  private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
150
152
  const wasEnabled = this.enabled;
151
153
  this.enabled = false;
154
+ this.contextDirty = true;
152
155
  this.compacting = false;
153
156
  this.pendingReview = false;
154
157
  this.compactGeneration++;
@@ -293,14 +296,16 @@ export class PlanMode {
293
296
  }
294
297
 
295
298
  private restoreFrom(branch: readonly unknown[]): PlanState | undefined {
296
- let state: PlanState | undefined;
297
- for (const raw of branch) {
299
+ // Only the newest persisted plan state matters. Walk backward so resuming a long session
300
+ // does not inspect every historical transcript entry.
301
+ for (let index = branch.length - 1; index >= 0; index--) {
302
+ const raw = branch[index];
298
303
  if (!raw || typeof raw !== "object") continue;
299
304
  const entry = raw as Record<string, unknown>;
300
305
  if (entry.type !== "custom" || entry.customType !== PLAN_ENTRY || !entry.data || typeof entry.data !== "object") continue;
301
306
  const data = entry.data as Record<string, unknown>;
302
307
  if ((data.v !== 1 && data.v !== 2) || typeof data.enabled !== "boolean" || !Array.isArray(data.steps)) continue;
303
- state = {
308
+ return {
304
309
  v: 2, enabled: data.enabled,
305
310
  steps: data.steps.filter((item): item is string => typeof item === "string").map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50),
306
311
  text: typeof data.text === "string" ? safePlanText(data.text) : "",
@@ -308,7 +313,7 @@ export class PlanMode {
308
313
  ...(typeof data.title === "string" ? { title: cleanText(data.title, 120) } : {})
309
314
  };
310
315
  }
311
- return state;
316
+ return undefined;
312
317
  }
313
318
 
314
319
  private async restore(branch: readonly unknown[], ctx: ExtensionContext): Promise<void> {
@@ -411,14 +416,34 @@ export class PlanMode {
411
416
 
412
417
  this.pi.on("before_agent_start", async () => {
413
418
  if (!this.enabled) return;
419
+ this.contextDirty = true;
414
420
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
415
421
  });
416
422
 
423
+ // Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
424
+ // context and newest reminder; when it is off, remove all stale plan prompt messages.
417
425
  this.pi.on("context", async (event) => {
418
- if (this.enabled) return;
419
- return { messages: event.messages.filter((raw) => {
420
- const message = raw as { customType?: string };
421
- return message.customType !== "pi-jar.plan-context" && message.customType !== "pi-jar.plan-reminder";
426
+ if (!this.contextDirty) return;
427
+ let latestContext = -1;
428
+ let latestReminder = -1;
429
+ let needsPrune = false;
430
+ for (let index = event.messages.length - 1; index >= 0; index--) {
431
+ const type = (event.messages[index] as { customType?: string }).customType;
432
+ if (type === "pi-jar.plan-context") {
433
+ if (!this.enabled || latestContext >= 0) needsPrune = true;
434
+ else latestContext = index;
435
+ } else if (type === "pi-jar.plan-reminder") {
436
+ if (!this.enabled || latestReminder >= 0) needsPrune = true;
437
+ else latestReminder = index;
438
+ }
439
+ }
440
+ this.contextDirty = false;
441
+ if (!needsPrune) return;
442
+ return { messages: event.messages.filter((raw, index) => {
443
+ const type = (raw as { customType?: string }).customType;
444
+ if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
445
+ if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
446
+ return true;
422
447
  }) };
423
448
  });
424
449
 
@@ -434,6 +459,7 @@ export class PlanMode {
434
459
  return;
435
460
  }
436
461
  this.reminders++;
462
+ this.contextDirty = true;
437
463
  return {
438
464
  entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
439
465
  content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
@@ -453,8 +479,8 @@ export class PlanMode {
453
479
  if (this.enabled && this.pendingReview) await this.review(ctx);
454
480
  });
455
481
 
456
- this.pi.on("session_start", async (_event, ctx) => { await this.restore(ctx.sessionManager.getBranch(), ctx); });
457
- this.pi.on("session_tree", async (_event, ctx) => { await this.restore(ctx.sessionManager.getBranch(), ctx); });
482
+ this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
483
+ this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
458
484
  this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
459
485
  }
460
486
  }
@@ -4,8 +4,21 @@ import { cleanText } from "./status.ts";
4
4
 
5
5
  export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
6
6
 
7
- const userText = (message: { content?: unknown }) => typeof message.content === "string" ? message.content
8
- : Array.isArray(message.content) ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n") : "";
7
+ const MAX_PROMPT_CHARS = 8000;
8
+ const userText = (message: { content?: unknown }): string => {
9
+ if (typeof message.content === "string") return message.content.slice(0, MAX_PROMPT_CHARS + 1);
10
+ if (!Array.isArray(message.content)) return "";
11
+ let text = "";
12
+ for (const part of message.content as Array<{ type?: string; text?: string }>) {
13
+ if (part?.type !== "text" || typeof part.text !== "string") continue;
14
+ const separator = text ? "\n" : "";
15
+ const room = MAX_PROMPT_CHARS + 1 - text.length - separator.length;
16
+ if (room <= 0) break;
17
+ text += separator + part.text.slice(0, room);
18
+ if (part.text.length > room) break;
19
+ }
20
+ return text;
21
+ };
9
22
 
10
23
  /**
11
24
  * Prompts newest first and deduplicated: every user message in this session file (all branches),
@@ -17,13 +30,17 @@ export function collectPrompts(entries: readonly unknown[], earlier: readonly {
17
30
  const add = (text: string, source: PastPrompt["source"], at?: number) => {
18
31
  const value = text.trim();
19
32
  const key = value.replace(/\s+/g, " ");
20
- if (!value || value.length > 8000 || seen.has(key)) return;
33
+ if (!value || value.length > MAX_PROMPT_CHARS || seen.has(key)) return;
21
34
  seen.add(key);
22
35
  result.push({ text: value, source, ...(at ? { at } : {}) });
23
36
  };
24
- const own = entries.filter((raw): raw is { type: string; message: { role: string; content?: unknown }; timestamp?: string } =>
25
- !!raw && typeof raw === "object" && (raw as { type?: string }).type === "message" && (raw as { message?: { role?: string } }).message?.role === "user");
26
- for (const entry of [...own].reverse()) add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
37
+ for (let index = entries.length - 1; index >= 0; index--) {
38
+ const raw = entries[index];
39
+ if (!raw || typeof raw !== "object") continue;
40
+ const entry = raw as { type?: string; message?: { role?: string; content?: unknown }; timestamp?: string };
41
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
42
+ add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
43
+ }
27
44
  for (const session of earlier) if (session.firstMessage) add(session.firstMessage, "earlier session", session.modified?.getTime());
28
45
  return result;
29
46
  }
@@ -36,9 +53,9 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
36
53
  let query = "";
37
54
  let selected = 0;
38
55
  let first = 0;
39
- let visible: PastPrompt[] = [...prompts];
56
+ let visible: PastPrompt[] = prompts.slice();
40
57
  const rows = 10;
41
- const filter = () => { visible = query ? fuzzyFilter([...prompts], query, (item) => item.text) : [...prompts]; selected = 0; first = 0; };
58
+ const filter = () => { visible = query ? fuzzyFilter(prompts, query, (item) => item.text) : prompts.slice(); selected = 0; first = 0; };
42
59
  return {
43
60
  invalidate() {},
44
61
  handleInput(data: string) {
package/src/quota.ts CHANGED
@@ -20,17 +20,27 @@ function parseQuota(value: unknown): Quota | undefined {
20
20
  return fiveHour || week ? { ...(fiveHour ? { fiveHour } : {}), ...(week ? { week } : {}) } : undefined;
21
21
  }
22
22
 
23
+ const publishedCache = new Map<string, { raw: string; expiresAt?: number; value?: Quota }>();
24
+
23
25
  /** Public statuses are authoritative only when valid and fresh. No arbitrary status prose is parsed. */
24
26
  export function publishedQuota(statuses: ReadonlyMap<string, string>, provider: string, now: number): Quota | undefined {
25
27
  const raw = statuses.get(QUOTA_PREFIX + provider);
26
28
  if (typeof raw !== "string" || raw.length > 2048) return undefined;
29
+ const cached = publishedCache.get(provider);
30
+ if (cached?.raw === raw) {
31
+ const { expiresAt, value } = cached;
32
+ return value && expiresAt != null && expiresAt > now && expiresAt <= now + QUOTA_TTL_MS ? value : undefined;
33
+ }
27
34
  try {
28
35
  const data: unknown = JSON.parse(raw);
29
- if (!data || typeof data !== "object") return undefined;
36
+ if (!data || typeof data !== "object") { publishedCache.set(provider, { raw }); return undefined; }
30
37
  const obj = data as Record<string, unknown>;
31
- if (typeof obj.expiresAt !== "number" || obj.expiresAt <= now || obj.expiresAt > now + QUOTA_TTL_MS) return undefined;
32
- return parseQuota(obj);
33
- } catch { return undefined; }
38
+ const expiresAt = obj.expiresAt;
39
+ const value = parseQuota(obj);
40
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || !value) { publishedCache.set(provider, { raw }); return undefined; }
41
+ publishedCache.set(provider, { raw, expiresAt, value });
42
+ return expiresAt > now && expiresAt <= now + QUOTA_TTL_MS ? value : undefined;
43
+ } catch { publishedCache.set(provider, { raw }); return undefined; }
34
44
  }
35
45
 
36
46
  function codexAccountId(token: string): string | undefined {
@@ -0,0 +1,24 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type SessionManagerLike = ExtensionContext["sessionManager"] & { getLeafId?: () => string | null | undefined };
4
+ const cache = new WeakMap<object, { leaf: string; branch: readonly unknown[] }>();
5
+
6
+ /**
7
+ * Pi's getBranch() walks leaf → root and reverses a new array every call. Reuse that projection
8
+ * across pi-jar features while the leaf id is unchanged; a new session entry naturally changes
9
+ * the leaf and invalidates the cache.
10
+ */
11
+ export function sessionBranch(ctx: Pick<ExtensionContext, "sessionManager">): readonly unknown[] {
12
+ const manager = ctx.sessionManager as SessionManagerLike;
13
+ const leaf = manager.getLeafId?.();
14
+ if (!leaf) return manager.getBranch();
15
+ const hit = cache.get(manager as object);
16
+ if (hit?.leaf === leaf) return hit.branch;
17
+ const branch = manager.getBranch();
18
+ cache.set(manager as object, { leaf, branch });
19
+ return branch;
20
+ }
21
+
22
+ export function clearSessionBranchCache(ctx: Pick<ExtensionContext, "sessionManager">): void {
23
+ cache.delete(ctx.sessionManager as object);
24
+ }
@@ -5,8 +5,8 @@ import { GOAL_ENTRY, GoalStore } from "./goals.ts";
5
5
  import { cleanText } from "./status.ts";
6
6
 
7
7
  export const RECENT_SESSIONS = 3;
8
- /** Larger session files are listed without goal/plan details. */
9
- const MAX_SCAN_BYTES = 8 * 1024 * 1024;
8
+ /** Welcome metadata is decorative; never parse multi-megabyte old transcripts for it. */
9
+ export const MAX_SCAN_BYTES = 512 * 1024;
10
10
 
11
11
  export interface RecentSession { path: string; title: string; modified: Date; messages: number; goal?: string; plan?: string }
12
12
 
package/src/session-ui.ts CHANGED
@@ -14,18 +14,28 @@ export function filterSessions(sessions: readonly SessionInfo[], query: string):
14
14
 
15
15
  export async function pickSession(ctx: ExtensionContext, sessions: readonly SessionInfo[], initial = ""): Promise<string | undefined> {
16
16
  if (!ctx.hasUI || ctx.mode !== "tui") return undefined;
17
+ const indexed = sessions.map((session) => ({
18
+ session,
19
+ haystack: `${session.name ?? ""} ${sessionDisplayName(session.name, session.id)} ${session.firstMessage}`.toLowerCase()
20
+ }));
21
+ const search = (query: string): SessionInfo[] => {
22
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
23
+ if (!terms.length) return sessions.slice();
24
+ const rows: SessionInfo[] = [];
25
+ for (const item of indexed) if (terms.every((term) => item.haystack.includes(term))) rows.push(item.session);
26
+ return rows;
27
+ };
17
28
  return ctx.ui.custom<string | undefined>((tui, theme, _keys, done) => {
18
29
  let query = initial.slice(0, 100);
19
30
  let selected = 0;
20
31
  let first = 0;
21
32
  let width = 80;
33
+ let rows = search(query);
22
34
  const pageSize = () => Math.max(2, Math.min(10, Math.floor(((process.stdout.rows ?? 24) - 7) / 2)));
23
- const matches = () => filterSessions(sessions, query);
24
- const refresh = () => { selected = 0; first = 0; tui.requestRender(); };
35
+ const refresh = () => { rows = search(query); selected = 0; first = 0; tui.requestRender(); };
25
36
  return {
26
37
  invalidate() {},
27
38
  handleInput(data: string) {
28
- const rows = matches();
29
39
  if (matchesKey(data, Key.escape)) return done(undefined);
30
40
  if (matchesKey(data, Key.enter)) return done(rows[selected]?.path);
31
41
  if (matchesKey(data, Key.up)) selected = Math.max(0, selected - 1);
@@ -39,13 +49,13 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
39
49
  },
40
50
  handleMouse(event: TuiMouseEvent) {
41
51
  if (event.type === "wheel" && event.wheelDelta) {
42
- selected = Math.max(0, Math.min(matches().length - 1, selected + Math.sign(event.wheelDelta)));
52
+ selected = Math.max(0, Math.min(rows.length - 1, selected + Math.sign(event.wheelDelta)));
43
53
  first = Math.max(0, Math.min(selected, first), selected - pageSize() + 1);
44
54
  tui.requestRender(); return { handled: true };
45
55
  }
46
56
  if (event.type !== "click" || event.button !== "left" || event.x >= width) return;
47
57
  const index = first + Math.floor((event.y - 2) / 2);
48
- const row = matches()[index];
58
+ const row = rows[index];
49
59
  if (event.y >= 2 && event.y < 2 + 2 * pageSize() && row) {
50
60
  selected = index; done(row.path); return { handled: true };
51
61
  }
@@ -53,7 +63,6 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
53
63
  render(available: number): string[] {
54
64
  width = Math.max(0, available);
55
65
  const fit = (line: string) => truncateToWidth(line, width);
56
- const rows = matches();
57
66
  selected = Math.max(0, Math.min(selected, rows.length - 1));
58
67
  const lines = [
59
68
  fit(theme.fg("accent", `╭─ SESSIONS · ${rows.length}/${sessions.length} ─`)),