pi-jar 0.1.0 → 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/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} ─`)),
package/src/shells.ts CHANGED
@@ -11,7 +11,10 @@ export const SHELL_MESSAGE = "pi-jar.shell";
11
11
  export const MAX_RUNNING_SHELLS = 8;
12
12
  const MAX_KEPT_SHELLS = 20;
13
13
  const MAX_LINES = 2000;
14
+ const LINE_PRUNE_BATCH = 128;
14
15
  const MAX_LINE_CHARS = 2000;
16
+ const MAX_RETAINED_CHARS = 1024 * 1024;
17
+ const MAX_TOOL_OUTPUT_CHARS = 32 * 1024;
15
18
  const KILL_GRACE_MS = 3000;
16
19
 
17
20
  export type ShellStatus = "running" | "exited" | "killed" | "failed";
@@ -44,14 +47,25 @@ const ANSI = /\x1b\][^\x07]*(?:\x07|\x1b\\)?|\x1b\[[0-?]*[ -/]*[@-~]|\x1b./g;
44
47
  export class ShellManager {
45
48
  private jobs = new Map<string, { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> }>();
46
49
  private next = 1;
50
+ private lineChars = new Map<string, number>();
47
51
  private readonly onEvent: (event: ShellEvent) => void;
48
52
  private readonly spawnShell: typeof spawn;
49
53
  onChange?: () => void;
50
54
  constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
51
55
 
52
56
  list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
57
+ summaries(): Array<Omit<ShellJob, "lines">> {
58
+ return [...this.jobs.values()].map(({ job }) => {
59
+ const { lines: _lines, ...summary } = job;
60
+ return { ...summary };
61
+ });
62
+ }
53
63
  get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }
54
- running(): number { return [...this.jobs.values()].filter(({ job }) => job.status === "running").length; }
64
+ running(): number {
65
+ let count = 0;
66
+ for (const { job } of this.jobs.values()) if (job.status === "running") count++;
67
+ return count;
68
+ }
55
69
 
56
70
  start(options: StartOptions): ShellJob {
57
71
  const command = options.command.trim();
@@ -68,6 +82,7 @@ export class ShellManager {
68
82
  notify: options.notify ?? true, lines: [], dropped: 0, ...(options.watch ? { watch: options.watch } : {}) };
69
83
  const entry: { job: ShellJob; child?: ChildProcess; pattern?: RegExp; partial: string; timer?: ReturnType<typeof setTimeout> } = { job, partial: "", ...(pattern ? { pattern } : {}) };
70
84
  this.jobs.set(id, entry);
85
+ this.lineChars.set(id, 0);
71
86
  // Own process group so kill() stops the whole tree (dev servers spawn children).
72
87
  const child = this.spawnShell("/bin/sh", ["-c", command], { cwd: options.cwd, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], env: process.env });
73
88
  entry.child = child;
@@ -105,16 +120,26 @@ export class ShellManager {
105
120
  const line = raw.replace(ANSI, "").replace(/\r/g, "").slice(0, MAX_LINE_CHARS);
106
121
  const { job } = entry;
107
122
  job.lines.push(line);
108
- if (job.lines.length > MAX_LINES) { job.lines.splice(0, job.lines.length - MAX_LINES); job.dropped++; }
123
+ this.lineChars.set(job.id, (this.lineChars.get(job.id) ?? 0) + line.length);
124
+ // Bound both line count and retained characters. A shell that prints very wide lines should
125
+ // not quietly reserve several MiB forever just because it has not reached MAX_LINES yet.
126
+ while (job.lines.length > MAX_LINES + LINE_PRUNE_BATCH || (this.lineChars.get(job.id) ?? 0) > MAX_RETAINED_CHARS) {
127
+ const removed = job.lines.splice(0, Math.min(LINE_PRUNE_BATCH, job.lines.length));
128
+ let removedChars = 0;
129
+ for (const item of removed) removedChars += item.length;
130
+ this.lineChars.set(job.id, Math.max(0, (this.lineChars.get(job.id) ?? 0) - removedChars));
131
+ job.dropped += removed.length;
132
+ if (!removed.length) break;
133
+ }
109
134
  if (entry.pattern && !job.matched && entry.pattern.test(line)) {
110
135
  job.matched = line;
111
- this.onEvent({ kind: "match", job: { ...job, lines: [...job.lines] } });
136
+ this.onEvent({ kind: "match", job: { ...job, lines: job.lines.slice(-20) } });
112
137
  }
113
138
  }
114
139
 
115
140
  private finish(entry: { job: ShellJob; timer?: ReturnType<typeof setTimeout> }): void {
116
141
  if (entry.timer) clearTimeout(entry.timer);
117
- this.onEvent({ kind: "exit", job: { ...entry.job, lines: [...entry.job.lines] } });
142
+ this.onEvent({ kind: "exit", job: { ...entry.job, lines: entry.job.lines.slice(-20) } });
118
143
  this.onChange?.();
119
144
  }
120
145
 
@@ -122,8 +147,10 @@ export class ShellManager {
122
147
  output(id: string, count = 40): string[] {
123
148
  const entry = this.jobs.get(id);
124
149
  if (!entry) throw new Error("no shell " + id);
125
- const lines = [...entry.job.lines, ...(entry.partial ? [entry.partial] : [])];
126
- return lines.slice(-Math.max(1, Math.min(400, Math.floor(count))));
150
+ const limit = Math.max(1, Math.min(400, Math.floor(count)));
151
+ if (!entry.partial) return entry.job.lines.slice(-limit);
152
+ if (limit === 1) return [entry.partial];
153
+ return [...entry.job.lines.slice(-(limit - 1)), entry.partial];
127
154
  }
128
155
 
129
156
  kill(id: string): boolean {
@@ -147,22 +174,41 @@ export class ShellManager {
147
174
  /** Forget finished jobs beyond the retention limit (oldest first). */
148
175
  private prune(): void {
149
176
  const finished = [...this.jobs.values()].filter(({ job }) => job.status !== "running");
150
- for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) this.jobs.delete(job.id);
177
+ for (const { job } of finished.slice(0, Math.max(0, this.jobs.size - MAX_KEPT_SHELLS + 1))) {
178
+ this.jobs.delete(job.id);
179
+ this.lineChars.delete(job.id);
180
+ }
151
181
  }
152
182
 
153
183
  dispose(): void {
154
184
  for (const id of this.jobs.keys()) { try { this.kill(id); } catch { /* already gone */ } }
155
185
  this.jobs.clear();
186
+ this.lineChars.clear();
156
187
  }
157
188
  }
158
189
 
159
- const elapsed = (job: ShellJob, now = Date.now()) => {
190
+ type ShellMeta = Omit<ShellJob, "lines">;
191
+ const elapsed = (job: ShellMeta | ShellJob, now = Date.now()) => {
160
192
  const seconds = Math.max(0, Math.round(((job.endedAt ?? now) - job.startedAt) / 1000));
161
193
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
162
194
  };
163
- export const shellState = (job: ShellJob) => job.status === "running" ? "running"
195
+ export const shellState = (job: ShellMeta | ShellJob) => job.status === "running" ? "running"
164
196
  : job.status === "exited" ? `exited ${job.exitCode ?? "?"}` : job.status === "failed" ? `failed: ${cleanText(job.error ?? "", 60)}` : "killed";
165
- const describe = (job: ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
197
+ const describe = (job: ShellMeta | ShellJob) => `${job.id} · ${job.name} · ${shellState(job)} · ${elapsed(job)}${job.watch ? ` · watch /${job.watch}/${job.matched ? " matched" : ""}` : ""}`;
198
+ const boundedTail = (lines: readonly string[], limit = MAX_TOOL_OUTPUT_CHARS): string => {
199
+ let size = 0;
200
+ const kept: string[] = [];
201
+ for (let index = lines.length - 1; index >= 0; index--) {
202
+ const line = lines[index]!;
203
+ const cost = line.length + (kept.length ? 1 : 0);
204
+ if (size + cost > limit) break;
205
+ kept.push(line);
206
+ size += cost;
207
+ }
208
+ kept.reverse();
209
+ const omitted = lines.length - kept.length;
210
+ return (omitted ? `… ${omitted} earlier line(s) omitted\n` : "") + kept.join("\n");
211
+ };
166
212
 
167
213
  /** The message that wakes the agent when a watched shell matches or ends. */
168
214
  export function shellEventMessage(event: ShellEvent): string {
@@ -198,7 +244,9 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
198
244
  parameters: Parameters,
199
245
  async execute(_id, params, _signal, _update, ctx) {
200
246
  const manager = shells();
201
- const reply = (text: string, jobs: ShellJob[] = manager?.list() ?? []) => ({ content: [{ type: "text" as const, text }], details: { jobs } });
247
+ const reply = (text: string) => ({ content: [{ type: "text" as const, text: text.slice(0, MAX_TOOL_OUTPUT_CHARS) }], details: {
248
+ jobs: (manager?.summaries() ?? []).map((job) => ({ id: job.id, name: job.name, status: job.status, pid: job.pid, exitCode: job.exitCode }))
249
+ } });
202
250
  if (!manager) return reply("Background shells are unavailable before a Pi session starts.");
203
251
  try {
204
252
  switch (params.action) {
@@ -210,14 +258,14 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
210
258
  case "output": {
211
259
  if (!params.id) return reply("output needs an id.");
212
260
  const lines = manager.output(params.id, params.lines ?? 40);
213
- return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? lines.join("\n") : "(no output yet)"}`);
261
+ return reply(`${describe(manager.get(params.id)!)}\n${lines.length ? boundedTail(lines) : "(no output yet)"}`);
214
262
  }
215
263
  case "kill":
216
264
  if (!params.id) return reply("kill needs an id.");
217
265
  return reply(manager.kill(params.id) ? `Stopping ${params.id}.` : `${params.id} is not running.`);
218
266
  default: {
219
- const jobs = manager.list();
220
- return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.", jobs);
267
+ const jobs = manager.summaries();
268
+ return reply(jobs.length ? jobs.map(describe).join("\n") : "No background shells.");
221
269
  }
222
270
  }
223
271
  } catch (error) {
@@ -232,11 +280,18 @@ export function registerShells(pi: ExtensionAPI, shells: () => ShellManager | un
232
280
  return new Text(text, 0, 0);
233
281
  },
234
282
  renderResult(result, { expanded }, theme) {
235
- const text = result.content.map((part) => part.type === "text" ? part.text : "").join("\n");
236
- const lines = text.split("\n");
237
- const shown = expanded ? lines : lines.slice(0, 6);
283
+ const text = result.content.find((part) => part.type === "text")?.text ?? "";
284
+ if (expanded) {
285
+ const lines = text.split("\n");
286
+ return new Text(lines.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n"), 0, 0);
287
+ }
288
+ // Historical shell cards are rebuilt on resume. Split only enough text to draw the collapsed
289
+ // card instead of counting every stored output line.
290
+ const sample = text.split("\n", 7);
291
+ const more = sample.length > 6;
292
+ const shown = sample.slice(0, 6);
238
293
  return new Text(shown.map((line, index) => theme.fg(index ? "muted" : "accent", safeLine(line))).join("\n")
239
- + (shown.length < lines.length ? "\n" + theme.fg("dim", `… +${lines.length - shown.length} lines (expand)`) : ""), 0, 0);
294
+ + (more ? "\n" + theme.fg("dim", "… more lines (expand)") : ""), 0, 0);
240
295
  }
241
296
  });
242
297
  }
@@ -249,7 +304,7 @@ const SHELL_ACTIONS = [
249
304
  /** Overlay: shells on the left, the selected shell's live output on the right; k kills. */
250
305
  export async function openShellsView(ctx: ExtensionContext, manager: ShellManager): Promise<void> {
251
306
  if (!ctx.hasUI || ctx.mode !== "tui") return;
252
- if (!manager.list().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
307
+ if (!manager.summaries().length) { ctx.ui.notify("pi-jar: no background shells (the agent starts them with jar_shell)", "info"); return; }
253
308
  await ctx.ui.custom<void>((tui, theme, _keys, done) => {
254
309
  let selected = 0;
255
310
  let follow = true;
@@ -259,14 +314,14 @@ export async function openShellsView(ctx: ExtensionContext, manager: ShellManage
259
314
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
260
315
  const fg = (color: string, text: string) => theme.fg(color as never, text);
261
316
  const previous = manager.onChange;
262
- const timer = setInterval(() => tui.requestRender(), 500);
317
+ const timer = setInterval(() => tui.requestRender(), 1000);
263
318
  timer.unref?.();
264
319
  manager.onChange = () => { previous?.(); tui.requestRender(); };
265
320
  const close = () => { clearInterval(timer); manager.onChange = previous; done(); };
266
321
  const component = {
267
322
  invalidate() {},
268
323
  handleInput(data: string) {
269
- const jobs = manager.list();
324
+ const jobs = manager.summaries();
270
325
  if (matchesKey(data, Key.escape) || data === "q") return close();
271
326
  if (matchesKey(data, Key.up) || data === "k") selected = Math.max(0, selected - 1);
272
327
  else if (matchesKey(data, Key.down) || data === "j") selected = Math.min(jobs.length - 1, selected + 1);
@@ -281,14 +336,14 @@ export async function openShellsView(ctx: ExtensionContext, manager: ShellManage
281
336
  if (event.type === "wheel" && event.wheelDelta) { follow = false; scroll = Math.max(0, scroll + Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
282
337
  if (event.type !== "click" || event.button !== "left") return;
283
338
  if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
284
- if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.list().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
339
+ if (row >= 0 && row < layout.rows && layout.leftWidth && event.x < layout.leftWidth + 3 && row < manager.summaries().length) { selected = row; follow = true; tui.requestRender(); return { handled: true, focus: true }; }
285
340
  const action = SHELL_ACTIONS[event.y - layout.footerTop];
286
341
  if (action) { component.handleInput(action.key); return { handled: true }; }
287
342
  },
288
343
  render(available: number): string[] {
289
344
  width = Math.max(24, available);
290
345
  rows = contentRows(6 + SHELL_ACTIONS.length, 6);
291
- const jobs = manager.list();
346
+ const jobs = manager.summaries();
292
347
  selected = Math.min(selected, Math.max(0, jobs.length - 1));
293
348
  const job = jobs[selected];
294
349
  const listWidth = sidebarWidth(width, 20, 34);
package/src/side-model.ts CHANGED
@@ -16,7 +16,10 @@ export interface SideCall {
16
16
  /** Session-scoped record of side calls, so /usage can show what they cost. */
17
17
  export class SideUsage {
18
18
  private calls: SideCall[] = [];
19
- add(call: SideCall): void { this.calls.push(call); if (this.calls.length > 500) this.calls.shift(); }
19
+ add(call: SideCall): void {
20
+ this.calls.push(call);
21
+ if (this.calls.length > 512) this.calls.splice(0, 64);
22
+ }
20
23
  all(): readonly SideCall[] { return this.calls; }
21
24
  clear(): void { this.calls = []; }
22
25
  }
@@ -42,7 +45,16 @@ export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, us
42
45
  usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
43
46
  cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
44
47
  if (result.stopReason === "error" || result.stopReason === "aborted") throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
45
- const text = result.content.filter((part) => part.type === "text").map((part) => (part as { text: string }).text).join("").trim();
48
+ const MAX_ANSWER_CHARS = 32 * 1024;
49
+ let text = "";
50
+ for (const part of result.content) {
51
+ if (part.type !== "text") continue;
52
+ const value = (part as { text: string }).text;
53
+ const room = MAX_ANSWER_CHARS - text.length;
54
+ if (room <= 0) break;
55
+ text += value.slice(0, room);
56
+ }
57
+ text = text.trim();
46
58
  if (!text) throw new Error(`${name} returned an empty answer`);
47
59
  return { text, model: name };
48
60
  }
package/src/status.ts CHANGED
@@ -24,6 +24,36 @@ export interface JarStatus {
24
24
  extras: string[];
25
25
  }
26
26
 
27
+ const ROLE_CACHE_LIMIT = 64;
28
+ const roleCache = new Map<string, JarRole | null>();
29
+
30
+ function cachedRole(id: string, rawValue: string): JarRole | undefined {
31
+ const cacheKey = id + "\0" + rawValue;
32
+ if (roleCache.has(cacheKey)) return roleCache.get(cacheKey) ?? undefined;
33
+ let parsed: JarRole | undefined;
34
+ try {
35
+ if (rawValue.length > 4096) throw new Error("oversized status");
36
+ const data: unknown = JSON.parse(rawValue);
37
+ if (!data || typeof data !== "object") throw new Error("invalid status");
38
+ const role = data as Record<string, unknown>;
39
+ if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) throw new Error("invalid role");
40
+ const expiresAt = role.expiresAt;
41
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt)) throw new Error("invalid expiry");
42
+ const name = cleanText(role.name, 32) || id;
43
+ parsed = {
44
+ id,
45
+ name,
46
+ label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
47
+ state: role.state as RoleState,
48
+ task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
49
+ expiresAt
50
+ };
51
+ } catch { parsed = undefined; }
52
+ roleCache.set(cacheKey, parsed ?? null);
53
+ if (roleCache.size > ROLE_CACHE_LIMIT) roleCache.delete(roleCache.keys().next().value!);
54
+ return parsed;
55
+ }
56
+
27
57
  /** Explicit opt-in contract; never guess teammate state from arbitrary status prose. */
28
58
  export function collectStatuses(statuses: ReadonlyMap<string, string>, now: number): JarStatus {
29
59
  const roles: JarRole[] = [];
@@ -40,32 +70,12 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
40
70
  }
41
71
  const id = rawKey.slice(ROLE_PREFIX.length);
42
72
  if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
43
- try {
44
- if (rawValue.length > 4096) throw new Error("oversized status");
45
- const data: unknown = JSON.parse(rawValue);
46
- if (!data || typeof data !== "object") throw new Error("invalid status");
47
- const role = data as Record<string, unknown>;
48
- if (typeof role.name !== "string" || typeof role.state !== "string" || !STATES.has(role.state as RoleState)) {
49
- throw new Error("invalid role");
50
- }
51
- const state = role.state as RoleState;
52
- const expiresAt = role.expiresAt;
53
- if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= now || expiresAt > now + ROLE_TTL_MS) {
54
- extras.push(`${id}: unavailable`);
55
- continue;
56
- }
57
- const name = cleanText(role.name, 32) || id;
58
- roles.push({
59
- id,
60
- name,
61
- label: cleanText(typeof role.label === "string" ? role.label : name, 12) || id,
62
- state,
63
- task: typeof role.task === "string" ? cleanText(role.task, 48) : undefined,
64
- expiresAt: typeof expiresAt === "number" ? expiresAt : undefined
65
- });
66
- } catch {
73
+ const role = cachedRole(id, rawValue);
74
+ if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
67
75
  extras.push(`${id}: unavailable`);
76
+ continue;
68
77
  }
78
+ roles.push(role);
69
79
  }
70
80
  return { roles: roles.slice(0, 16), extras: extras.slice(0, 16) };
71
81
  }
package/src/suggest.ts CHANGED
@@ -4,7 +4,6 @@ import { Type } from "typebox";
4
4
  import { cleanText } from "./status.ts";
5
5
 
6
6
  export const SUGGEST_TOOL = "jar_suggest";
7
- const REMINDER_TYPE = "pi-jar.suggest-reminder";
8
7
  const MAX_SUGGESTION = 160;
9
8
 
10
9
  /** The next-prompt suggestion shown as ghost text in an empty composer. Session-only, never persisted. */
@@ -25,27 +24,22 @@ export class SuggestionState {
25
24
 
26
25
  export interface SuggestionOptions {
27
26
  enabled: () => boolean;
28
- /** Skip enforcement while another workflow owns the next step (plan review, goal loop). */
29
- skip: () => boolean;
30
27
  }
31
28
 
32
29
  /** Ask the agent for one next-prompt suggestion per finished request, like an inline autocomplete. */
33
30
  export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, options: SuggestionOptions): { sync(): void } {
34
- let suggested = false;
35
- let reminded = false;
36
31
  pi.registerTool?.({
37
32
  name: SUGGEST_TOOL,
38
33
  label: "suggest",
39
- description: "Offer the user one short next prompt. It appears as dimmed ghost text in their input box; Tab accepts it for editing. Call it once, last, when you finish a request.",
40
- promptSnippet: "Finish each completed request by calling jar_suggest with the most useful next prompt for the user.",
34
+ description: "Optionally offer one useful next prompt as ghost text in the input box; Tab accepts it for editing.",
35
+ promptSnippet: "Optionally use jar_suggest at the end when one genuinely useful next prompt would help the user continue.",
41
36
  promptGuidelines: [
42
- "When you finish the user's request, call jar_suggest exactly once as your final action with the single most useful next prompt the user could send (imperative, one line, under 120 characters, written as the user would type it, e.g. \"Run the full test suite\").",
43
- "Do not mention jar_suggest or the suggestion in your reply, and do not call it mid-task or while waiting for an answer."
37
+ "jar_suggest is best-effort, not mandatory. Use it only when there is one clear next action worth surfacing; skip it for routine completions.",
38
+ "When used, call it once as the final action with one imperative line under 120 characters. Do not mention jar_suggest in your reply."
44
39
  ],
45
40
  parameters: Type.Object({ suggestion: Type.String({ description: "The user's likely next prompt, one line." }) }),
46
41
  execute: async (_id, params) => {
47
42
  const ok = state.set(params.suggestion ?? "");
48
- suggested ||= ok;
49
43
  return { content: [{ type: "text", text: ok ? "Suggestion shown to the user. End your turn now." : "Suggestion was empty; skipped." }], details: { suggestion: state.text }, terminate: true };
50
44
  },
51
45
  renderCall() { return new Text("", 0, 0); },
@@ -66,24 +60,11 @@ export function registerSuggestions(pi: ExtensionAPI, state: SuggestionState, op
66
60
 
67
61
  pi.on("input", (event) => {
68
62
  if (event.source !== "interactive") return;
69
- suggested = false;
70
- reminded = false;
71
63
  state.clear();
72
64
  });
73
65
  pi.on("agent_start", () => { state.clear(); });
74
- pi.on("session_start", () => { suggested = false; reminded = false; state.clear(); });
66
+ pi.on("session_start", () => { state.clear(); });
75
67
  pi.on("session_tree", () => { state.clear(); });
76
- pi.on("context", async (event) => ({ messages: event.messages.filter((raw) => (raw as { customType?: string }).customType !== REMINDER_TYPE) }));
77
-
78
- pi.on("agent_before_settle", (event) => {
79
- if (!options.enabled() || options.skip() || event.continue || event.outcome !== "completed" || suggested || reminded) return;
80
- if (typeof pi.getActiveTools === "function" && !pi.getActiveTools().includes(SUGGEST_TOOL)) return;
81
- reminded = true;
82
- return {
83
- entries: [{ type: "custom_message", customType: REMINDER_TYPE, display: false,
84
- content: "[PI-JAR] Call jar_suggest now with the single most useful next prompt for the user. Do not add any other text." }],
85
- continue: true
86
- };
87
- });
68
+ // No settle hook: suggestions must not add a provider turn or a hidden session entry.
88
69
  return { sync };
89
70
  }
package/src/task-tool.ts CHANGED
@@ -22,14 +22,27 @@ const Parameters = Type.Object({
22
22
 
23
23
  interface TaskToolDetails {
24
24
  action: string;
25
+ /** Bounded preview only; the canonical list lives in TodoStore. */
25
26
  items: Todo[];
27
+ total: number;
28
+ done: number;
29
+ current?: string;
26
30
  changed?: string;
31
+ truncated?: boolean;
27
32
  }
28
33
 
34
+ const stats = (items: readonly Todo[]) => {
35
+ let done = 0;
36
+ let current: Todo | undefined;
37
+ for (const item of items) {
38
+ if (item.done) done++;
39
+ if (!current && item.status === "in_progress") current = item;
40
+ }
41
+ return { total: items.length, done, current };
42
+ };
29
43
  const summary = (items: readonly Todo[]) => {
30
- const done = items.filter((item) => item.done).length;
31
- const current = items.find((item) => item.status === "in_progress");
32
- return `${done}/${items.length} done` + (current ? ` · now: ${current.title}` : "");
44
+ const value = stats(items);
45
+ return `${value.done}/${value.total} done` + (value.current ? ` · now: ${value.current.title}` : "");
33
46
  };
34
47
 
35
48
  const lines = (items: readonly Todo[]) => items.length
@@ -60,7 +73,7 @@ export function registerTaskTool(
60
73
  const current = store();
61
74
  const action = params.todos ? "write" : params.action ?? "list";
62
75
  if (!current) {
63
- return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [] } satisfies TaskToolDetails };
76
+ return { content: [{ type: "text", text: "Task tracking is unavailable before a Pi session starts." }], details: { action, items: [], total: 0, done: 0 } satisfies TaskToolDetails };
64
77
  }
65
78
  let changedId: string | undefined;
66
79
  let error: string | undefined;
@@ -94,12 +107,22 @@ export function registerTaskTool(
94
107
  }
95
108
  if (action !== "list" && !error) changed(ctx);
96
109
  const items = current.all();
110
+ const state = stats(items);
111
+ const changedItem = changedId ? items.find((item) => item.id === changedId) : undefined;
97
112
  const message = error
98
- ? `Could not ${action} tasks: ${error}\n${lines(items)}`
99
- : `${action === "list" ? "Tasks" : "Task list updated"} (${summary(items)}):\n${lines(items)}${action === "list" ? "" : "\n" + REMINDER}`;
113
+ ? `Could not ${action} tasks: ${error} (${summary(items)}).`
114
+ : action === "list"
115
+ ? `Tasks (${summary(items)}):\n${lines(items)}`
116
+ : `Task list updated (${summary(items)}).${changedItem ? `\n${todoMark(changedItem)} [${changedItem.status}] ${changedItem.title} (${changedItem.id})` : ""}\n${REMINDER}`;
117
+ const preview = items.slice(0, 8);
100
118
  return {
101
119
  content: [{ type: "text", text: message }],
102
- details: { action, items, ...(changedId ? { changed: changedId } : {}) } satisfies TaskToolDetails
120
+ details: {
121
+ action, items: preview, total: state.total, done: state.done,
122
+ ...(state.current ? { current: state.current.title } : {}),
123
+ ...(preview.length < items.length ? { truncated: true } : {}),
124
+ ...(changedId ? { changed: changedId } : {})
125
+ } satisfies TaskToolDetails
103
126
  };
104
127
  },
105
128
  renderCall(args, theme) {
@@ -115,12 +138,13 @@ export function registerTaskTool(
115
138
  if (isPartial) return new Text(theme.fg("warning", "Updating tasks…"), 0, 0);
116
139
  const details = result.details as TaskToolDetails | undefined;
117
140
  const items = details?.items ?? [];
118
- if (!items.length) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
119
- // Like Claude: the checklist itself is the result. Collapsed shows up to 8 rows.
141
+ const total = details?.total ?? items.length;
142
+ const done = details?.done ?? items.filter((item) => item.done).length;
143
+ if (!total) return new Text(theme.fg("dim", "No tracked tasks."), 0, 0);
120
144
  const shown = expanded ? items : items.slice(0, 8);
121
- let text = theme.fg("dim", summary(items));
145
+ let text = theme.fg("dim", `${done}/${total} done${details?.current ? " · now: " + details.current : ""}`);
122
146
  for (const item of shown) text += "\n" + todoRow(item, (color, value) => theme.fg(color, value), (value) => theme.bold(value));
123
- if (shown.length < items.length) text += "\n" + theme.fg("dim", ` … +${items.length - shown.length} more (expand)`);
147
+ if (details?.truncated || shown.length < total) text += "\n" + theme.fg("dim", ` … +${Math.max(0, total - shown.length)} more · /jar tasks`);
124
148
  return new Text(text, 0, 0);
125
149
  }
126
150
  });