pi-goosedump 0.10.4 → 0.10.6
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 +54 -74
- package/index.ts +201 -56
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,69 +1,62 @@
|
|
|
1
1
|
# pi-goosedump
|
|
2
2
|
|
|
3
|
-
`pi-goosedump`
|
|
4
|
-
entry expansion, and compaction
|
|
5
|
-
prefix
|
|
6
|
-
summaries.
|
|
3
|
+
`pi-goosedump` adds goosedump-backed session-history search, durable-memory
|
|
4
|
+
recall, entry expansion, and compaction to Pi. Its tools use the `goose_`
|
|
5
|
+
prefix.
|
|
7
6
|
|
|
8
|
-
##
|
|
7
|
+
## Install
|
|
9
8
|
|
|
10
|
-
```
|
|
9
|
+
```sh
|
|
11
10
|
pi install npm:pi-goosedump
|
|
12
11
|
```
|
|
13
12
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
and
|
|
17
|
-
|
|
13
|
+
The package installs `@jarkkojs/goosedump`, including native binaries for Linux
|
|
14
|
+
x64, macOS arm64, and Windows x64. If no compatible binary is available, the
|
|
15
|
+
extension keeps Pi's normal compaction behavior and its goosedump features
|
|
16
|
+
report that they are unavailable.
|
|
18
17
|
|
|
19
18
|
## Tools
|
|
20
19
|
|
|
21
|
-
-
|
|
22
|
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
with
|
|
28
|
-
-
|
|
29
|
-
the last N user turns verbatim
|
|
30
|
-
|
|
31
|
-
The read tools (`goose_search`, `goose_get`, `goose_grep`) each accept an
|
|
32
|
-
optional `provider` (default `pi`; also `claude`, `codex`, `crush`, `gemini`,
|
|
33
|
-
`goose`, `opencode`) and `session` id. `session` defaults to the current Pi
|
|
34
|
-
session for `pi` and is required otherwise. `scope` is `lineage` (default,
|
|
35
|
-
current branch) or `all`. Targets are always provider-qualified
|
|
36
|
-
(`provider:id`); a bare id is never sent to goosedump.
|
|
20
|
+
- **`goose_search`** ranks session messages and returns compact overviews with
|
|
21
|
+
entry IDs.
|
|
22
|
+
- **`goose_get`** expands entry IDs returned by `goose_search` or `goose_grep`.
|
|
23
|
+
- **`goose_grep`** filters session messages with a glob pattern.
|
|
24
|
+
- **`goose_recall`** searches memory harvested by earlier compactions. It
|
|
25
|
+
searches the current working directory by default and returns full entries
|
|
26
|
+
with provenance.
|
|
27
|
+
- **`goose_compact`** starts asynchronous compaction of the current Pi session.
|
|
28
|
+
Set `keep` to preserve the last N user turns verbatim.
|
|
37
29
|
|
|
38
|
-
|
|
30
|
+
The session-history tools accept an optional `provider` (`pi` by default),
|
|
31
|
+
`session`, and `scope` (`lineage` by default or `all`). The current Pi session
|
|
32
|
+
is used when both provider and session are omitted. Non-Pi providers require an
|
|
33
|
+
explicit session ID. Targets sent to goosedump are always provider-qualified.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
|
|
37
|
+
```text
|
|
39
38
|
goose_search({ query: "bug fix" })
|
|
40
|
-
goose_recall({ query: "prior architecture decision" })
|
|
41
39
|
goose_search({ query: "auth", scope: "all", page: 2 })
|
|
42
40
|
goose_get({ ids: ["entry-a", "entry-b"] })
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
goose_grep({ pattern: "*timeout*" })
|
|
42
|
+
goose_recall({ query: "prior architecture decision" })
|
|
45
43
|
goose_compact({ keep: 2 })
|
|
46
44
|
```
|
|
47
45
|
|
|
48
46
|
## Commands
|
|
49
47
|
|
|
50
|
-
- **`/goose-search [query]
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
summarized; with no argument goosedump summarizes up to Pi's token-based cut.
|
|
56
|
-
- **`/goose-settings`:** edit the global and project compaction settings.
|
|
48
|
+
- **`/goose-search [query]`** searches the current session. A bare command opens
|
|
49
|
+
an inline prompt; selecting a result expands it.
|
|
50
|
+
- **`/goose-compact [keep:N]`** compacts now. With `keep:N`, the last N user
|
|
51
|
+
turns remain verbatim; without it, Pi chooses the cut point.
|
|
52
|
+
- **`/goose-settings`** edits global and project compaction settings.
|
|
57
53
|
|
|
58
54
|
## Settings
|
|
59
55
|
|
|
60
|
-
|
|
56
|
+
Optional settings live under `goosedump` in:
|
|
61
57
|
|
|
62
|
-
- `~/.pi/agent/settings.json`
|
|
63
|
-
- `.pi/settings.json`
|
|
64
|
-
|
|
65
|
-
Project settings override global settings. All settings live in a `goosedump`
|
|
66
|
-
section and are optional (defaults shown):
|
|
58
|
+
- `~/.pi/agent/settings.json` for global settings.
|
|
59
|
+
- `.pi/settings.json` for project overrides.
|
|
67
60
|
|
|
68
61
|
```json
|
|
69
62
|
{
|
|
@@ -73,37 +66,24 @@ section and are optional (defaults shown):
|
|
|
73
66
|
}
|
|
74
67
|
```
|
|
75
68
|
|
|
76
|
-
`turnsBeforeCompact`
|
|
77
|
-
compaction
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
summary starts where the previous one left off); omitted on the first
|
|
94
|
-
compaction.
|
|
95
|
-
- `--until` is the first entry Pi is keeping (`firstKeptEntryId`).
|
|
96
|
-
|
|
97
|
-
goosedump composes the structured summary itself and emits a native Pi
|
|
98
|
-
compaction entry, from which the extension extracts the `summary` field. The
|
|
99
|
-
extension prepends Pi's previous compaction summary when present, so prior goals,
|
|
100
|
-
progress, and context survive incremental compactions. A `keep:N` override (via
|
|
101
|
-
`/goose-compact` or the `goose_compact` tool) replaces Pi's token-based cut with
|
|
102
|
-
a turn-based one: summarize everything before the Nth-from-last user entry in the
|
|
103
|
-
active, uncompacted history and keep the tail verbatim. Fewer than N active user
|
|
104
|
-
turns cancels with a notice. On completion Pi notifies the token reduction.
|
|
69
|
+
`turnsBeforeCompact` is an integer from 0 to 255. `0` disables scheduled
|
|
70
|
+
compaction. The countdown is restored when a session resumes and appears in the
|
|
71
|
+
footer as hexadecimal: `GD: 0A` means ten turns remain, `GD: 00` is ready to
|
|
72
|
+
compact, and `GD: --` is disabled. Clearing the project value restores the
|
|
73
|
+
global setting.
|
|
74
|
+
|
|
75
|
+
## Compaction behavior
|
|
76
|
+
|
|
77
|
+
The extension replaces Pi's normal summary during manual, scheduled, and
|
|
78
|
+
automatic compaction. It preserves the previous compaction summary and asks
|
|
79
|
+
goosedump to summarize only the newly compacted range. A `keep:N` request
|
|
80
|
+
instead keeps the last N active user turns; it is cancelled when no earlier
|
|
81
|
+
history is available to summarize.
|
|
82
|
+
|
|
83
|
+
Compactions with custom instructions continue to use Pi's normal compactor. If
|
|
84
|
+
goosedump fails, the extension reports the error and falls back to Pi. Successful
|
|
85
|
+
compactions report memory-learning warnings without discarding the summary.
|
|
105
86
|
|
|
106
87
|
## License
|
|
107
88
|
|
|
108
|
-
`pi-goosedump` is licensed under `Apache-2.0`. See [LICENSE](LICENSE)
|
|
109
|
-
information.
|
|
89
|
+
`pi-goosedump` is licensed under `Apache-2.0`. See [LICENSE](LICENSE).
|
package/index.ts
CHANGED
|
@@ -50,6 +50,9 @@ import {
|
|
|
50
50
|
import { Type } from '@sinclair/typebox';
|
|
51
51
|
|
|
52
52
|
const require = createRequire(import.meta.url);
|
|
53
|
+
const BRANCH_SUMMARY_PREFIX =
|
|
54
|
+
'The following is a summary of a branch that this conversation came back from:\n\n<summary>\n';
|
|
55
|
+
const BRANCH_SUMMARY_SUFFIX = '</summary>';
|
|
53
56
|
|
|
54
57
|
// One rendered message after normalizing a context into pi's native JSONL.
|
|
55
58
|
interface GoosedumpMessage {
|
|
@@ -58,6 +61,11 @@ interface GoosedumpMessage {
|
|
|
58
61
|
content: string;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
interface GoosedumpCommandOutput {
|
|
65
|
+
stdout: string;
|
|
66
|
+
stderr: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
interface GoosedumpMemoryHit {
|
|
62
70
|
hash: string;
|
|
63
71
|
kind: string;
|
|
@@ -67,6 +75,7 @@ interface GoosedumpMemoryHit {
|
|
|
67
75
|
context_id: string;
|
|
68
76
|
entry_id: string;
|
|
69
77
|
path: string;
|
|
78
|
+
source_path: string;
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
// A `content` part of a pi-native message line.
|
|
@@ -85,6 +94,7 @@ interface PiEntryJson {
|
|
|
85
94
|
type?: string;
|
|
86
95
|
id?: string;
|
|
87
96
|
message?: { role?: string; content?: PiPartJson[] | string };
|
|
97
|
+
content?: PiPartJson[] | string;
|
|
88
98
|
summary?: string;
|
|
89
99
|
}
|
|
90
100
|
|
|
@@ -102,6 +112,15 @@ const DEFAULT_GOOSEDUMP_SETTINGS: GoosedumpSettings = {
|
|
|
102
112
|
const TURNS_BEFORE_COMPACT_MIN = 0;
|
|
103
113
|
const TURNS_BEFORE_COMPACT_MAX = 255;
|
|
104
114
|
|
|
115
|
+
function isTurnsBeforeCompact(value: unknown): value is number {
|
|
116
|
+
return (
|
|
117
|
+
typeof value === 'number' &&
|
|
118
|
+
Number.isSafeInteger(value) &&
|
|
119
|
+
value >= TURNS_BEFORE_COMPACT_MIN &&
|
|
120
|
+
value <= TURNS_BEFORE_COMPACT_MAX
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
function piAgentDir(): string {
|
|
106
125
|
return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
|
|
107
126
|
}
|
|
@@ -132,7 +151,13 @@ function readJsonObject(path: string): Record<string, unknown> {
|
|
|
132
151
|
function readJsonObjectStrict(path: string): Record<string, unknown> {
|
|
133
152
|
if (!existsSync(path)) return {};
|
|
134
153
|
|
|
135
|
-
|
|
154
|
+
let parsed: unknown;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
159
|
+
throw new Error(`${path}: ${message}`);
|
|
160
|
+
}
|
|
136
161
|
if (!isRecord(parsed)) throw new Error(`${path} must contain a JSON object`);
|
|
137
162
|
return parsed;
|
|
138
163
|
}
|
|
@@ -159,17 +184,26 @@ function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
|
|
|
159
184
|
|
|
160
185
|
const settings: Partial<GoosedumpSettings> = {};
|
|
161
186
|
const value = raw.turnsBeforeCompact;
|
|
162
|
-
if (
|
|
163
|
-
typeof value === 'number' &&
|
|
164
|
-
Number.isSafeInteger(value) &&
|
|
165
|
-
value >= TURNS_BEFORE_COMPACT_MIN &&
|
|
166
|
-
value <= TURNS_BEFORE_COMPACT_MAX
|
|
167
|
-
) {
|
|
168
|
-
settings.turnsBeforeCompact = value;
|
|
169
|
-
}
|
|
187
|
+
if (isTurnsBeforeCompact(value)) settings.turnsBeforeCompact = value;
|
|
170
188
|
return settings;
|
|
171
189
|
}
|
|
172
190
|
|
|
191
|
+
function validateGoosedumpSettings(path: string): void {
|
|
192
|
+
const root = readJsonObjectStrict(path);
|
|
193
|
+
if (root.goosedump === undefined) return;
|
|
194
|
+
if (!isRecord(root.goosedump)) {
|
|
195
|
+
throw new Error(`${path}: goosedump must be a JSON object`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const value = root.goosedump.turnsBeforeCompact;
|
|
199
|
+
if (value !== undefined && !isTurnsBeforeCompact(value)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`${path}: goosedump.turnsBeforeCompact must be an integer from ` +
|
|
202
|
+
`${TURNS_BEFORE_COMPACT_MIN} to ${TURNS_BEFORE_COMPACT_MAX}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
173
207
|
function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
|
|
174
208
|
return {
|
|
175
209
|
...DEFAULT_GOOSEDUMP_SETTINGS,
|
|
@@ -181,7 +215,7 @@ function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
|
|
|
181
215
|
function notifyInvalidSettings(ctx: ExtensionContext): void {
|
|
182
216
|
for (const path of [globalSettingsPath(), projectSettingsPath(ctx.cwd)]) {
|
|
183
217
|
try {
|
|
184
|
-
|
|
218
|
+
validateGoosedumpSettings(path);
|
|
185
219
|
} catch (error) {
|
|
186
220
|
const message = error instanceof Error ? error.message : String(error);
|
|
187
221
|
if (ctx.hasUI) ctx.ui.notify(`goosedump settings error: ${message}`, 'error');
|
|
@@ -196,14 +230,7 @@ function readScopedGoosedumpSettings(cwd: string, scope: SettingsScope): Goosedu
|
|
|
196
230
|
|
|
197
231
|
const settings: Partial<GoosedumpSettings> = {};
|
|
198
232
|
const value = raw.turnsBeforeCompact;
|
|
199
|
-
if (
|
|
200
|
-
typeof value === 'number' &&
|
|
201
|
-
Number.isSafeInteger(value) &&
|
|
202
|
-
value >= TURNS_BEFORE_COMPACT_MIN &&
|
|
203
|
-
value <= TURNS_BEFORE_COMPACT_MAX
|
|
204
|
-
) {
|
|
205
|
-
settings.turnsBeforeCompact = value;
|
|
206
|
-
}
|
|
233
|
+
if (isTurnsBeforeCompact(value)) settings.turnsBeforeCompact = value;
|
|
207
234
|
return { ...DEFAULT_GOOSEDUMP_SETTINGS, ...settings };
|
|
208
235
|
}
|
|
209
236
|
|
|
@@ -290,11 +317,10 @@ function flattenPiContent(content: PiPartJson[] | string | undefined): string {
|
|
|
290
317
|
return content.map(flattenPiPart).filter(Boolean).join('\n');
|
|
291
318
|
}
|
|
292
319
|
|
|
293
|
-
// goosedump
|
|
294
|
-
// Passing `--as-provider pi` normalizes every provider's output to
|
|
295
|
-
//
|
|
296
|
-
|
|
297
|
-
function parsePiMessages(output: string): GoosedumpMessage[] {
|
|
320
|
+
// goosedump renders `show`/`grep`/`search` in a provider's native format.
|
|
321
|
+
// Passing `--as-provider pi` normalizes every provider's output to Pi JSONL, so
|
|
322
|
+
// one parser covers all sources, including Pi-native context entries.
|
|
323
|
+
export function parsePiMessages(output: string): GoosedumpMessage[] {
|
|
298
324
|
const messages: GoosedumpMessage[] = [];
|
|
299
325
|
for (const line of output.split('\n')) {
|
|
300
326
|
if (!line) continue;
|
|
@@ -304,9 +330,22 @@ function parsePiMessages(output: string): GoosedumpMessage[] {
|
|
|
304
330
|
} catch {
|
|
305
331
|
continue;
|
|
306
332
|
}
|
|
307
|
-
if (entry.type
|
|
308
|
-
|
|
309
|
-
|
|
333
|
+
if (entry.type === 'message' && entry.message) {
|
|
334
|
+
const content = flattenPiContent(entry.message.content);
|
|
335
|
+
messages.push({ id: entry.id ?? '', role: entry.message.role ?? 'unknown', content });
|
|
336
|
+
} else if (entry.type === 'custom_message') {
|
|
337
|
+
messages.push({
|
|
338
|
+
id: entry.id ?? '',
|
|
339
|
+
role: 'user',
|
|
340
|
+
content: flattenPiContent(entry.content),
|
|
341
|
+
});
|
|
342
|
+
} else if (entry.type === 'branch_summary') {
|
|
343
|
+
messages.push({
|
|
344
|
+
id: entry.id ?? '',
|
|
345
|
+
role: 'user',
|
|
346
|
+
content: `${BRANCH_SUMMARY_PREFIX}${entry.summary ?? ''}${BRANCH_SUMMARY_SUFFIX}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
310
349
|
}
|
|
311
350
|
return messages;
|
|
312
351
|
}
|
|
@@ -324,7 +363,8 @@ function parseMemoryHits(output: string): GoosedumpMemoryHit[] {
|
|
|
324
363
|
typeof item.provider === 'string' &&
|
|
325
364
|
typeof item.context_id === 'string' &&
|
|
326
365
|
typeof item.entry_id === 'string' &&
|
|
327
|
-
typeof item.path === 'string'
|
|
366
|
+
typeof item.path === 'string' &&
|
|
367
|
+
typeof item.source_path === 'string',
|
|
328
368
|
);
|
|
329
369
|
}
|
|
330
370
|
|
|
@@ -332,7 +372,7 @@ function formatMemoryHits(hits: GoosedumpMemoryHit[]): string {
|
|
|
332
372
|
return hits
|
|
333
373
|
.map(
|
|
334
374
|
(hit) =>
|
|
335
|
-
`[${hit.hash}] ${hit.kind} (${hit.provider}:${hit.context_id}/${hit.entry_id})\n${hit.text}`,
|
|
375
|
+
`[${hit.hash}] ${hit.kind} (${hit.provider}:${hit.context_id}/${hit.entry_id})\nWorking directory: ${hit.path}\nSource: ${hit.source_path}\n${hit.text}`,
|
|
336
376
|
)
|
|
337
377
|
.join('\n\n---\n\n');
|
|
338
378
|
}
|
|
@@ -394,7 +434,7 @@ function runGoosedump(args: string[]): string {
|
|
|
394
434
|
// Non-blocking variant for the compact path: goosedump compact runs an ML
|
|
395
435
|
// model and can take many seconds, so execFileSync would freeze Pi's event
|
|
396
436
|
// loop (and its compaction spinner). execFile keeps the loop free to render.
|
|
397
|
-
function runGoosedumpAsync(args: string[], signal?: AbortSignal): Promise<
|
|
437
|
+
function runGoosedumpAsync(args: string[], signal?: AbortSignal): Promise<GoosedumpCommandOutput> {
|
|
398
438
|
return new Promise((resolve, reject) => {
|
|
399
439
|
execFile(
|
|
400
440
|
process.execPath,
|
|
@@ -409,7 +449,7 @@ function runGoosedumpAsync(args: string[], signal?: AbortSignal): Promise<string
|
|
|
409
449
|
const message = stderr.trim() || err.message;
|
|
410
450
|
reject(new Error(message));
|
|
411
451
|
} else {
|
|
412
|
-
resolve(stdout);
|
|
452
|
+
resolve({ stdout, stderr });
|
|
413
453
|
}
|
|
414
454
|
},
|
|
415
455
|
);
|
|
@@ -475,12 +515,12 @@ async function goosedumpRecall(
|
|
|
475
515
|
query: string,
|
|
476
516
|
options: { provider?: string; path?: string; limit?: number } = {},
|
|
477
517
|
signal?: AbortSignal,
|
|
478
|
-
): Promise<GoosedumpMemoryHit[]> {
|
|
518
|
+
): Promise<{ hits: GoosedumpMemoryHit[]; warning: string }> {
|
|
479
519
|
const args = ['recall', query, '--limit', String(options.limit ?? 5)];
|
|
480
520
|
if (options.provider) args.push('--provider', options.provider);
|
|
481
521
|
if (options.path) args.push('--path', options.path);
|
|
482
522
|
const output = await runGoosedumpAsync(args, signal);
|
|
483
|
-
return parseMemoryHits(output);
|
|
523
|
+
return { hits: parseMemoryHits(output.stdout), warning: output.stderr.trim() };
|
|
484
524
|
}
|
|
485
525
|
|
|
486
526
|
// Resolve a goosedump 0.9 `provider:id` target. An explicit contextId is the
|
|
@@ -537,23 +577,25 @@ async function goosedumpCompact(
|
|
|
537
577
|
target: string,
|
|
538
578
|
options: { scope?: string; from?: string; until: string },
|
|
539
579
|
signal?: AbortSignal,
|
|
540
|
-
): Promise<string> {
|
|
580
|
+
): Promise<{ summary: string; warning: string }> {
|
|
541
581
|
const scope = options.scope ?? 'lineage';
|
|
542
582
|
const args = ['compact', target, '--scope', scope];
|
|
543
583
|
if (options.from) args.push('--from', options.from);
|
|
544
584
|
args.push('--until', options.until);
|
|
545
585
|
|
|
546
586
|
const output = await runGoosedumpAsync(args, signal);
|
|
547
|
-
for (const line of output.split('\n')) {
|
|
587
|
+
for (const line of output.stdout.split('\n')) {
|
|
548
588
|
if (!line) continue;
|
|
549
589
|
try {
|
|
550
590
|
const entry = JSON.parse(line) as PiEntryJson;
|
|
551
|
-
if (entry.type === 'compaction' && typeof entry.summary === 'string')
|
|
591
|
+
if (entry.type === 'compaction' && typeof entry.summary === 'string') {
|
|
592
|
+
return { summary: entry.summary, warning: output.stderr.trim() };
|
|
593
|
+
}
|
|
552
594
|
} catch {
|
|
553
595
|
continue;
|
|
554
596
|
}
|
|
555
597
|
}
|
|
556
|
-
return output.trim();
|
|
598
|
+
return { summary: output.stdout.trim(), warning: output.stderr.trim() };
|
|
557
599
|
}
|
|
558
600
|
|
|
559
601
|
export interface GoosedumpIntegrationOptions {
|
|
@@ -574,6 +616,7 @@ interface GoosedumpCompactionDetails {
|
|
|
574
616
|
from?: string;
|
|
575
617
|
until: string;
|
|
576
618
|
fileOps: unknown;
|
|
619
|
+
warning?: string;
|
|
577
620
|
}
|
|
578
621
|
|
|
579
622
|
export interface GoosedumpIntegration {
|
|
@@ -610,11 +653,19 @@ function isCompactionEntry(entry: SessionEntry): entry is CompactionEntry {
|
|
|
610
653
|
|
|
611
654
|
function isMessageEntry(entry: SessionEntry): entry is SessionEntry & {
|
|
612
655
|
type: 'message';
|
|
613
|
-
message: { role?: string };
|
|
656
|
+
message: { role?: string; stopReason?: string };
|
|
614
657
|
} {
|
|
615
658
|
return entry.type === 'message';
|
|
616
659
|
}
|
|
617
660
|
|
|
661
|
+
function isCompletedAssistant(message: { role?: string; stopReason?: string }): boolean {
|
|
662
|
+
return (
|
|
663
|
+
message.role === 'assistant' &&
|
|
664
|
+
message.stopReason !== 'error' &&
|
|
665
|
+
message.stopReason !== 'aborted'
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
618
669
|
// Entry types that sessionEntryToContextMessages turns into context messages;
|
|
619
670
|
// only these count toward "is there anything to summarize".
|
|
620
671
|
const SUMMARIZABLE_ENTRY_TYPES = new Set(['message', 'custom_message', 'branch_summary']);
|
|
@@ -631,6 +682,38 @@ function piKeepRecentTokens(cwd: string): number {
|
|
|
631
682
|
return DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
|
|
632
683
|
}
|
|
633
684
|
|
|
685
|
+
function piCompactionEnabled(cwd: string): boolean {
|
|
686
|
+
for (const path of [projectSettingsPath(cwd), globalSettingsPath()]) {
|
|
687
|
+
const compaction = readJsonObject(path).compaction;
|
|
688
|
+
if (isRecord(compaction) && typeof compaction.enabled === 'boolean') {
|
|
689
|
+
return compaction.enabled;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return DEFAULT_COMPACTION_SETTINGS.enabled;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function piReserveTokens(cwd: string): number {
|
|
696
|
+
for (const path of [projectSettingsPath(cwd), globalSettingsPath()]) {
|
|
697
|
+
const compaction = readJsonObject(path).compaction;
|
|
698
|
+
if (isRecord(compaction) && typeof compaction.reserveTokens === 'number') {
|
|
699
|
+
return compaction.reserveTokens;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return DEFAULT_COMPACTION_SETTINGS.reserveTokens;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const SUMMARY_TOKEN_BUDGET_RATIO = 0.8;
|
|
706
|
+
const OMITTED_SUMMARY_CONTEXT = '\n\n[Earlier compaction context omitted]\n\n';
|
|
707
|
+
|
|
708
|
+
function piAutoCompactionPending(ctx: ExtensionContext): boolean {
|
|
709
|
+
const usage = ctx.getContextUsage();
|
|
710
|
+
if (!usage || usage.tokens === null) return false;
|
|
711
|
+
|
|
712
|
+
return (
|
|
713
|
+
piCompactionEnabled(ctx.cwd) && usage.tokens > usage.contextWindow - piReserveTokens(ctx.cwd)
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
|
|
634
717
|
// Predict whether ctx.compact() would throw "Nothing to compact" or "Already
|
|
635
718
|
// compacted": mirrors prepareCompaction's guards over the active lineage so
|
|
636
719
|
// plugin-triggered compaction can skip the call instead of surfacing pi's
|
|
@@ -694,7 +777,7 @@ function completedTurnsSinceCompaction(branchEntries: SessionEntry[]): number {
|
|
|
694
777
|
let completed = 0;
|
|
695
778
|
for (let i = start; i < branchEntries.length; i += 1) {
|
|
696
779
|
const entry = branchEntries[i];
|
|
697
|
-
if (isMessageEntry(entry) && entry.message
|
|
780
|
+
if (isMessageEntry(entry) && isCompletedAssistant(entry.message)) completed += 1;
|
|
698
781
|
}
|
|
699
782
|
return completed;
|
|
700
783
|
}
|
|
@@ -753,12 +836,59 @@ function buildCompactRange(
|
|
|
753
836
|
: { scope: 'lineage', until: firstKeptEntryId };
|
|
754
837
|
}
|
|
755
838
|
|
|
839
|
+
function utf8Prefix(text: string, maxBytes: number): string {
|
|
840
|
+
const prefix: string[] = [];
|
|
841
|
+
let bytes = 0;
|
|
842
|
+
for (const char of text) {
|
|
843
|
+
const charBytes = Buffer.byteLength(char);
|
|
844
|
+
if (bytes + charBytes > maxBytes) break;
|
|
845
|
+
prefix.push(char);
|
|
846
|
+
bytes += charBytes;
|
|
847
|
+
}
|
|
848
|
+
return prefix.join('');
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function utf8Suffix(text: string, maxBytes: number): string {
|
|
852
|
+
const suffix: string[] = [];
|
|
853
|
+
let bytes = 0;
|
|
854
|
+
for (const char of Array.from(text).reverse()) {
|
|
855
|
+
const charBytes = Buffer.byteLength(char);
|
|
856
|
+
if (bytes + charBytes > maxBytes) break;
|
|
857
|
+
suffix.push(char);
|
|
858
|
+
bytes += charBytes;
|
|
859
|
+
}
|
|
860
|
+
return suffix.reverse().join('');
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function truncateSummary(summary: string, maxBytes: number): string {
|
|
864
|
+
const text = summary.trim();
|
|
865
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
866
|
+
|
|
867
|
+
const markerBytes = Buffer.byteLength(OMITTED_SUMMARY_CONTEXT);
|
|
868
|
+
if (maxBytes <= markerBytes) return utf8Prefix(text, maxBytes);
|
|
869
|
+
|
|
870
|
+
const retainedBytes = maxBytes - markerBytes;
|
|
871
|
+
const headBytes = Math.ceil(retainedBytes / 2);
|
|
872
|
+
const tailBytes = Math.floor(retainedBytes / 2);
|
|
873
|
+
return `${utf8Prefix(text, headBytes)}${OMITTED_SUMMARY_CONTEXT}${utf8Suffix(text, tailBytes)}`;
|
|
874
|
+
}
|
|
875
|
+
|
|
756
876
|
function mergeCompactionSummary(
|
|
757
877
|
previousSummary: string | undefined,
|
|
758
878
|
generatedSummary: string,
|
|
879
|
+
reserveTokens: number,
|
|
759
880
|
): string {
|
|
881
|
+
// A token always represents at least one UTF-8 byte. Capping bytes is
|
|
882
|
+
// conservative, but unlike a chars/token estimate it cannot undercount text.
|
|
883
|
+
const maxBytes = Math.max(1, Math.floor(reserveTokens * SUMMARY_TOKEN_BUDGET_RATIO));
|
|
884
|
+
const generated = truncateSummary(generatedSummary, maxBytes);
|
|
760
885
|
const previous = previousSummary?.trim();
|
|
761
|
-
|
|
886
|
+
const separator = '\n\n';
|
|
887
|
+
const generatedBytes = Buffer.byteLength(generated);
|
|
888
|
+
if (!previous || generatedBytes + Buffer.byteLength(separator) >= maxBytes) return generated;
|
|
889
|
+
|
|
890
|
+
const previousBytes = maxBytes - generatedBytes - Buffer.byteLength(separator);
|
|
891
|
+
return `${truncateSummary(previous, previousBytes)}${separator}${generated}`;
|
|
762
892
|
}
|
|
763
893
|
|
|
764
894
|
async function buildGoosedumpCompaction(
|
|
@@ -770,6 +900,7 @@ async function buildGoosedumpCompaction(
|
|
|
770
900
|
fileOps: unknown;
|
|
771
901
|
},
|
|
772
902
|
branchEntries: SessionEntry[],
|
|
903
|
+
reserveTokens: number,
|
|
773
904
|
keep?: number,
|
|
774
905
|
signal?: AbortSignal,
|
|
775
906
|
): Promise<
|
|
@@ -789,9 +920,13 @@ async function buildGoosedumpCompaction(
|
|
|
789
920
|
const range = buildCompactRange(branchEntries, firstKeptEntryId);
|
|
790
921
|
if (!range) return undefined;
|
|
791
922
|
|
|
792
|
-
const
|
|
793
|
-
if (!
|
|
794
|
-
const summary = mergeCompactionSummary(
|
|
923
|
+
const generated = await goosedumpCompact(target, range, signal);
|
|
924
|
+
if (!generated.summary) return undefined;
|
|
925
|
+
const summary = mergeCompactionSummary(
|
|
926
|
+
preparation.previousSummary,
|
|
927
|
+
generated.summary,
|
|
928
|
+
reserveTokens,
|
|
929
|
+
);
|
|
795
930
|
|
|
796
931
|
return {
|
|
797
932
|
summary,
|
|
@@ -804,6 +939,7 @@ async function buildGoosedumpCompaction(
|
|
|
804
939
|
from: range.from,
|
|
805
940
|
until: range.until,
|
|
806
941
|
fileOps: preparation.fileOps,
|
|
942
|
+
warning: generated.warning || undefined,
|
|
807
943
|
},
|
|
808
944
|
};
|
|
809
945
|
}
|
|
@@ -924,11 +1060,7 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
|
|
|
924
1060
|
const projectPath = projectSettingsPath(location);
|
|
925
1061
|
const projectRaw = readJsonObjectStrict(projectPath).goosedump;
|
|
926
1062
|
const projectValue =
|
|
927
|
-
isRecord(projectRaw) &&
|
|
928
|
-
typeof projectRaw.turnsBeforeCompact === 'number' &&
|
|
929
|
-
Number.isSafeInteger(projectRaw.turnsBeforeCompact) &&
|
|
930
|
-
projectRaw.turnsBeforeCompact >= TURNS_BEFORE_COMPACT_MIN &&
|
|
931
|
-
projectRaw.turnsBeforeCompact <= TURNS_BEFORE_COMPACT_MAX
|
|
1063
|
+
isRecord(projectRaw) && isTurnsBeforeCompact(projectRaw.turnsBeforeCompact)
|
|
932
1064
|
? projectRaw.turnsBeforeCompact
|
|
933
1065
|
: undefined;
|
|
934
1066
|
|
|
@@ -979,12 +1111,7 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
|
|
|
979
1111
|
}
|
|
980
1112
|
|
|
981
1113
|
const parsed = Number(newValue);
|
|
982
|
-
if (
|
|
983
|
-
!Number.isSafeInteger(parsed) ||
|
|
984
|
-
parsed < TURNS_BEFORE_COMPACT_MIN ||
|
|
985
|
-
parsed > TURNS_BEFORE_COMPACT_MAX
|
|
986
|
-
)
|
|
987
|
-
return;
|
|
1114
|
+
if (!isTurnsBeforeCompact(parsed)) return;
|
|
988
1115
|
try {
|
|
989
1116
|
writeScopedGoosedumpSetting(location, scope, key, parsed);
|
|
990
1117
|
settingsList.updateValue(id, newValue);
|
|
@@ -1278,6 +1405,7 @@ export function createGoosedumpIntegration(
|
|
|
1278
1405
|
let turnsRemaining = 0;
|
|
1279
1406
|
let counterTriggered = false;
|
|
1280
1407
|
let compactionQueued = false;
|
|
1408
|
+
let compactionHookActive = false;
|
|
1281
1409
|
let pendingKeep: number | null = null;
|
|
1282
1410
|
|
|
1283
1411
|
function syncCompactCounters(ctx: ExtensionContext, clearPending = false): void {
|
|
@@ -1328,7 +1456,7 @@ export function createGoosedumpIntegration(
|
|
|
1328
1456
|
ctx: ExtensionContext,
|
|
1329
1457
|
keep: number | null,
|
|
1330
1458
|
): 'started' | 'busy' | 'nothing-to-compact' {
|
|
1331
|
-
if (compactionQueued) return 'busy';
|
|
1459
|
+
if (compactionQueued || compactionHookActive) return 'busy';
|
|
1332
1460
|
if (keep === null && !hasCompactableHistory(ctx)) return 'nothing-to-compact';
|
|
1333
1461
|
|
|
1334
1462
|
compactionQueued = true;
|
|
@@ -1390,7 +1518,7 @@ export function createGoosedumpIntegration(
|
|
|
1390
1518
|
async execute(_id, params, signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
|
|
1391
1519
|
if (!goosedumpAvailable) return goosedumpUnavailableResult();
|
|
1392
1520
|
try {
|
|
1393
|
-
const hits = await goosedumpRecall(
|
|
1521
|
+
const { hits, warning } = await goosedumpRecall(
|
|
1394
1522
|
params.query,
|
|
1395
1523
|
{
|
|
1396
1524
|
provider: params.provider,
|
|
@@ -1399,6 +1527,7 @@ export function createGoosedumpIntegration(
|
|
|
1399
1527
|
},
|
|
1400
1528
|
signal,
|
|
1401
1529
|
);
|
|
1530
|
+
if (warning && ctx.hasUI) ctx.ui.notify(warning, 'warning');
|
|
1402
1531
|
if (hits.length === 0) return textResult(`No durable memory for "${params.query}"`);
|
|
1403
1532
|
return textResult(formatMemoryHits(hits));
|
|
1404
1533
|
} catch (err) {
|
|
@@ -1601,6 +1730,10 @@ export function createGoosedumpIntegration(
|
|
|
1601
1730
|
if (!counterTriggered || compactionQueued) return;
|
|
1602
1731
|
if (ctx.hasPendingMessages()) return;
|
|
1603
1732
|
if (!hasCompactableHistory(ctx)) return;
|
|
1733
|
+
// Pi checks this threshold immediately after agent_end. Let that native
|
|
1734
|
+
// path invoke our session_before_compact hook instead of starting a
|
|
1735
|
+
// competing manual compaction.
|
|
1736
|
+
if (!resumeAfter && piAutoCompactionPending(ctx)) return;
|
|
1604
1737
|
|
|
1605
1738
|
syncCompactCounters(ctx, true);
|
|
1606
1739
|
updateStatus(ctx);
|
|
@@ -1640,7 +1773,7 @@ export function createGoosedumpIntegration(
|
|
|
1640
1773
|
});
|
|
1641
1774
|
|
|
1642
1775
|
pi.on('turn_end', async (event, ctx) => {
|
|
1643
|
-
noteCompletedTurn(ctx);
|
|
1776
|
+
if (isCompletedAssistant(event.message)) noteCompletedTurn(ctx);
|
|
1644
1777
|
// A turn with tool results means the agent loop would keep going; compact
|
|
1645
1778
|
// between turns and resume so the run's progress is not lost.
|
|
1646
1779
|
if (event.toolResults.length > 0) maybeAutoCompact(ctx, true);
|
|
@@ -1667,12 +1800,19 @@ export function createGoosedumpIntegration(
|
|
|
1667
1800
|
const target = currentSessionTarget(ctx);
|
|
1668
1801
|
if (!target) return;
|
|
1669
1802
|
|
|
1803
|
+
// Pi checks reserveTokens immediately after agent_end. A turn-driven
|
|
1804
|
+
// ctx.compact() can reach this hook at the same time; let the first
|
|
1805
|
+
// compaction proceed and cancel the duplicate before both append entries.
|
|
1806
|
+
if (compactionHookActive) return { cancel: true };
|
|
1807
|
+
compactionHookActive = true;
|
|
1808
|
+
|
|
1670
1809
|
try {
|
|
1671
1810
|
const keep = pendingKeep ?? undefined;
|
|
1672
1811
|
const compaction = await buildGoosedumpCompaction(
|
|
1673
1812
|
target,
|
|
1674
1813
|
event.preparation,
|
|
1675
1814
|
event.branchEntries,
|
|
1815
|
+
piReserveTokens(ctx.cwd),
|
|
1676
1816
|
keep,
|
|
1677
1817
|
event.signal,
|
|
1678
1818
|
);
|
|
@@ -1680,6 +1820,10 @@ export function createGoosedumpIntegration(
|
|
|
1680
1820
|
if (ctx.hasUI) ctx.ui.notify(compaction.reason, 'info');
|
|
1681
1821
|
return { cancel: true };
|
|
1682
1822
|
}
|
|
1823
|
+
const warning = compaction?.details?.warning;
|
|
1824
|
+
if (warning && ctx.hasUI) {
|
|
1825
|
+
ctx.ui.notify(warning, 'warning');
|
|
1826
|
+
}
|
|
1683
1827
|
return compaction ? { compaction } : undefined;
|
|
1684
1828
|
} catch (err) {
|
|
1685
1829
|
if (event.signal.aborted) return { cancel: true };
|
|
@@ -1692,6 +1836,7 @@ export function createGoosedumpIntegration(
|
|
|
1692
1836
|
}
|
|
1693
1837
|
return undefined;
|
|
1694
1838
|
} finally {
|
|
1839
|
+
compactionHookActive = false;
|
|
1695
1840
|
pendingKeep = null;
|
|
1696
1841
|
}
|
|
1697
1842
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goosedump",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"description": "Pi extension for goosedump-backed session-history search, entry expansion, and compaction",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"compaction",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@earendil-works/pi-tui": "^0.78.0",
|
|
36
|
-
"@jarkkojs/goosedump": "^0.10.
|
|
36
|
+
"@jarkkojs/goosedump": "^0.10.6",
|
|
37
37
|
"@sinclair/typebox": "^0.34.49"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|