pi-summarize 0.1.0 → 0.2.0

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
@@ -4,10 +4,10 @@ A [pi](https://github.com/earendil-works/pi) extension package with two features
4
4
 
5
5
  1. **Live activity line.** While the agent works, the working indicator shows one short, specific present-progressive line (≤ 12 words) describing the goal rather than the command, e.g. `Checking why the sync test fails`. Tool calls summarize immediately; text and thinking summarize at most every ~2 s. Subagents run their own copy of the extension and narrate themselves; the parent does not relay their progress. While a tool runs, the line shows elapsed time every 5 s (display only, not recorded), so long waits visibly move. Calls are cancelled when superseded or when the run ends, and failures or timeouts (10 s) fall back to a deterministic line such as `Reading files.js…` or `Working…`.
6
6
 
7
- In the transcript, tool rows are hidden and each recorded line shows as `◆ <summary>`. Ctrl+O (expand) brings back the tool rows and shows each summary's thinking tail and tool calls. With `activity` off, tools render normally.
7
+ In the transcript, tool rows are hidden. Each step (one model turn that runs tools) leaves one breadcrumb, `◆ <summary>`, where its tool rows were, recorded at `turn_end` from that step's live line (waiting up to 2.5 s for it). A tool-only step with the same goal as the previous breadcrumb merges into it; a step with its own lead-in text always gets its own breadcrumb. Ctrl+O (expand) brings back the tool rows and shows each summary's thinking tail and tool calls. With `activity` off, tools render normally.
8
8
  2. **Auto-title.** After the first assistant reply in a session with no name, it sets a 2–4 word session name. It never overwrites a name you set, and it does not retry once it has named the session.
9
9
 
10
- Each shown line and each generated title is persisted with `pi.appendEntry("pi-summarize", …)`. These custom entries are not sent to the LLM. Hosts can read them from the session JSONL.
10
+ Each breadcrumb and each generated title is persisted with `pi.appendEntry("pi-summarize", …)`. These custom entries are not sent to the LLM. Hosts can read them from the session JSONL.
11
11
 
12
12
  ```jsonc
13
13
  // {"type":"custom","customType":"pi-summarize","data":{"kind":"activity","text":"Reading files.js…","at":1760000000000,"turn":2,"source":"fallback","toolCallIds":["t1"]}}
@@ -16,7 +16,7 @@ Each shown line and each generated title is persisted with `pi.appendEntry("pi-s
16
16
 
17
17
  ## Host integration
18
18
 
19
- Each shown activity line is also emitted on the extension bus as `pi.events` channel `pi-summarize:activity`, with payload `{ sessionId, text, at, turn, source, toolCallIds }`. `sessionId` is the session that produced the line, so a host running several sessions can route by it.
19
+ Each breadcrumb is also emitted on the extension bus as `pi.events` channel `pi-summarize:activity`, with payload `{ sessionId, text, at, turn, source, toolCallIds }`. `sessionId` is the session that produced the line, so a host running several sessions can route by it.
20
20
 
21
21
  Subagent children get their own bus unless the host passes one to their resource loader, so emits from a child do not reach the parent by default. Persisted history needs no bus: a child's session JSONL (the subagent `outputFile`) already contains its `pi-summarize` activity entries.
22
22
 
@@ -78,6 +78,8 @@ export default function (pi: ExtensionAPI) {
78
78
  pi.events.emit(ACTIVITY_CHANNEL, { sessionId, ...entry });
79
79
  },
80
80
  });
81
+ // With tool rows hidden, hidden-thinking placeholders pile up between breadcrumbs; the breadcrumbs carry the thinking on Ctrl+O.
82
+ if (cfg.activity && ctx.hasUI) ctx.ui.setHiddenThinkingLabel("");
81
83
  if (cfg.model && !resolveModel(ctx, cfg.model) && ctx.hasUI) {
82
84
  ctx.ui.notify(`pi-summarize: model "${cfg.model}" not found; using fallback lines`, "warning");
83
85
  }
@@ -106,6 +108,12 @@ export default function (pi: ExtensionAPI) {
106
108
  if (cfg.activity) tracker?.toolStart(event.toolCallId, event.toolName, event.args);
107
109
  });
108
110
 
111
+ // Awaited so the breadcrumb lands where the step's hidden tool rows are, before the next step's text.
112
+ pi.on("turn_end", async (_event, ctx) => {
113
+ live = ctx;
114
+ if (cfg.activity) await tracker?.turnEnd();
115
+ });
116
+
109
117
  pi.on("tool_execution_end", async (_event, ctx) => {
110
118
  live = ctx;
111
119
  if (cfg.activity) tracker?.toolEnd();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-summarize",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension: live one-line activity summaries and auto-titled sessions, using a cheap side model.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/core.ts CHANGED
@@ -108,6 +108,8 @@ export interface TrackerOptions {
108
108
  record(entry: ActivityRecord): void;
109
109
  debounceMs?: number;
110
110
  timeoutMs?: number;
111
+ /** How long turnEnd waits for the step's live line before using the fallback. */
112
+ stepWaitMs?: number;
111
113
  /** While a tool runs with no new line, re-show the line with elapsed time this often. 0 disables. */
112
114
  heartbeatMs?: number;
113
115
  }
@@ -119,13 +121,17 @@ export class ActivityTracker {
119
121
  private lastTool?: ToolActivity;
120
122
  private text = "";
121
123
  private thinking = "";
122
- private toolCallIds: string[] = [];
124
+ /** The current step (one model turn): its lead-in text, thinking and tool calls. */
125
+ private step = { text: "", thinking: "", tools: [] as string[], ids: [] as string[] };
123
126
  private timer?: ReturnType<typeof setTimeout>;
124
127
  private inflight?: AbortController;
128
+ private pending?: Promise<void>;
125
129
  private seq = 0;
126
130
  private last?: string;
127
- /** Recently recorded breadcrumbs; survives across runs so a new run does not repeat the last goal. */
128
- private recent: string[] = [];
131
+ private lastTurn = -1;
132
+ private lastSource: ActivityRecord["source"] = "fallback";
133
+ /** Last recorded breadcrumb; survives across runs so a tool-only step can merge into it. */
134
+ private crumb?: string;
129
135
  private stale = false;
130
136
  private toolSince?: number;
131
137
  private beat?: ReturnType<typeof setInterval>;
@@ -143,20 +149,22 @@ export class ActivityTracker {
143
149
  this.lastTool = undefined;
144
150
  this.text = "";
145
151
  this.thinking = "";
146
- this.toolCallIds = [];
147
152
  }
148
153
 
149
154
  turnStart(turn: number): void {
150
155
  this.turn = turn;
156
+ this.step = { text: "", thinking: "", tools: [], ids: [] };
151
157
  }
152
158
 
153
159
  thinkingDelta(delta: string): void {
154
160
  this.thinking = tail(this.thinking + delta, 400);
161
+ this.step.thinking = tail(this.step.thinking + delta, 400);
155
162
  this.schedule();
156
163
  }
157
164
 
158
165
  textDelta(delta: string): void {
159
166
  this.text = tail(this.text + delta, 600);
167
+ this.step.text = tail(this.step.text + delta, 600);
160
168
  this.schedule();
161
169
  }
162
170
 
@@ -166,9 +174,11 @@ export class ActivityTracker {
166
174
  this.toolSince = Date.now();
167
175
  this.stale = true; // no heartbeat until this tool's line is decided
168
176
  this.startBeat();
169
- this.tools = [...this.tools, `${name}(${clip(JSON.stringify(args ?? {}), 160)})`].slice(-4);
170
- this.toolCallIds.push(toolCallId);
171
- void this.flush();
177
+ const call = `${name}(${clip(JSON.stringify(args ?? {}), 160)})`;
178
+ this.tools = [...this.tools, call].slice(-4);
179
+ this.step.tools.push(call);
180
+ this.step.ids.push(toolCallId);
181
+ this.kick();
172
182
  }
173
183
 
174
184
  toolEnd(): void {
@@ -176,6 +186,29 @@ export class ActivityTracker {
176
186
  this.schedule();
177
187
  }
178
188
 
189
+ /**
190
+ * A step that ran tools ends: record one breadcrumb where its hidden tool rows sit.
191
+ * Waits briefly for the step's live line; a tool-only step with the same goal merges into the previous breadcrumb.
192
+ */
193
+ async turnEnd(): Promise<void> {
194
+ const step = this.step;
195
+ const turn = this.turn;
196
+ if (!step.ids.length) return;
197
+ this.step = { text: "", thinking: "", tools: [], ids: [] };
198
+ if (this.pending) {
199
+ let timer: ReturnType<typeof setTimeout> | undefined;
200
+ await Promise.race([this.pending, new Promise((r) => (timer = setTimeout(r, this.opts.stepWaitMs ?? 2500)))]);
201
+ clearTimeout(timer);
202
+ }
203
+ const fresh = this.last !== undefined && this.lastTurn === turn;
204
+ const line = fresh ? (this.last as string) : fallbackLine(this.lastTool);
205
+ const source = fresh ? this.lastSource : "fallback";
206
+ const leadIn = step.text.trim() !== "";
207
+ if (this.crumb && (line === this.crumb || (!leadIn && (source === "fallback" || similarLine(this.crumb, line))))) return;
208
+ this.crumb = line;
209
+ this.opts.record({ text: line, at: Date.now(), turn, source, toolCallIds: step.ids, thinking: step.thinking.trim() || undefined, tools: step.tools });
210
+ }
211
+
179
212
  /** Keeps the line visibly alive during long tools: same text plus elapsed seconds, display only. */
180
213
  private startBeat(): void {
181
214
  const every = this.opts.heartbeatMs ?? 5000;
@@ -194,7 +227,6 @@ export class ActivityTracker {
194
227
  if (this.beat) clearInterval(this.beat);
195
228
  this.beat = undefined;
196
229
  this.toolSince = undefined;
197
- if (this.toolCallIds.length) void this.recordLate();
198
230
  this.inflight?.abort();
199
231
  this.inflight = undefined;
200
232
  this.seq++;
@@ -205,7 +237,7 @@ export class ActivityTracker {
205
237
  /** Max-wait debounce: the first event in a window arms one flush, so long streams still update. */
206
238
  private schedule(): void {
207
239
  if (this.timer) return;
208
- this.timer = setTimeout(() => void this.flush(), this.opts.debounceMs ?? 2000);
240
+ this.timer = setTimeout(() => this.kick(), this.opts.debounceMs ?? 2000);
209
241
  }
210
242
 
211
243
  private clearTimer(): void {
@@ -213,6 +245,11 @@ export class ActivityTracker {
213
245
  this.timer = undefined;
214
246
  }
215
247
 
248
+ private kick(): void {
249
+ this.pending = this.flush();
250
+ }
251
+
252
+ /** Updates the live working line only; breadcrumbs are recorded per step in turnEnd. */
216
253
  private async flush(): Promise<void> {
217
254
  this.clearTimer();
218
255
  const seq = ++this.seq;
@@ -220,7 +257,6 @@ export class ActivityTracker {
220
257
  const ctl = new AbortController();
221
258
  this.inflight = ctl;
222
259
  const timeout = setTimeout(() => ctl.abort(), this.opts.timeoutMs ?? 10_000);
223
- const ids = [...this.toolCallIds];
224
260
  let text: string | undefined;
225
261
  try {
226
262
  text = cleanLine(await this.opts.complete(this.prompt(), ctl.signal));
@@ -232,69 +268,34 @@ export class ActivityTracker {
232
268
  if (seq !== this.seq) return; // a newer flush or stop() superseded this one
233
269
  this.stale = false;
234
270
  this.inflight = undefined;
235
- const source = text ? "model" : "fallback";
236
- const line = text ?? fallbackLine(this.lastTool);
237
- if (line.toUpperCase() === "SAME") {
238
- if (!this.last && this.recent.length) this.opts.show((this.last = this.recent[this.recent.length - 1])); // new run, same goal
239
- return; // keep ids for the next line
240
- }
241
- if ((text && similarLine(this.last, line)) || line === this.last) return;
271
+ const same = text?.toUpperCase() === "SAME";
272
+ this.lastTurn = this.turn;
273
+ if (same && this.last) return; // the current line still describes this step
274
+ const line = text && !same ? text : fallbackLine(this.lastTool);
275
+ this.lastSource = text && !same ? "model" : "fallback";
276
+ if (line === this.last || (text && similarLine(this.last, line))) return;
242
277
  this.last = line;
243
278
  this.opts.show(line);
244
- if (this.isRepeat(line)) return; // the live line may reword; the transcript keeps one breadcrumb per goal
245
- this.toolCallIds = [];
246
- this.remember(line);
247
- this.opts.record({ text: line, at: Date.now(), turn: this.turn, source, toolCallIds: ids, thinking: this.thinking.trim() || undefined, tools: [...this.tools] });
248
- }
249
-
250
- private isRepeat(line: string): boolean {
251
- return this.recent.some((r) => r === line || similarLine(r, line));
252
- }
253
-
254
- private remember(line: string): void {
255
- this.recent = [...this.recent, line].slice(-3);
256
- }
257
-
258
- /** Short runs end before their line is recorded; record it anyway so hidden tool rows still leave a summary. */
259
- private async recordLate(): Promise<void> {
260
- const ids = [...this.toolCallIds];
261
- const [prompt, last, tool, turn] = [this.prompt(), this.last, this.lastTool, this.turn];
262
- const [thinking, tools] = [this.thinking.trim() || undefined, [...this.tools]];
263
- this.toolCallIds = [];
264
- let text: string | undefined;
265
- try {
266
- text = cleanLine(await this.opts.complete(prompt, AbortSignal.timeout(this.opts.timeoutMs ?? 10_000)));
267
- } catch {
268
- text = undefined;
269
- }
270
- if (text?.toUpperCase() === "SAME" && (last || this.recent.length)) return; // an earlier breadcrumb covers these tools
271
- const line = text ?? fallbackLine(tool);
272
- if (this.isRepeat(line)) return;
273
- this.remember(line);
274
- try {
275
- this.opts.record({ text: line, at: Date.now(), turn, source: text ? "model" : "fallback", toolCallIds: ids, thinking, tools });
276
- } catch {
277
- // session may already be gone (shutdown)
278
- }
279
279
  }
280
280
 
281
281
  private prompt(): string {
282
+ const leadIn = this.step.text.trim();
282
283
  return [
283
- "You narrate the GOAL a coding agent is pursuing right now, not the command it runs.",
284
- "Infer intent from the user's request, the agent's own words, and its recent tool calls (latest last).",
284
+ "You narrate the GOAL of a coding agent's current step, not the command it runs.",
285
+ "Infer intent from the agent's lead-in for this step, the user's request, and the step's tool calls.",
285
286
  "Never name a shell command, flag, or tool; say why it is being run.",
286
- "Good: \"Checking why the sync test fails\", \"Finding where titles get set\", \"Delegating a search for config loaders\".",
287
- "Bad: \"Running git status\", \"Running bash\", \"Reading core.ts\".",
287
+ 'Good: "Checking why the sync test fails", "Finding where titles get set", "Skipping re-renders when state is unchanged".',
288
+ 'Bad: "Running git status", "Running bash", "Reading core.ts".',
288
289
  "Reply with ONE line, at most 12 words, present-progressive.",
289
290
  "No quotes, no preamble, no trailing period. Output only the line.",
290
- this.recent.length ? `Breadcrumbs so far (latest last): ${this.recent.join(" | ")}` : "",
291
- this.last ? `Current line: ${this.last}` : "",
292
- this.recent.length || this.last ? "Reply exactly SAME unless the goal clearly changed from these. Rewording the same goal is SAME." : "",
291
+ this.crumb ? `Previous step: ${this.crumb}. Describe what is new in this step; do not repeat it.` : "",
292
+ this.last && !leadIn ? `Current line: ${this.last}\nIf it still describes the work, reply exactly SAME.` : "",
293
293
  "",
294
294
  "<context>",
295
295
  this.userPrompt ? `User asked: ${clip(this.userPrompt, 300)}` : "",
296
+ leadIn ? `Agent's lead-in for this step: ${clip(leadIn, 400)}` : "",
296
297
  this.tools.length ? `Recent tool calls (latest last): ${this.tools.join("; ")}` : "",
297
- this.text.trim() ? `Assistant text tail: ${clip(this.text, 600)}` : "",
298
+ !leadIn && this.text.trim() ? `Assistant text tail: ${clip(this.text, 600)}` : "",
298
299
  this.thinking.trim() ? `Thinking tail: ${clip(this.thinking, 300)}` : "",
299
300
  "</context>",
300
301
  ]