pi-summarize 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +94 -2
- package/extensions/index.ts +211 -0
- package/package.json +43 -4
- package/src/core.ts +349 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bryan Jeffrey Smith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-summarize
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) extension package with two features, both driven by a cheap side model:
|
|
4
|
+
|
|
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
|
+
|
|
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
|
+
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
|
+
|
|
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
|
+
|
|
12
|
+
```jsonc
|
|
13
|
+
// {"type":"custom","customType":"pi-summarize","data":{"kind":"activity","text":"Reading files.js…","at":1760000000000,"turn":2,"source":"fallback","toolCallIds":["t1"]}}
|
|
14
|
+
// {"type":"custom","customType":"pi-summarize","data":{"kind":"title","text":"Fix Sync Adapter","auto":true,"at":1760000000000}}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Host integration
|
|
18
|
+
|
|
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
|
+
|
|
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
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
From npm:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pi install npm:pi-summarize
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
From git:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pi install git:github.com/bry-guy/pi-summarize
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From a local checkout:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pi install /path/to/pi-summarize
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
Set these in `~/.pi/agent/settings.json` under the `pi-summarize` key, or override with environment variables (env wins):
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"pi-summarize": {
|
|
50
|
+
"model": "anthropic/claude-haiku-5-5",
|
|
51
|
+
"thinking": "low",
|
|
52
|
+
"activity": true,
|
|
53
|
+
"title": true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
| Setting | Env var | Default | Notes |
|
|
59
|
+
|---|---|---|---|
|
|
60
|
+
| `model` | `PI_SUMMARIZE_MODEL` | session's current model | `provider/id`. If not found, a warning is shown once and only fallback lines are used. |
|
|
61
|
+
| `thinking` | `PI_SUMMARIZE_THINKING` | `off` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. Invalid values become `off`. |
|
|
62
|
+
| `activity` | `PI_SUMMARIZE_ACTIVITY` | `true` | Live working line. `0`/`false`/`off`/`no` disable it. |
|
|
63
|
+
| `title` | `PI_SUMMARIZE_TITLE` | `true` | Auto-title. Same false values. |
|
|
64
|
+
|
|
65
|
+
The extension reads `settings.json` from `$PI_CODING_AGENT_DIR` (default `~/.pi/agent`). Project-level settings are not read.
|
|
66
|
+
|
|
67
|
+
## Try it
|
|
68
|
+
|
|
69
|
+
Run pi in the TUI and send a prompt that uses tools. The working line should change from the default spinner text to activity lines. After the first reply, the session name should be set (check `/name` or the session selector).
|
|
70
|
+
|
|
71
|
+
Non-interactive check:
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
cd /tmp && pi --session-dir /tmp/ps-sessions -p "list files in /tmp briefly"
|
|
75
|
+
grep '"customType":"pi-summarize"' /tmp/ps-sessions/*.jsonl
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
npm test # node --test on test/*.test.ts (Node ≥ 22.18, native TS type stripping)
|
|
82
|
+
mise run release # tests, refuses an already-published version, then npm publish
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- `src/core.ts`: config, fallback lines, debounced `ActivityTracker`, auto-title rules. No pi imports.
|
|
86
|
+
- `extensions/index.ts`: pi wiring (events, model call via `ctx.modelRegistry.streamSimple`, `setWorkingMessage`, `appendEntry`, `setSessionName`).
|
|
87
|
+
- `test/core.test.ts`: unit tests with an injected mock completion.
|
|
88
|
+
- `test/extension.test.ts`: loads the extension against a fake pi API and mock registry.
|
|
89
|
+
|
|
90
|
+
## Limits
|
|
91
|
+
|
|
92
|
+
- Model calls use `streamSimple`, not `complete()`, because only `streamSimple` accepts a reasoning level. `complete()` is used in pi's own examples.
|
|
93
|
+
- Activity entries record `toolCallIds` but not `fromEntryIds`, because streaming events do not carry transcript entry ids.
|
|
94
|
+
- Each activity line costs one side-model call. A long run with text streaming makes roughly one call every 2 s. Use a small model.
|
|
95
|
+
- Only user-scope settings are read. The extension does not add a settings UI.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { Box, Container, Text } from "@earendil-works/pi-tui";
|
|
6
|
+
import { ActivityTracker, autoTitle, resolveConfig, type SummarizeConfig } from "../src/core.ts";
|
|
7
|
+
|
|
8
|
+
type Ctx = ExtensionContext;
|
|
9
|
+
type Context = Parameters<Ctx["modelRegistry"]["streamSimple"]>[1];
|
|
10
|
+
type Content = { type?: string; text?: string };
|
|
11
|
+
|
|
12
|
+
const textOf = (content: unknown): string =>
|
|
13
|
+
typeof content === "string"
|
|
14
|
+
? content
|
|
15
|
+
: Array.isArray(content)
|
|
16
|
+
? (content as Content[])
|
|
17
|
+
.filter((c) => c.type === "text" && typeof c.text === "string")
|
|
18
|
+
.map((c) => c.text)
|
|
19
|
+
.join("\n")
|
|
20
|
+
: "";
|
|
21
|
+
|
|
22
|
+
/** Bus channel for each shown activity line. Hosts such as subagent runners listen here. */
|
|
23
|
+
const ACTIVITY_CHANNEL = "pi-summarize:activity";
|
|
24
|
+
|
|
25
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
26
|
+
|
|
27
|
+
function loadSettings(): unknown {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf8"));
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Configured "provider/id" model, or the session's current model when unset. */
|
|
36
|
+
function resolveModel(ctx: Ctx, spec: string | undefined) {
|
|
37
|
+
if (!spec) return ctx.model;
|
|
38
|
+
const slash = spec.indexOf("/");
|
|
39
|
+
return slash > 0 ? ctx.modelRegistry.find(spec.slice(0, slash), spec.slice(slash + 1)) : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** One side-model call. Rejects when no usable model exists, the call fails, or the signal aborts. */
|
|
43
|
+
async function complete(ctx: Ctx, cfg: SummarizeConfig, prompt: string, signal: AbortSignal, maxTokens: number) {
|
|
44
|
+
const model = resolveModel(ctx, cfg.model);
|
|
45
|
+
if (!model || !ctx.modelRegistry.hasConfiguredAuth(model)) throw new Error("no usable summary model");
|
|
46
|
+
const context: Context = {
|
|
47
|
+
messages: [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }],
|
|
48
|
+
};
|
|
49
|
+
const stream = ctx.modelRegistry.streamSimple(model, context, {
|
|
50
|
+
...(cfg.thinking === "off" ? {} : { reasoning: cfg.thinking }),
|
|
51
|
+
signal,
|
|
52
|
+
maxTokens,
|
|
53
|
+
});
|
|
54
|
+
for await (const event of stream) {
|
|
55
|
+
if (event.type === "done") return textOf(event.message.content);
|
|
56
|
+
if (event.type === "error") throw new Error("summary model error");
|
|
57
|
+
}
|
|
58
|
+
throw new Error("summary stream ended without a result");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function (pi: ExtensionAPI) {
|
|
62
|
+
registerRenderers(pi, () => cfg.activity);
|
|
63
|
+
let live: Ctx | undefined; // latest context; the one captured at session_start can go stale
|
|
64
|
+
let cfg: SummarizeConfig = resolveConfig({}, {});
|
|
65
|
+
let tracker: ActivityTracker | undefined;
|
|
66
|
+
let titling = false;
|
|
67
|
+
|
|
68
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
69
|
+
live = ctx;
|
|
70
|
+
cfg = resolveConfig(loadSettings(), process.env);
|
|
71
|
+
tracker?.stop();
|
|
72
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
73
|
+
tracker = new ActivityTracker({
|
|
74
|
+
complete: (prompt, signal) => complete(live!, cfg, prompt, signal, 60),
|
|
75
|
+
show: (text) => live?.ui.setWorkingMessage(text),
|
|
76
|
+
record: (entry) => {
|
|
77
|
+
pi.appendEntry("pi-summarize", { kind: "activity", ...entry });
|
|
78
|
+
pi.events.emit(ACTIVITY_CHANNEL, { sessionId, ...entry });
|
|
79
|
+
},
|
|
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("");
|
|
83
|
+
if (cfg.model && !resolveModel(ctx, cfg.model) && ctx.hasUI) {
|
|
84
|
+
ctx.ui.notify(`pi-summarize: model "${cfg.model}" not found; using fallback lines`, "warning");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
89
|
+
live = ctx;
|
|
90
|
+
if (cfg.activity) tracker?.begin("prompt" in event ? event.prompt : undefined);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
pi.on("turn_start", async (event, ctx) => {
|
|
94
|
+
live = ctx;
|
|
95
|
+
if (cfg.activity) tracker?.turnStart(event.turnIndex);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
pi.on("message_update", async (event, ctx) => {
|
|
99
|
+
live = ctx;
|
|
100
|
+
if (!cfg.activity) return;
|
|
101
|
+
const ev = event.assistantMessageEvent;
|
|
102
|
+
if (ev.type === "thinking_delta") tracker?.thinkingDelta(ev.delta);
|
|
103
|
+
if (ev.type === "text_delta") tracker?.textDelta(ev.delta);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.on("tool_execution_start", async (event, ctx) => {
|
|
107
|
+
live = ctx;
|
|
108
|
+
if (cfg.activity) tracker?.toolStart(event.toolCallId, event.toolName, event.args);
|
|
109
|
+
});
|
|
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
|
+
|
|
117
|
+
pi.on("tool_execution_end", async (_event, ctx) => {
|
|
118
|
+
live = ctx;
|
|
119
|
+
if (cfg.activity) tracker?.toolEnd();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
123
|
+
live = ctx;
|
|
124
|
+
if (cfg.activity) tracker?.stop();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// agent_settled, not agent_end: print mode can exit right after agent_end, and this must finish first.
|
|
128
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
129
|
+
live = ctx;
|
|
130
|
+
if (!cfg.title || titling) return;
|
|
131
|
+
|
|
132
|
+
const branch = ctx.sessionManager.getBranch();
|
|
133
|
+
const alreadyTitled = branch.some(
|
|
134
|
+
(e) => e.type === "custom" && e.customType === "pi-summarize" && (e.data as { kind?: string })?.kind === "title",
|
|
135
|
+
);
|
|
136
|
+
const messages = branch.flatMap((e) => (e.type === "message" ? [e.message as { role?: string; content?: unknown }] : []));
|
|
137
|
+
const firstUser = messages.find((m) => m.role === "user");
|
|
138
|
+
const firstAssistant = messages.find((m) => m.role === "assistant");
|
|
139
|
+
|
|
140
|
+
titling = true;
|
|
141
|
+
try {
|
|
142
|
+
await autoTitle({
|
|
143
|
+
hasReply: Boolean(firstAssistant),
|
|
144
|
+
alreadyTitled,
|
|
145
|
+
getName: () => pi.getSessionName(),
|
|
146
|
+
setName: (name) => pi.setSessionName(name),
|
|
147
|
+
record: (entry) => pi.appendEntry("pi-summarize", entry),
|
|
148
|
+
complete: (prompt) => complete(live!, cfg, prompt, AbortSignal.timeout(15_000), 30),
|
|
149
|
+
firstUser: textOf(firstUser?.content),
|
|
150
|
+
firstAssistant: textOf(firstAssistant?.content),
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
// titling is best-effort
|
|
154
|
+
} finally {
|
|
155
|
+
titling = false;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
pi.on("session_shutdown", async () => {
|
|
160
|
+
tracker?.stop();
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
type Renderers = NonNullable<ReturnType<Parameters<ExtensionAPI["registerToolRenderer"]>[0]>>;
|
|
165
|
+
type Data = { kind?: string; text?: string; source?: string; thinking?: string; tools?: string[] };
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Collapsed (default): tool rows render nothing, so the summary lines stand in for them.
|
|
169
|
+
* Expanded (Ctrl+O): tools render normally, and summaries show what they summarized.
|
|
170
|
+
*/
|
|
171
|
+
function registerRenderers(pi: ExtensionAPI, enabled: () => boolean) {
|
|
172
|
+
pi.registerEntryRenderer<Data>("pi-summarize", (entry, { expanded }, theme) => {
|
|
173
|
+
const d = entry.data;
|
|
174
|
+
if (d?.kind !== "activity" || !d.text || !enabled()) return new Container();
|
|
175
|
+
if (d.source === "fallback" && !expanded) return new Container(); // generic lines add nothing at a glance
|
|
176
|
+
const box = new Box(1, 0);
|
|
177
|
+
box.addChild(new Text(`${theme.fg("accent", "◆")} ${theme.fg("muted", d.text)}`, 0, 0));
|
|
178
|
+
if (expanded) {
|
|
179
|
+
if (d.thinking) box.addChild(new Text(theme.fg("dim", ` thinking: ${d.thinking.replace(/\s+/g, " ")}`), 0, 0));
|
|
180
|
+
for (const t of d.tools ?? []) box.addChild(new Text(theme.fg("dim", ` ${t}`), 0, 0));
|
|
181
|
+
}
|
|
182
|
+
return box;
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
pi.registerToolRenderer((toolName, next): Renderers | undefined => {
|
|
186
|
+
const inner = next();
|
|
187
|
+
if (!enabled()) return inner;
|
|
188
|
+
// renderShell "self" lets an empty render collapse the row to zero lines; when expanded we draw the shell ourselves.
|
|
189
|
+
type Theme = Parameters<NonNullable<Renderers["renderCall"]>>[1];
|
|
190
|
+
const shell = (child: unknown, theme: Theme, ctx: { isPartial: boolean; isError: boolean }) => {
|
|
191
|
+
const box = new Box(1, 0, (t: string) => theme.bg(ctx.isPartial ? "toolPendingBg" : ctx.isError ? "toolErrorBg" : "toolSuccessBg", t));
|
|
192
|
+
if (child) box.addChild(child as Text);
|
|
193
|
+
return box;
|
|
194
|
+
};
|
|
195
|
+
return {
|
|
196
|
+
renderShell: "self",
|
|
197
|
+
renderCall(args, theme, ctx) {
|
|
198
|
+
if (!ctx.expanded) return new Container();
|
|
199
|
+
if (inner?.renderShell === "self") return inner.renderCall?.(args, theme, ctx) ?? new Container();
|
|
200
|
+
return shell(inner?.renderCall?.(args, theme, { ...ctx, lastComponent: undefined }) ?? new Text(theme.fg("toolTitle", theme.bold(toolName)), 0, 0), theme, ctx);
|
|
201
|
+
},
|
|
202
|
+
renderResult(result, opts, theme, ctx) {
|
|
203
|
+
if (!ctx.expanded) return new Container();
|
|
204
|
+
if (inner?.renderShell === "self") return inner.renderResult?.(result, opts, theme, ctx) ?? new Container();
|
|
205
|
+
const text = (result.content as Content[] | undefined)?.filter((c) => c.type === "text").map((c) => c.text).join("\n");
|
|
206
|
+
const child = inner?.renderResult ? inner.renderResult(result, opts, theme, { ...ctx, lastComponent: undefined }) : text ? new Text(theme.fg("toolOutput", text), 0, 0) : undefined;
|
|
207
|
+
return shell(child, theme, ctx);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-summarize",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Pi extension: live one-line activity summaries and auto-titled sessions, using a cheap side model.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"summary",
|
|
9
|
+
"session-title"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/bry-guy/pi-summarize.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/bry-guy/pi-summarize#readme",
|
|
18
|
+
"bugs": {
|
|
19
|
+
"url": "https://github.com/bry-guy/pi-summarize/issues"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"extensions",
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./extensions/index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test test/*.test.ts"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
37
|
+
"@earendil-works/pi-tui": "*"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22.18"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-tui": "^1.3.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Pure logic for pi-summarize: config, fallback lines, debounced activity tracker, auto-title rules.
|
|
2
|
+
// No pi runtime imports, so it runs under plain `node --test`.
|
|
3
|
+
|
|
4
|
+
export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
5
|
+
|
|
6
|
+
export interface SummarizeConfig {
|
|
7
|
+
/** "provider/id". Undefined means the session's current model. */
|
|
8
|
+
model?: string;
|
|
9
|
+
thinking: ThinkingLevel;
|
|
10
|
+
activity: boolean;
|
|
11
|
+
title: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const THINKING_LEVELS: readonly ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
|
|
15
|
+
|
|
16
|
+
const flag = (value: unknown, fallback: boolean): boolean =>
|
|
17
|
+
value === undefined || value === null ? fallback : !/^(0|false|off|no)$/i.test(String(value));
|
|
18
|
+
|
|
19
|
+
/** Env (PI_SUMMARIZE_*) wins over the "pi-summarize" key in settings.json. */
|
|
20
|
+
export function resolveConfig(settings: unknown, env: Record<string, string | undefined>): SummarizeConfig {
|
|
21
|
+
const own = (settings as { "pi-summarize"?: Record<string, unknown> } | undefined)?.["pi-summarize"] ?? {};
|
|
22
|
+
const model = env.PI_SUMMARIZE_MODEL ?? (typeof own.model === "string" ? own.model : undefined);
|
|
23
|
+
const thinkingRaw = env.PI_SUMMARIZE_THINKING ?? own.thinking;
|
|
24
|
+
const thinking = THINKING_LEVELS.find((level) => level === thinkingRaw) ?? "off";
|
|
25
|
+
return {
|
|
26
|
+
model: model?.trim() || undefined,
|
|
27
|
+
thinking,
|
|
28
|
+
activity: flag(env.PI_SUMMARIZE_ACTIVITY ?? own.activity, true),
|
|
29
|
+
title: flag(env.PI_SUMMARIZE_TITLE ?? own.title, true),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ToolActivity {
|
|
34
|
+
name: string;
|
|
35
|
+
args?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const str = (value: unknown): string | undefined => (typeof value === "string" && value.trim() ? value : undefined);
|
|
39
|
+
const clip = (text: string, max: number): string => {
|
|
40
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
41
|
+
if (flat.length <= max) return flat;
|
|
42
|
+
const cut = flat.slice(0, max - 1);
|
|
43
|
+
const space = cut.lastIndexOf(" ");
|
|
44
|
+
return `${(space > max / 2 ? cut.slice(0, space) : cut).replace(/[\s.,;:\u2026]+$/, "")}\u2026`;
|
|
45
|
+
};
|
|
46
|
+
const baseName = (path: string): string => path.split("/").filter(Boolean).pop() ?? path;
|
|
47
|
+
|
|
48
|
+
/** Deterministic line used when the model is unavailable, slow, or fails. */
|
|
49
|
+
export function fallbackLine(tool?: ToolActivity): string {
|
|
50
|
+
if (!tool) return "Thinking…";
|
|
51
|
+
const args = tool.args ?? {};
|
|
52
|
+
const path = str(args.path) ?? str(args.file_path) ?? str(args.file);
|
|
53
|
+
const name = tool.name.toLowerCase();
|
|
54
|
+
if (name === "bash") return "Working…"; // the command itself is noise; the model supplies the goal
|
|
55
|
+
if (name === "read" && path) return `Reading ${baseName(path)}…`;
|
|
56
|
+
if (name === "edit" && path) return `Editing ${baseName(path)}…`;
|
|
57
|
+
if (name === "write" && path) return `Writing ${baseName(path)}…`;
|
|
58
|
+
if (["grep", "find", "glob"].includes(name)) return `Searching ${clip(str(args.pattern) ?? path ?? "files", 30).replace(/…$/, "")}…`;
|
|
59
|
+
if (name === "ls") return `Listing ${clip(path ?? "files", 30).replace(/…$/, "")}…`;
|
|
60
|
+
return `Running ${tool.name}…`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const words = (text: string): Set<string> =>
|
|
64
|
+
new Set(text.toLowerCase().replace(/[^a-z0-9_\s-]/g, " ").split(/\s+/).filter((w) => w.length > 2));
|
|
65
|
+
|
|
66
|
+
/** True when two lines say essentially the same thing (word overlap), so the newer one adds nothing. */
|
|
67
|
+
export function similarLine(a: string | undefined, b: string): boolean {
|
|
68
|
+
if (!a) return false;
|
|
69
|
+
const x = words(a);
|
|
70
|
+
const y = words(b);
|
|
71
|
+
if (!x.size || !y.size) return a.trim() === b.trim();
|
|
72
|
+
let shared = 0;
|
|
73
|
+
for (const w of x) if (y.has(w)) shared++;
|
|
74
|
+
return shared / Math.min(x.size, y.size) >= 0.75;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Accept one short line; reject anything over 12 words so the fallback is used instead. */
|
|
78
|
+
export function cleanLine(raw: string): string | undefined {
|
|
79
|
+
const line = raw
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((s) => s.trim())
|
|
82
|
+
.find(Boolean)
|
|
83
|
+
?.replace(/^["'`*#>\-\s]+|["'`*\s]+$/g, "")
|
|
84
|
+
.replace(/\.+$/, "");
|
|
85
|
+
if (!line || line.split(/\s+/).length > 12) return undefined;
|
|
86
|
+
if (/^same$/i.test(line)) return "SAME";
|
|
87
|
+
return clip(line, 80);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tail = (text: string, max: number): string => (text.length > max ? text.slice(-max) : text);
|
|
91
|
+
|
|
92
|
+
export interface ActivityRecord {
|
|
93
|
+
text: string;
|
|
94
|
+
at: number;
|
|
95
|
+
turn: number;
|
|
96
|
+
source: "model" | "fallback";
|
|
97
|
+
toolCallIds: string[];
|
|
98
|
+
/** What the line summarized, for the expanded transcript view. */
|
|
99
|
+
thinking?: string;
|
|
100
|
+
tools?: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TrackerOptions {
|
|
104
|
+
/** Calls the side model. Must reject on failure and honor the signal. */
|
|
105
|
+
complete(prompt: string, signal: AbortSignal): Promise<string>;
|
|
106
|
+
/** Shows a line in the working indicator; undefined restores the default. */
|
|
107
|
+
show(text: string | undefined): void;
|
|
108
|
+
record(entry: ActivityRecord): void;
|
|
109
|
+
debounceMs?: number;
|
|
110
|
+
timeoutMs?: number;
|
|
111
|
+
/** How long turnEnd waits for the step's live line before using the fallback. */
|
|
112
|
+
stepWaitMs?: number;
|
|
113
|
+
/** While a tool runs with no new line, re-show the line with elapsed time this often. 0 disables. */
|
|
114
|
+
heartbeatMs?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class ActivityTracker {
|
|
118
|
+
private userPrompt?: string;
|
|
119
|
+
private turn = 0;
|
|
120
|
+
private tools: string[] = [];
|
|
121
|
+
private lastTool?: ToolActivity;
|
|
122
|
+
private text = "";
|
|
123
|
+
private thinking = "";
|
|
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[] };
|
|
126
|
+
private timer?: ReturnType<typeof setTimeout>;
|
|
127
|
+
private inflight?: AbortController;
|
|
128
|
+
private pending?: Promise<void>;
|
|
129
|
+
private seq = 0;
|
|
130
|
+
private last?: 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;
|
|
135
|
+
private stale = false;
|
|
136
|
+
private toolSince?: number;
|
|
137
|
+
private beat?: ReturnType<typeof setInterval>;
|
|
138
|
+
|
|
139
|
+
private readonly opts: TrackerOptions;
|
|
140
|
+
|
|
141
|
+
constructor(opts: TrackerOptions) {
|
|
142
|
+
this.opts = opts;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** New agent run: forget the previous run's activity. */
|
|
146
|
+
begin(userPrompt?: string): void {
|
|
147
|
+
this.userPrompt = userPrompt;
|
|
148
|
+
this.tools = [];
|
|
149
|
+
this.lastTool = undefined;
|
|
150
|
+
this.text = "";
|
|
151
|
+
this.thinking = "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
turnStart(turn: number): void {
|
|
155
|
+
this.turn = turn;
|
|
156
|
+
this.step = { text: "", thinking: "", tools: [], ids: [] };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
thinkingDelta(delta: string): void {
|
|
160
|
+
this.thinking = tail(this.thinking + delta, 400);
|
|
161
|
+
this.step.thinking = tail(this.step.thinking + delta, 400);
|
|
162
|
+
this.schedule();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
textDelta(delta: string): void {
|
|
166
|
+
this.text = tail(this.text + delta, 600);
|
|
167
|
+
this.step.text = tail(this.step.text + delta, 600);
|
|
168
|
+
this.schedule();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Tool calls are the most informative boundary, so they summarize immediately. */
|
|
172
|
+
toolStart(toolCallId: string, name: string, args?: Record<string, unknown>): void {
|
|
173
|
+
this.lastTool = { name, args };
|
|
174
|
+
this.toolSince = Date.now();
|
|
175
|
+
this.stale = true; // no heartbeat until this tool's line is decided
|
|
176
|
+
this.startBeat();
|
|
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();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
toolEnd(): void {
|
|
185
|
+
this.toolSince = undefined;
|
|
186
|
+
this.schedule();
|
|
187
|
+
}
|
|
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
|
+
|
|
212
|
+
/** Keeps the line visibly alive during long tools: same text plus elapsed seconds, display only. */
|
|
213
|
+
private startBeat(): void {
|
|
214
|
+
const every = this.opts.heartbeatMs ?? 5000;
|
|
215
|
+
if (!every || this.beat) return;
|
|
216
|
+
this.beat = setInterval(() => {
|
|
217
|
+
if (!this.toolSince || !this.last || this.stale) return;
|
|
218
|
+
const secs = Math.round((Date.now() - this.toolSince) / 1000);
|
|
219
|
+
if (secs >= 5) this.opts.show(`${this.last} · ${secs}s`);
|
|
220
|
+
}, every);
|
|
221
|
+
(this.beat as { unref?: () => void }).unref?.();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Agent run ended: drop pending work, cancel in-flight calls, restore the default line. */
|
|
225
|
+
stop(): void {
|
|
226
|
+
this.clearTimer();
|
|
227
|
+
if (this.beat) clearInterval(this.beat);
|
|
228
|
+
this.beat = undefined;
|
|
229
|
+
this.toolSince = undefined;
|
|
230
|
+
this.inflight?.abort();
|
|
231
|
+
this.inflight = undefined;
|
|
232
|
+
this.seq++;
|
|
233
|
+
this.last = undefined;
|
|
234
|
+
this.opts.show(undefined);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Max-wait debounce: the first event in a window arms one flush, so long streams still update. */
|
|
238
|
+
private schedule(): void {
|
|
239
|
+
if (this.timer) return;
|
|
240
|
+
this.timer = setTimeout(() => this.kick(), this.opts.debounceMs ?? 2000);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private clearTimer(): void {
|
|
244
|
+
if (this.timer) clearTimeout(this.timer);
|
|
245
|
+
this.timer = undefined;
|
|
246
|
+
}
|
|
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. */
|
|
253
|
+
private async flush(): Promise<void> {
|
|
254
|
+
this.clearTimer();
|
|
255
|
+
const seq = ++this.seq;
|
|
256
|
+
this.inflight?.abort();
|
|
257
|
+
const ctl = new AbortController();
|
|
258
|
+
this.inflight = ctl;
|
|
259
|
+
const timeout = setTimeout(() => ctl.abort(), this.opts.timeoutMs ?? 10_000);
|
|
260
|
+
let text: string | undefined;
|
|
261
|
+
try {
|
|
262
|
+
text = cleanLine(await this.opts.complete(this.prompt(), ctl.signal));
|
|
263
|
+
} catch {
|
|
264
|
+
text = undefined; // fail silently to the fallback below
|
|
265
|
+
} finally {
|
|
266
|
+
clearTimeout(timeout);
|
|
267
|
+
}
|
|
268
|
+
if (seq !== this.seq) return; // a newer flush or stop() superseded this one
|
|
269
|
+
this.stale = false;
|
|
270
|
+
this.inflight = undefined;
|
|
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;
|
|
277
|
+
this.last = line;
|
|
278
|
+
this.opts.show(line);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private prompt(): string {
|
|
282
|
+
const leadIn = this.step.text.trim();
|
|
283
|
+
return [
|
|
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.",
|
|
286
|
+
"Never name a shell command, flag, or tool; say why it is being run.",
|
|
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".',
|
|
289
|
+
"Reply with ONE line, at most 12 words, present-progressive.",
|
|
290
|
+
"No quotes, no preamble, no trailing period. Output only the line.",
|
|
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
|
+
"",
|
|
294
|
+
"<context>",
|
|
295
|
+
this.userPrompt ? `User asked: ${clip(this.userPrompt, 300)}` : "",
|
|
296
|
+
leadIn ? `Agent's lead-in for this step: ${clip(leadIn, 400)}` : "",
|
|
297
|
+
this.tools.length ? `Recent tool calls (latest last): ${this.tools.join("; ")}` : "",
|
|
298
|
+
!leadIn && this.text.trim() ? `Assistant text tail: ${clip(this.text, 600)}` : "",
|
|
299
|
+
this.thinking.trim() ? `Thinking tail: ${clip(this.thinking, 300)}` : "",
|
|
300
|
+
"</context>",
|
|
301
|
+
]
|
|
302
|
+
.filter(Boolean)
|
|
303
|
+
.join("\n");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Strip quotes/punctuation, keep 2–4 words. */
|
|
308
|
+
export function cleanTitle(raw: string): string | undefined {
|
|
309
|
+
const line = raw
|
|
310
|
+
.split("\n")
|
|
311
|
+
.map((s) => s.trim())
|
|
312
|
+
.find(Boolean)
|
|
313
|
+
?.replace(/^["'`*#\s]+|["'`*.!?\s]+$/g, "");
|
|
314
|
+
if (!line) return undefined;
|
|
315
|
+
return clip(line.split(/\s+/).slice(0, 4).join(" "), 60);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface AutoTitleOptions {
|
|
319
|
+
hasReply: boolean;
|
|
320
|
+
/** True once pi-summarize has already named this session (see the "title" record). */
|
|
321
|
+
alreadyTitled: boolean;
|
|
322
|
+
getName(): string | undefined;
|
|
323
|
+
setName(name: string): void;
|
|
324
|
+
record(entry: { kind: "title"; text: string; auto: true; at: number }): void;
|
|
325
|
+
complete(prompt: string): Promise<string>;
|
|
326
|
+
firstUser?: string;
|
|
327
|
+
firstAssistant?: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Names an unnamed session after its first reply. Never overwrites a name that exists before or during the call. */
|
|
331
|
+
export async function autoTitle(o: AutoTitleOptions): Promise<string | undefined> {
|
|
332
|
+
if (!o.hasReply || o.alreadyTitled || o.getName()) return undefined;
|
|
333
|
+
const prompt = [
|
|
334
|
+
"Give a 2-4 word title for this coding session. Reply with only the title, no quotes.",
|
|
335
|
+
"",
|
|
336
|
+
`User: ${clip(o.firstUser ?? "", 500)}`,
|
|
337
|
+
`Assistant: ${clip(o.firstAssistant ?? "", 500)}`,
|
|
338
|
+
].join("\n");
|
|
339
|
+
let title: string | undefined;
|
|
340
|
+
try {
|
|
341
|
+
title = cleanTitle(await o.complete(prompt));
|
|
342
|
+
} catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
if (!title || o.getName()) return undefined;
|
|
346
|
+
o.setName(title);
|
|
347
|
+
o.record({ kind: "title", text: title, auto: true, at: Date.now() });
|
|
348
|
+
return title;
|
|
349
|
+
}
|