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 +3 -3
- package/extensions/index.ts +8 -0
- package/package.json +1 -1
- package/src/core.ts +61 -60
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
|
|
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
|
|
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
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
128
|
-
private
|
|
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
|
-
|
|
170
|
-
this.
|
|
171
|
-
|
|
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(() =>
|
|
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
|
|
236
|
-
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
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
|
|
284
|
-
"Infer intent from the
|
|
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
|
-
|
|
287
|
-
|
|
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.
|
|
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
|
]
|