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.
Files changed (3) hide show
  1. package/README.md +54 -74
  2. package/index.ts +201 -56
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -1,69 +1,62 @@
1
1
  # pi-goosedump
2
2
 
3
- `pi-goosedump` is a pi extension for goosedump-backed session-history search,
4
- entry expansion, and compaction. It exposes goosedump tools under the `goose_`
5
- prefix and replaces Pi's default LLM compaction with goosedump-generated
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
- ## Installation
7
+ ## Install
9
8
 
10
- ```bash
9
+ ```sh
11
10
  pi install npm:pi-goosedump
12
11
  ```
13
12
 
14
- This installs `pi-goosedump` and its main dependency `@jarkkojs/goosedump`,
15
- which includes platform-specific native binaries for Linux x64, macOS arm64,
16
- and Windows x64.
17
- If the goosedump binary cannot be resolved, the extension disables itself.
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
- - **goose_search:** ranks messages by query relevance; returns compact entry
22
- overviews with IDs.
23
- - **goose_recall:** searches durable memory harvested from earlier compactions,
24
- scoped to the current working directory by default.
25
- - **goose_get:** expands specific entry IDs to their full content.
26
- - **goose_grep:** filters messages by glob pattern; returns compact entries
27
- with IDs.
28
- - **goose_compact:** triggers a goosedump compaction now, optionally keeping
29
- the last N user turns verbatim while summarizing everything before.
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
- goose_get({ ids: ["entry-a"], provider: "claude", session: "1f6b1eba-…" })
44
- goose_grep({ pattern: "*rand*" })
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]`:** search the current session history; accepts a
51
- query argument or prompts inline when bare. Shows ranked results in a
52
- scrollable panel; press enter to expand an entry to its full content.
53
- - **`/goose-compact [keep:N]`:** compact now with goosedump. With `keep:N`
54
- (N ≥ 1) the last N user turns stay verbatim and everything before is
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
- `pi-goosedump` reads optional JSON settings from:
56
+ Optional settings live under `goosedump` in:
61
57
 
62
- - `~/.pi/agent/settings.json` — Global
63
- - `.pi/settings.json` — Project
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` sets how many LLM turns elapse before goosedump triggers
77
- compaction, as an integer 0–255. `0` disables the counter. The settings editor
78
- can clear the project value to inherit Global again, and writes updates atomically.
79
- The countdown is reconstructed from the active session after Pi restarts or
80
- resumes, rather than restarting at the configured value. When enabled, the footer
81
- shows `GD: XX` counting down in hex (e.g. `GD: 0A`); `GD: 00` is armed and waiting
82
- to compact, while `GD: --` means disabled.
83
-
84
- ### Compaction flow
85
-
86
- pi-goosedump hooks Pi's `session_before_compact` event — fired by manual
87
- `/compact`, the turn counter above, and Pi's own auto-compaction — and
88
- substitutes a goosedump summary for Pi's LLM summary. It resolves the current
89
- session to its goosedump context by reading the `id` field from the session
90
- header line, then runs `goosedump compact` over the replacement range:
91
-
92
- - `--from` is the entry following the most recent prior compaction (so the
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) for more
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
- const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
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
- readJsonObjectStrict(path);
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 0.9 renders `show`/`grep`/`search` in a provider's native format.
294
- // Passing `--as-provider pi` normalizes every provider's output to pi's JSONL,
295
- // so one parser covers all sources: each `{"type":"message"}` line becomes a
296
- // `GoosedumpMessage` with its `content[]` parts flattened to readable text.
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 !== 'message' || !entry.message) continue;
308
- const content = flattenPiContent(entry.message.content);
309
- messages.push({ id: entry.id ?? '', role: entry.message.role ?? 'unknown', content });
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<string> {
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') return entry.summary;
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.role === 'assistant') completed += 1;
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
- return previous ? `${previous}\n\n${generatedSummary}` : generatedSummary;
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 generatedSummary = await goosedumpCompact(target, range, signal);
793
- if (!generatedSummary) return undefined;
794
- const summary = mergeCompactionSummary(preparation.previousSummary, generatedSummary);
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.4",
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.4",
36
+ "@jarkkojs/goosedump": "^0.10.6",
37
37
  "@sinclair/typebox": "^0.34.49"
38
38
  },
39
39
  "devDependencies": {