pi-goosedump 0.10.5 → 0.10.7

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 (2) hide show
  1. package/index.ts +201 -45
  2. package/package.json +2 -2
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 {
@@ -91,6 +94,7 @@ interface PiEntryJson {
91
94
  type?: string;
92
95
  id?: string;
93
96
  message?: { role?: string; content?: PiPartJson[] | string };
97
+ content?: PiPartJson[] | string;
94
98
  summary?: string;
95
99
  }
96
100
 
@@ -108,6 +112,15 @@ const DEFAULT_GOOSEDUMP_SETTINGS: GoosedumpSettings = {
108
112
  const TURNS_BEFORE_COMPACT_MIN = 0;
109
113
  const TURNS_BEFORE_COMPACT_MAX = 255;
110
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
+
111
124
  function piAgentDir(): string {
112
125
  return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
113
126
  }
@@ -138,7 +151,13 @@ function readJsonObject(path: string): Record<string, unknown> {
138
151
  function readJsonObjectStrict(path: string): Record<string, unknown> {
139
152
  if (!existsSync(path)) return {};
140
153
 
141
- 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
+ }
142
161
  if (!isRecord(parsed)) throw new Error(`${path} must contain a JSON object`);
143
162
  return parsed;
144
163
  }
@@ -165,17 +184,26 @@ function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
165
184
 
166
185
  const settings: Partial<GoosedumpSettings> = {};
167
186
  const value = raw.turnsBeforeCompact;
168
- if (
169
- typeof value === 'number' &&
170
- Number.isSafeInteger(value) &&
171
- value >= TURNS_BEFORE_COMPACT_MIN &&
172
- value <= TURNS_BEFORE_COMPACT_MAX
173
- ) {
174
- settings.turnsBeforeCompact = value;
175
- }
187
+ if (isTurnsBeforeCompact(value)) settings.turnsBeforeCompact = value;
176
188
  return settings;
177
189
  }
178
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
+
179
207
  function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
180
208
  return {
181
209
  ...DEFAULT_GOOSEDUMP_SETTINGS,
@@ -187,7 +215,7 @@ function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
187
215
  function notifyInvalidSettings(ctx: ExtensionContext): void {
188
216
  for (const path of [globalSettingsPath(), projectSettingsPath(ctx.cwd)]) {
189
217
  try {
190
- readJsonObjectStrict(path);
218
+ validateGoosedumpSettings(path);
191
219
  } catch (error) {
192
220
  const message = error instanceof Error ? error.message : String(error);
193
221
  if (ctx.hasUI) ctx.ui.notify(`goosedump settings error: ${message}`, 'error');
@@ -202,14 +230,7 @@ function readScopedGoosedumpSettings(cwd: string, scope: SettingsScope): Goosedu
202
230
 
203
231
  const settings: Partial<GoosedumpSettings> = {};
204
232
  const value = raw.turnsBeforeCompact;
205
- if (
206
- typeof value === 'number' &&
207
- Number.isSafeInteger(value) &&
208
- value >= TURNS_BEFORE_COMPACT_MIN &&
209
- value <= TURNS_BEFORE_COMPACT_MAX
210
- ) {
211
- settings.turnsBeforeCompact = value;
212
- }
233
+ if (isTurnsBeforeCompact(value)) settings.turnsBeforeCompact = value;
213
234
  return { ...DEFAULT_GOOSEDUMP_SETTINGS, ...settings };
214
235
  }
215
236
 
@@ -250,6 +271,33 @@ function resolveGoosedumpBinary(): string {
250
271
  return require.resolve('@jarkkojs/goosedump/bin/goosedump.js');
251
272
  }
252
273
 
274
+ function resolveNativeGoosedumpBinary(): string {
275
+ const target = `${process.platform}-${process.arch}`;
276
+ let packageName: string;
277
+ let binaryName = 'goosedump';
278
+
279
+ switch (target) {
280
+ case 'linux-x64':
281
+ packageName = '@jarkkojs/goosedump-linux-x64';
282
+ break;
283
+ case 'darwin-arm64':
284
+ packageName = '@jarkkojs/goosedump-darwin-arm64';
285
+ break;
286
+ case 'win32-x64':
287
+ packageName = '@jarkkojs/goosedump-win32-x64';
288
+ binaryName = 'goosedump.exe';
289
+ break;
290
+ default:
291
+ throw new Error(`unsupported platform architecture: ${target}`);
292
+ }
293
+
294
+ const wrapperRequire = createRequire(resolveGoosedumpBinary());
295
+ const packageDir = dirname(wrapperRequire.resolve(`${packageName}/package.json`));
296
+ const binaryPath = join(packageDir, 'bin', binaryName);
297
+ if (!existsSync(binaryPath)) throw new Error(`binary not found: ${binaryPath}`);
298
+ return binaryPath;
299
+ }
300
+
253
301
  function clip(value: string, max: number): string {
254
302
  return value.length > max ? `${value.slice(0, max)}...` : value;
255
303
  }
@@ -296,11 +344,10 @@ function flattenPiContent(content: PiPartJson[] | string | undefined): string {
296
344
  return content.map(flattenPiPart).filter(Boolean).join('\n');
297
345
  }
298
346
 
299
- // goosedump 0.9 renders `show`/`grep`/`search` in a provider's native format.
300
- // Passing `--as-provider pi` normalizes every provider's output to pi's JSONL,
301
- // so one parser covers all sources: each `{"type":"message"}` line becomes a
302
- // `GoosedumpMessage` with its `content[]` parts flattened to readable text.
303
- function parsePiMessages(output: string): GoosedumpMessage[] {
347
+ // goosedump renders `show`/`grep`/`search` in a provider's native format.
348
+ // Passing `--as-provider pi` normalizes every provider's output to Pi JSONL, so
349
+ // one parser covers all sources, including Pi-native context entries.
350
+ export function parsePiMessages(output: string): GoosedumpMessage[] {
304
351
  const messages: GoosedumpMessage[] = [];
305
352
  for (const line of output.split('\n')) {
306
353
  if (!line) continue;
@@ -310,9 +357,22 @@ function parsePiMessages(output: string): GoosedumpMessage[] {
310
357
  } catch {
311
358
  continue;
312
359
  }
313
- if (entry.type !== 'message' || !entry.message) continue;
314
- const content = flattenPiContent(entry.message.content);
315
- messages.push({ id: entry.id ?? '', role: entry.message.role ?? 'unknown', content });
360
+ if (entry.type === 'message' && entry.message) {
361
+ const content = flattenPiContent(entry.message.content);
362
+ messages.push({ id: entry.id ?? '', role: entry.message.role ?? 'unknown', content });
363
+ } else if (entry.type === 'custom_message') {
364
+ messages.push({
365
+ id: entry.id ?? '',
366
+ role: 'user',
367
+ content: flattenPiContent(entry.content),
368
+ });
369
+ } else if (entry.type === 'branch_summary') {
370
+ messages.push({
371
+ id: entry.id ?? '',
372
+ role: 'user',
373
+ content: `${BRANCH_SUMMARY_PREFIX}${entry.summary ?? ''}${BRANCH_SUMMARY_SUFFIX}`,
374
+ });
375
+ }
316
376
  }
317
377
  return messages;
318
378
  }
@@ -404,8 +464,8 @@ function runGoosedump(args: string[]): string {
404
464
  function runGoosedumpAsync(args: string[], signal?: AbortSignal): Promise<GoosedumpCommandOutput> {
405
465
  return new Promise((resolve, reject) => {
406
466
  execFile(
407
- process.execPath,
408
- [resolveGoosedumpBinary(), ...args],
467
+ resolveNativeGoosedumpBinary(),
468
+ args,
409
469
  {
410
470
  encoding: 'utf-8',
411
471
  maxBuffer: 16 * 1024 * 1024,
@@ -620,11 +680,19 @@ function isCompactionEntry(entry: SessionEntry): entry is CompactionEntry {
620
680
 
621
681
  function isMessageEntry(entry: SessionEntry): entry is SessionEntry & {
622
682
  type: 'message';
623
- message: { role?: string };
683
+ message: { role?: string; stopReason?: string };
624
684
  } {
625
685
  return entry.type === 'message';
626
686
  }
627
687
 
688
+ function isCompletedAssistant(message: { role?: string; stopReason?: string }): boolean {
689
+ return (
690
+ message.role === 'assistant' &&
691
+ message.stopReason !== 'error' &&
692
+ message.stopReason !== 'aborted'
693
+ );
694
+ }
695
+
628
696
  // Entry types that sessionEntryToContextMessages turns into context messages;
629
697
  // only these count toward "is there anything to summarize".
630
698
  const SUMMARIZABLE_ENTRY_TYPES = new Set(['message', 'custom_message', 'branch_summary']);
@@ -641,6 +709,38 @@ function piKeepRecentTokens(cwd: string): number {
641
709
  return DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
642
710
  }
643
711
 
712
+ function piCompactionEnabled(cwd: string): boolean {
713
+ for (const path of [projectSettingsPath(cwd), globalSettingsPath()]) {
714
+ const compaction = readJsonObject(path).compaction;
715
+ if (isRecord(compaction) && typeof compaction.enabled === 'boolean') {
716
+ return compaction.enabled;
717
+ }
718
+ }
719
+ return DEFAULT_COMPACTION_SETTINGS.enabled;
720
+ }
721
+
722
+ function piReserveTokens(cwd: string): number {
723
+ for (const path of [projectSettingsPath(cwd), globalSettingsPath()]) {
724
+ const compaction = readJsonObject(path).compaction;
725
+ if (isRecord(compaction) && typeof compaction.reserveTokens === 'number') {
726
+ return compaction.reserveTokens;
727
+ }
728
+ }
729
+ return DEFAULT_COMPACTION_SETTINGS.reserveTokens;
730
+ }
731
+
732
+ const SUMMARY_TOKEN_BUDGET_RATIO = 0.8;
733
+ const OMITTED_SUMMARY_CONTEXT = '\n\n[Earlier compaction context omitted]\n\n';
734
+
735
+ function piAutoCompactionPending(ctx: ExtensionContext): boolean {
736
+ const usage = ctx.getContextUsage();
737
+ if (!usage || usage.tokens === null) return false;
738
+
739
+ return (
740
+ piCompactionEnabled(ctx.cwd) && usage.tokens > usage.contextWindow - piReserveTokens(ctx.cwd)
741
+ );
742
+ }
743
+
644
744
  // Predict whether ctx.compact() would throw "Nothing to compact" or "Already
645
745
  // compacted": mirrors prepareCompaction's guards over the active lineage so
646
746
  // plugin-triggered compaction can skip the call instead of surfacing pi's
@@ -704,7 +804,7 @@ function completedTurnsSinceCompaction(branchEntries: SessionEntry[]): number {
704
804
  let completed = 0;
705
805
  for (let i = start; i < branchEntries.length; i += 1) {
706
806
  const entry = branchEntries[i];
707
- if (isMessageEntry(entry) && entry.message.role === 'assistant') completed += 1;
807
+ if (isMessageEntry(entry) && isCompletedAssistant(entry.message)) completed += 1;
708
808
  }
709
809
  return completed;
710
810
  }
@@ -763,12 +863,59 @@ function buildCompactRange(
763
863
  : { scope: 'lineage', until: firstKeptEntryId };
764
864
  }
765
865
 
866
+ function utf8Prefix(text: string, maxBytes: number): string {
867
+ const prefix: string[] = [];
868
+ let bytes = 0;
869
+ for (const char of text) {
870
+ const charBytes = Buffer.byteLength(char);
871
+ if (bytes + charBytes > maxBytes) break;
872
+ prefix.push(char);
873
+ bytes += charBytes;
874
+ }
875
+ return prefix.join('');
876
+ }
877
+
878
+ function utf8Suffix(text: string, maxBytes: number): string {
879
+ const suffix: string[] = [];
880
+ let bytes = 0;
881
+ for (const char of Array.from(text).reverse()) {
882
+ const charBytes = Buffer.byteLength(char);
883
+ if (bytes + charBytes > maxBytes) break;
884
+ suffix.push(char);
885
+ bytes += charBytes;
886
+ }
887
+ return suffix.reverse().join('');
888
+ }
889
+
890
+ function truncateSummary(summary: string, maxBytes: number): string {
891
+ const text = summary.trim();
892
+ if (Buffer.byteLength(text) <= maxBytes) return text;
893
+
894
+ const markerBytes = Buffer.byteLength(OMITTED_SUMMARY_CONTEXT);
895
+ if (maxBytes <= markerBytes) return utf8Prefix(text, maxBytes);
896
+
897
+ const retainedBytes = maxBytes - markerBytes;
898
+ const headBytes = Math.ceil(retainedBytes / 2);
899
+ const tailBytes = Math.floor(retainedBytes / 2);
900
+ return `${utf8Prefix(text, headBytes)}${OMITTED_SUMMARY_CONTEXT}${utf8Suffix(text, tailBytes)}`;
901
+ }
902
+
766
903
  function mergeCompactionSummary(
767
904
  previousSummary: string | undefined,
768
905
  generatedSummary: string,
906
+ reserveTokens: number,
769
907
  ): string {
908
+ // A token always represents at least one UTF-8 byte. Capping bytes is
909
+ // conservative, but unlike a chars/token estimate it cannot undercount text.
910
+ const maxBytes = Math.max(1, Math.floor(reserveTokens * SUMMARY_TOKEN_BUDGET_RATIO));
911
+ const generated = truncateSummary(generatedSummary, maxBytes);
770
912
  const previous = previousSummary?.trim();
771
- return previous ? `${previous}\n\n${generatedSummary}` : generatedSummary;
913
+ const separator = '\n\n';
914
+ const generatedBytes = Buffer.byteLength(generated);
915
+ if (!previous || generatedBytes + Buffer.byteLength(separator) >= maxBytes) return generated;
916
+
917
+ const previousBytes = maxBytes - generatedBytes - Buffer.byteLength(separator);
918
+ return `${truncateSummary(previous, previousBytes)}${separator}${generated}`;
772
919
  }
773
920
 
774
921
  async function buildGoosedumpCompaction(
@@ -780,6 +927,7 @@ async function buildGoosedumpCompaction(
780
927
  fileOps: unknown;
781
928
  },
782
929
  branchEntries: SessionEntry[],
930
+ reserveTokens: number,
783
931
  keep?: number,
784
932
  signal?: AbortSignal,
785
933
  ): Promise<
@@ -801,7 +949,11 @@ async function buildGoosedumpCompaction(
801
949
 
802
950
  const generated = await goosedumpCompact(target, range, signal);
803
951
  if (!generated.summary) return undefined;
804
- const summary = mergeCompactionSummary(preparation.previousSummary, generated.summary);
952
+ const summary = mergeCompactionSummary(
953
+ preparation.previousSummary,
954
+ generated.summary,
955
+ reserveTokens,
956
+ );
805
957
 
806
958
  return {
807
959
  summary,
@@ -935,11 +1087,7 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
935
1087
  const projectPath = projectSettingsPath(location);
936
1088
  const projectRaw = readJsonObjectStrict(projectPath).goosedump;
937
1089
  const projectValue =
938
- isRecord(projectRaw) &&
939
- typeof projectRaw.turnsBeforeCompact === 'number' &&
940
- Number.isSafeInteger(projectRaw.turnsBeforeCompact) &&
941
- projectRaw.turnsBeforeCompact >= TURNS_BEFORE_COMPACT_MIN &&
942
- projectRaw.turnsBeforeCompact <= TURNS_BEFORE_COMPACT_MAX
1090
+ isRecord(projectRaw) && isTurnsBeforeCompact(projectRaw.turnsBeforeCompact)
943
1091
  ? projectRaw.turnsBeforeCompact
944
1092
  : undefined;
945
1093
 
@@ -990,12 +1138,7 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
990
1138
  }
991
1139
 
992
1140
  const parsed = Number(newValue);
993
- if (
994
- !Number.isSafeInteger(parsed) ||
995
- parsed < TURNS_BEFORE_COMPACT_MIN ||
996
- parsed > TURNS_BEFORE_COMPACT_MAX
997
- )
998
- return;
1141
+ if (!isTurnsBeforeCompact(parsed)) return;
999
1142
  try {
1000
1143
  writeScopedGoosedumpSetting(location, scope, key, parsed);
1001
1144
  settingsList.updateValue(id, newValue);
@@ -1289,6 +1432,7 @@ export function createGoosedumpIntegration(
1289
1432
  let turnsRemaining = 0;
1290
1433
  let counterTriggered = false;
1291
1434
  let compactionQueued = false;
1435
+ let compactionHookActive = false;
1292
1436
  let pendingKeep: number | null = null;
1293
1437
 
1294
1438
  function syncCompactCounters(ctx: ExtensionContext, clearPending = false): void {
@@ -1339,7 +1483,7 @@ export function createGoosedumpIntegration(
1339
1483
  ctx: ExtensionContext,
1340
1484
  keep: number | null,
1341
1485
  ): 'started' | 'busy' | 'nothing-to-compact' {
1342
- if (compactionQueued) return 'busy';
1486
+ if (compactionQueued || compactionHookActive) return 'busy';
1343
1487
  if (keep === null && !hasCompactableHistory(ctx)) return 'nothing-to-compact';
1344
1488
 
1345
1489
  compactionQueued = true;
@@ -1613,6 +1757,10 @@ export function createGoosedumpIntegration(
1613
1757
  if (!counterTriggered || compactionQueued) return;
1614
1758
  if (ctx.hasPendingMessages()) return;
1615
1759
  if (!hasCompactableHistory(ctx)) return;
1760
+ // Pi checks this threshold immediately after agent_end. Let that native
1761
+ // path invoke our session_before_compact hook instead of starting a
1762
+ // competing manual compaction.
1763
+ if (!resumeAfter && piAutoCompactionPending(ctx)) return;
1616
1764
 
1617
1765
  syncCompactCounters(ctx, true);
1618
1766
  updateStatus(ctx);
@@ -1652,7 +1800,7 @@ export function createGoosedumpIntegration(
1652
1800
  });
1653
1801
 
1654
1802
  pi.on('turn_end', async (event, ctx) => {
1655
- noteCompletedTurn(ctx);
1803
+ if (isCompletedAssistant(event.message)) noteCompletedTurn(ctx);
1656
1804
  // A turn with tool results means the agent loop would keep going; compact
1657
1805
  // between turns and resume so the run's progress is not lost.
1658
1806
  if (event.toolResults.length > 0) maybeAutoCompact(ctx, true);
@@ -1679,12 +1827,19 @@ export function createGoosedumpIntegration(
1679
1827
  const target = currentSessionTarget(ctx);
1680
1828
  if (!target) return;
1681
1829
 
1830
+ // Pi checks reserveTokens immediately after agent_end. A turn-driven
1831
+ // ctx.compact() can reach this hook at the same time; let the first
1832
+ // compaction proceed and cancel the duplicate before both append entries.
1833
+ if (compactionHookActive) return { cancel: true };
1834
+ compactionHookActive = true;
1835
+
1682
1836
  try {
1683
1837
  const keep = pendingKeep ?? undefined;
1684
1838
  const compaction = await buildGoosedumpCompaction(
1685
1839
  target,
1686
1840
  event.preparation,
1687
1841
  event.branchEntries,
1842
+ piReserveTokens(ctx.cwd),
1688
1843
  keep,
1689
1844
  event.signal,
1690
1845
  );
@@ -1708,6 +1863,7 @@ export function createGoosedumpIntegration(
1708
1863
  }
1709
1864
  return undefined;
1710
1865
  } finally {
1866
+ compactionHookActive = false;
1711
1867
  pendingKeep = null;
1712
1868
  }
1713
1869
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.10.5",
3
+ "version": "0.10.7",
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.5",
36
+ "@jarkkojs/goosedump": "^0.10.7",
37
37
  "@sinclair/typebox": "^0.34.49"
38
38
  },
39
39
  "devDependencies": {