pi-goosedump 0.10.5 → 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 (2) hide show
  1. package/index.ts +172 -43
  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
 
@@ -296,11 +317,10 @@ function flattenPiContent(content: PiPartJson[] | string | undefined): string {
296
317
  return content.map(flattenPiPart).filter(Boolean).join('\n');
297
318
  }
298
319
 
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[] {
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[] {
304
324
  const messages: GoosedumpMessage[] = [];
305
325
  for (const line of output.split('\n')) {
306
326
  if (!line) continue;
@@ -310,9 +330,22 @@ function parsePiMessages(output: string): GoosedumpMessage[] {
310
330
  } catch {
311
331
  continue;
312
332
  }
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 });
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
+ }
316
349
  }
317
350
  return messages;
318
351
  }
@@ -620,11 +653,19 @@ function isCompactionEntry(entry: SessionEntry): entry is CompactionEntry {
620
653
 
621
654
  function isMessageEntry(entry: SessionEntry): entry is SessionEntry & {
622
655
  type: 'message';
623
- message: { role?: string };
656
+ message: { role?: string; stopReason?: string };
624
657
  } {
625
658
  return entry.type === 'message';
626
659
  }
627
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
+
628
669
  // Entry types that sessionEntryToContextMessages turns into context messages;
629
670
  // only these count toward "is there anything to summarize".
630
671
  const SUMMARIZABLE_ENTRY_TYPES = new Set(['message', 'custom_message', 'branch_summary']);
@@ -641,6 +682,38 @@ function piKeepRecentTokens(cwd: string): number {
641
682
  return DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
642
683
  }
643
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
+
644
717
  // Predict whether ctx.compact() would throw "Nothing to compact" or "Already
645
718
  // compacted": mirrors prepareCompaction's guards over the active lineage so
646
719
  // plugin-triggered compaction can skip the call instead of surfacing pi's
@@ -704,7 +777,7 @@ function completedTurnsSinceCompaction(branchEntries: SessionEntry[]): number {
704
777
  let completed = 0;
705
778
  for (let i = start; i < branchEntries.length; i += 1) {
706
779
  const entry = branchEntries[i];
707
- if (isMessageEntry(entry) && entry.message.role === 'assistant') completed += 1;
780
+ if (isMessageEntry(entry) && isCompletedAssistant(entry.message)) completed += 1;
708
781
  }
709
782
  return completed;
710
783
  }
@@ -763,12 +836,59 @@ function buildCompactRange(
763
836
  : { scope: 'lineage', until: firstKeptEntryId };
764
837
  }
765
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
+
766
876
  function mergeCompactionSummary(
767
877
  previousSummary: string | undefined,
768
878
  generatedSummary: string,
879
+ reserveTokens: number,
769
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);
770
885
  const previous = previousSummary?.trim();
771
- 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}`;
772
892
  }
773
893
 
774
894
  async function buildGoosedumpCompaction(
@@ -780,6 +900,7 @@ async function buildGoosedumpCompaction(
780
900
  fileOps: unknown;
781
901
  },
782
902
  branchEntries: SessionEntry[],
903
+ reserveTokens: number,
783
904
  keep?: number,
784
905
  signal?: AbortSignal,
785
906
  ): Promise<
@@ -801,7 +922,11 @@ async function buildGoosedumpCompaction(
801
922
 
802
923
  const generated = await goosedumpCompact(target, range, signal);
803
924
  if (!generated.summary) return undefined;
804
- const summary = mergeCompactionSummary(preparation.previousSummary, generated.summary);
925
+ const summary = mergeCompactionSummary(
926
+ preparation.previousSummary,
927
+ generated.summary,
928
+ reserveTokens,
929
+ );
805
930
 
806
931
  return {
807
932
  summary,
@@ -935,11 +1060,7 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
935
1060
  const projectPath = projectSettingsPath(location);
936
1061
  const projectRaw = readJsonObjectStrict(projectPath).goosedump;
937
1062
  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
1063
+ isRecord(projectRaw) && isTurnsBeforeCompact(projectRaw.turnsBeforeCompact)
943
1064
  ? projectRaw.turnsBeforeCompact
944
1065
  : undefined;
945
1066
 
@@ -990,12 +1111,7 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
990
1111
  }
991
1112
 
992
1113
  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;
1114
+ if (!isTurnsBeforeCompact(parsed)) return;
999
1115
  try {
1000
1116
  writeScopedGoosedumpSetting(location, scope, key, parsed);
1001
1117
  settingsList.updateValue(id, newValue);
@@ -1289,6 +1405,7 @@ export function createGoosedumpIntegration(
1289
1405
  let turnsRemaining = 0;
1290
1406
  let counterTriggered = false;
1291
1407
  let compactionQueued = false;
1408
+ let compactionHookActive = false;
1292
1409
  let pendingKeep: number | null = null;
1293
1410
 
1294
1411
  function syncCompactCounters(ctx: ExtensionContext, clearPending = false): void {
@@ -1339,7 +1456,7 @@ export function createGoosedumpIntegration(
1339
1456
  ctx: ExtensionContext,
1340
1457
  keep: number | null,
1341
1458
  ): 'started' | 'busy' | 'nothing-to-compact' {
1342
- if (compactionQueued) return 'busy';
1459
+ if (compactionQueued || compactionHookActive) return 'busy';
1343
1460
  if (keep === null && !hasCompactableHistory(ctx)) return 'nothing-to-compact';
1344
1461
 
1345
1462
  compactionQueued = true;
@@ -1613,6 +1730,10 @@ export function createGoosedumpIntegration(
1613
1730
  if (!counterTriggered || compactionQueued) return;
1614
1731
  if (ctx.hasPendingMessages()) return;
1615
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;
1616
1737
 
1617
1738
  syncCompactCounters(ctx, true);
1618
1739
  updateStatus(ctx);
@@ -1652,7 +1773,7 @@ export function createGoosedumpIntegration(
1652
1773
  });
1653
1774
 
1654
1775
  pi.on('turn_end', async (event, ctx) => {
1655
- noteCompletedTurn(ctx);
1776
+ if (isCompletedAssistant(event.message)) noteCompletedTurn(ctx);
1656
1777
  // A turn with tool results means the agent loop would keep going; compact
1657
1778
  // between turns and resume so the run's progress is not lost.
1658
1779
  if (event.toolResults.length > 0) maybeAutoCompact(ctx, true);
@@ -1679,12 +1800,19 @@ export function createGoosedumpIntegration(
1679
1800
  const target = currentSessionTarget(ctx);
1680
1801
  if (!target) return;
1681
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
+
1682
1809
  try {
1683
1810
  const keep = pendingKeep ?? undefined;
1684
1811
  const compaction = await buildGoosedumpCompaction(
1685
1812
  target,
1686
1813
  event.preparation,
1687
1814
  event.branchEntries,
1815
+ piReserveTokens(ctx.cwd),
1688
1816
  keep,
1689
1817
  event.signal,
1690
1818
  );
@@ -1708,6 +1836,7 @@ export function createGoosedumpIntegration(
1708
1836
  }
1709
1837
  return undefined;
1710
1838
  } finally {
1839
+ compactionHookActive = false;
1711
1840
  pendingKeep = null;
1712
1841
  }
1713
1842
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.10.5",
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.5",
36
+ "@jarkkojs/goosedump": "^0.10.6",
37
37
  "@sinclair/typebox": "^0.34.49"
38
38
  },
39
39
  "devDependencies": {