pi-goosedump 0.9.2 → 0.9.4

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 +206 -77
  2. package/package.json +2 -2
package/index.ts CHANGED
@@ -10,7 +10,12 @@ import type {
10
10
  SessionEntry,
11
11
  } from '@earendil-works/pi-coding-agent';
12
12
 
13
- import { defineTool, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
13
+ import {
14
+ DEFAULT_COMPACTION_SETTINGS,
15
+ defineTool,
16
+ findCutPoint,
17
+ getSettingsListTheme,
18
+ } from '@earendil-works/pi-coding-agent';
14
19
 
15
20
  import { execFile, execFileSync } from 'node:child_process';
16
21
  import {
@@ -80,6 +85,9 @@ const DEFAULT_GOOSEDUMP_SETTINGS: GoosedumpSettings = {
80
85
  turnsBeforeCompact: 0,
81
86
  };
82
87
 
88
+ const TURNS_BEFORE_COMPACT_MIN = 0;
89
+ const TURNS_BEFORE_COMPACT_MAX = 255;
90
+
83
91
  function piAgentDir(): string {
84
92
  return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
85
93
  }
@@ -113,7 +121,12 @@ function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
113
121
 
114
122
  const settings: Partial<GoosedumpSettings> = {};
115
123
  const value = raw.turnsBeforeCompact;
116
- if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 255) {
124
+ if (
125
+ typeof value === 'number' &&
126
+ Number.isSafeInteger(value) &&
127
+ value >= TURNS_BEFORE_COMPACT_MIN &&
128
+ value <= TURNS_BEFORE_COMPACT_MAX
129
+ ) {
117
130
  settings.turnsBeforeCompact = value;
118
131
  }
119
132
  return settings;
@@ -248,6 +261,13 @@ function missingSessionId(tool: string, provider = 'pi'): AgentToolResult<void>
248
261
  return textResult(`session is required for ${tool} when no current session is available`);
249
262
  }
250
263
 
264
+ const GOOSEDUMP_UNAVAILABLE_MESSAGE =
265
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump';
266
+
267
+ function goosedumpUnavailableResult(): AgentToolResult<void> {
268
+ return textResult(GOOSEDUMP_UNAVAILABLE_MESSAGE);
269
+ }
270
+
251
271
  function runGoosedump(args: string[]): string {
252
272
  try {
253
273
  return execFileSync('node', [resolveGoosedumpBinary(), ...args], {
@@ -434,6 +454,56 @@ function isMessageEntry(entry: SessionEntry): entry is SessionEntry & {
434
454
  return entry.type === 'message';
435
455
  }
436
456
 
457
+ // Entry types that sessionEntryToContextMessages turns into context messages;
458
+ // only these count toward "is there anything to summarize".
459
+ const SUMMARIZABLE_ENTRY_TYPES = new Set(['message', 'custom_message', 'branch_summary']);
460
+
461
+ // Pi's compaction keepRecentTokens setting, project scope over global, matching
462
+ // SettingsManager's merge order.
463
+ function piKeepRecentTokens(cwd: string): number {
464
+ for (const path of [projectSettingsPath(cwd), globalSettingsPath()]) {
465
+ const compaction = readJsonObject(path).compaction;
466
+ if (isRecord(compaction) && typeof compaction.keepRecentTokens === 'number') {
467
+ return compaction.keepRecentTokens;
468
+ }
469
+ }
470
+ return DEFAULT_COMPACTION_SETTINGS.keepRecentTokens;
471
+ }
472
+
473
+ // Predict whether ctx.compact() would throw "Nothing to compact" or "Already
474
+ // compacted": mirrors prepareCompaction's guards over the active lineage so
475
+ // plugin-triggered compaction can skip the call instead of surfacing pi's
476
+ // error toast (and needlessly aborting the agent).
477
+ function hasCompactableHistory(ctx: ExtensionContext): boolean {
478
+ const branch = ctx.sessionManager.getBranch();
479
+ if (branch.length === 0) return false;
480
+ if (branch[branch.length - 1].type === 'compaction') return false;
481
+
482
+ let boundaryStart = 0;
483
+ for (let i = branch.length - 1; i >= 0; i -= 1) {
484
+ const entry = branch[i];
485
+ if (isCompactionEntry(entry)) {
486
+ const keptIndex = branch.findIndex((e) => e.id === entry.firstKeptEntryId);
487
+ boundaryStart = keptIndex >= 0 ? keptIndex : i + 1;
488
+ break;
489
+ }
490
+ }
491
+
492
+ const cut = findCutPoint(branch, boundaryStart, branch.length, piKeepRecentTokens(ctx.cwd));
493
+ if (!branch[cut.firstKeptEntryIndex]?.id) return false;
494
+
495
+ const historyEnd = cut.isSplitTurn ? cut.turnStartIndex : cut.firstKeptEntryIndex;
496
+ for (let i = boundaryStart; i < historyEnd; i += 1) {
497
+ if (SUMMARIZABLE_ENTRY_TYPES.has(branch[i].type)) return true;
498
+ }
499
+ if (cut.isSplitTurn) {
500
+ for (let i = cut.turnStartIndex; i < cut.firstKeptEntryIndex; i += 1) {
501
+ if (SUMMARIZABLE_ENTRY_TYPES.has(branch[i].type)) return true;
502
+ }
503
+ }
504
+ return false;
505
+ }
506
+
437
507
  // Resolution of the `keep:N` boundary: scan the active lineage from the newest
438
508
  // entry backward, count user turns (those whose message role is `user`), and
439
509
  // return the id of the Nth-from-last user entry — the earliest entry to keep.
@@ -552,29 +622,49 @@ function isControlChar(char: string): boolean {
552
622
  class NumberInputSubmenu implements Component {
553
623
  private readonly input = new Input();
554
624
  private readonly hint: (text: string) => string;
555
- private readonly onSubmitValue: (value: string) => void;
625
+ private readonly onSubmitValue: (value: number) => void;
556
626
  private readonly onCancelEdit: () => void;
627
+ private readonly onInvalidValue: (message: string) => void;
557
628
  private readonly title: string;
629
+ private readonly currentValue: number;
630
+ private readonly min: number;
631
+ private readonly max: number;
558
632
 
559
633
  constructor(
560
634
  title: string,
561
- initialValue: string,
562
- onSubmit: (value: string) => void,
635
+ currentValue: number,
636
+ onSubmit: (value: number) => void,
563
637
  onCancel: () => void,
638
+ options?: { min?: number; max?: number; onInvalid?: (message: string) => void },
564
639
  ) {
565
640
  this.title = title;
641
+ this.currentValue = currentValue;
566
642
  this.onSubmitValue = onSubmit;
567
643
  this.onCancelEdit = onCancel;
568
- this.input.setValue(initialValue);
644
+ this.onInvalidValue = options?.onInvalid ?? (() => {});
645
+ this.min = options?.min ?? 0;
646
+ this.max = options?.max ?? 255;
569
647
  this.hint = getSettingsListTheme().hint;
570
648
  }
571
649
 
572
650
  handleInput(data: string): void {
573
651
  const kb = getKeybindings();
574
652
  if (kb.matches(data, 'tui.select.confirm') || data === '\n') {
575
- const digits = this.input.getValue().replace(/\D/g, '');
576
- const parsed = Number(digits);
577
- this.onSubmitValue(digits !== '' && Number.isSafeInteger(parsed) ? String(parsed) : '0');
653
+ const value = this.input.getValue();
654
+ if (value === '') {
655
+ this.onCancelEdit();
656
+ return;
657
+ }
658
+ if (!/^\d+$/.test(value)) {
659
+ this.onInvalidValue(`Enter a whole number between ${this.min} and ${this.max}`);
660
+ return;
661
+ }
662
+ const parsed = Number(value);
663
+ if (!Number.isSafeInteger(parsed) || parsed < this.min || parsed > this.max) {
664
+ this.onInvalidValue(`Enter a whole number between ${this.min} and ${this.max}`);
665
+ return;
666
+ }
667
+ this.onSubmitValue(parsed);
578
668
  return;
579
669
  }
580
670
  if (kb.matches(data, 'tui.select.cancel')) {
@@ -593,7 +683,7 @@ class NumberInputSubmenu implements Component {
593
683
 
594
684
  render(width: number): string[] {
595
685
  return [
596
- this.hint(` ${this.title}`),
686
+ this.hint(` ${this.title} · current ${this.currentValue} · ${this.min}-${this.max}`),
597
687
  '',
598
688
  ...this.input.render(width),
599
689
  '',
@@ -637,9 +727,14 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
637
727
  submenu: (currentValue, subDone) =>
638
728
  new NumberInputSubmenu(
639
729
  settingLabel(scope, 'turnsBeforeCompact'),
640
- currentValue,
641
- (value) => subDone(value),
730
+ Number(currentValue),
731
+ (value) => subDone(String(value)),
642
732
  () => subDone(),
733
+ {
734
+ min: TURNS_BEFORE_COMPACT_MIN,
735
+ max: TURNS_BEFORE_COMPACT_MAX,
736
+ onInvalid: (message) => ctx.ui.notify(message, 'error'),
737
+ },
643
738
  ),
644
739
  }));
645
740
  }
@@ -652,7 +747,12 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
652
747
  (id, newValue) => {
653
748
  const [scope, key] = id.split(':') as [SettingsScope, GoosedumpSettingKey];
654
749
  const parsed = Number(newValue);
655
- if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 255) return;
750
+ if (
751
+ !Number.isSafeInteger(parsed) ||
752
+ parsed < TURNS_BEFORE_COMPACT_MIN ||
753
+ parsed > TURNS_BEFORE_COMPACT_MAX
754
+ )
755
+ return;
656
756
  writeScopedGoosedumpSetting(location, scope, key, parsed);
657
757
  settingsList.updateValue(id, newValue);
658
758
  ctx.ui.notify(`${settingLabel(scope, key)} = ${formatCompactSetting(parsed)}`, 'info');
@@ -976,29 +1076,6 @@ export function createGoosedumpIntegration(
976
1076
  if (turnsRemaining <= 0) counterTriggered = true;
977
1077
  }
978
1078
  updateStatus(ctx);
979
-
980
- if (!counterTriggered || compactionQueued) return;
981
- // ctx.compact() uses manual compaction semantics and aborts the current run.
982
- // Wait until no queued work remains before triggering plugin-driven compaction.
983
- if (ctx.hasPendingMessages()) return;
984
-
985
- resetCompactCounters(ctx.cwd);
986
- updateStatus(ctx);
987
- compactionQueued = true;
988
- ctx.compact({
989
- onComplete: () => {
990
- compactionQueued = false;
991
- resetCompactCounters(ctx.cwd);
992
- updateStatus(ctx);
993
- },
994
- onError: (error) => {
995
- compactionQueued = false;
996
- resetCompactCounters(ctx.cwd);
997
- updateStatus(ctx);
998
- const message = error instanceof Error ? error.message : String(error);
999
- if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1000
- },
1001
- });
1002
1079
  }
1003
1080
 
1004
1081
  function checkGoosedump(ctx?: ExtensionContext): boolean {
@@ -1020,18 +1097,28 @@ export function createGoosedumpIntegration(
1020
1097
  // command and the goose_compact tool). Stashes a turn-based keep override and
1021
1098
  // fires Pi's compaction flow; the session_before_compact hook reads pendingKeep
1022
1099
  // and resets it on use, completion, or error.
1023
- function requestCompaction(ctx: ExtensionContext, keep: number | null): void {
1100
+ function requestCompaction(
1101
+ ctx: ExtensionContext,
1102
+ keep: number | null,
1103
+ ): 'started' | 'busy' | 'nothing-to-compact' {
1104
+ if (compactionQueued) return 'busy';
1105
+ if (!hasCompactableHistory(ctx)) return 'nothing-to-compact';
1106
+
1107
+ compactionQueued = true;
1024
1108
  pendingKeep = keep;
1025
1109
  ctx.compact({
1026
1110
  onComplete: () => {
1111
+ compactionQueued = false;
1027
1112
  pendingKeep = null;
1028
1113
  },
1029
1114
  onError: (error) => {
1115
+ compactionQueued = false;
1030
1116
  pendingKeep = null;
1031
1117
  const message = error instanceof Error ? error.message : String(error);
1032
1118
  if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1033
1119
  },
1034
1120
  });
1121
+ return 'started';
1035
1122
  }
1036
1123
 
1037
1124
  function register(pi: ExtensionAPI): void {
@@ -1091,11 +1178,7 @@ export function createGoosedumpIntegration(
1091
1178
  ),
1092
1179
  }),
1093
1180
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1094
- if (!goosedumpAvailable) {
1095
- return textResult(
1096
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1097
- );
1098
- }
1181
+ if (!goosedumpAvailable) return goosedumpUnavailableResult();
1099
1182
 
1100
1183
  try {
1101
1184
  const provider = params.provider ?? 'pi';
@@ -1172,11 +1255,7 @@ export function createGoosedumpIntegration(
1172
1255
  ),
1173
1256
  }),
1174
1257
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1175
- if (!goosedumpAvailable) {
1176
- return textResult(
1177
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1178
- );
1179
- }
1258
+ if (!goosedumpAvailable) return goosedumpUnavailableResult();
1180
1259
 
1181
1260
  try {
1182
1261
  const provider = params.provider ?? 'pi';
@@ -1247,11 +1326,7 @@ export function createGoosedumpIntegration(
1247
1326
  ),
1248
1327
  }),
1249
1328
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1250
- if (!goosedumpAvailable) {
1251
- return textResult(
1252
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1253
- );
1254
- }
1329
+ if (!goosedumpAvailable) return goosedumpUnavailableResult();
1255
1330
 
1256
1331
  try {
1257
1332
  const provider = params.provider ?? 'pi';
@@ -1298,14 +1373,14 @@ export function createGoosedumpIntegration(
1298
1373
  ),
1299
1374
  }),
1300
1375
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1301
- if (!goosedumpAvailable) {
1302
- return textResult(
1303
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1304
- );
1305
- }
1376
+ if (!goosedumpAvailable) return goosedumpUnavailableResult();
1306
1377
 
1307
1378
  const keep = params.keep ?? null;
1308
- requestCompaction(ctx, keep);
1379
+ const result = requestCompaction(ctx, keep);
1380
+ if (result === 'busy') return textResult('Compaction already in progress.');
1381
+ if (result === 'nothing-to-compact') {
1382
+ return textResult('Nothing to compact (session too small).');
1383
+ }
1309
1384
  return textResult(
1310
1385
  keep !== null
1311
1386
  ? `Compacting with goosedump; keeping the last ${keep} user turn${keep === 1 ? '' : 's'}.`
@@ -1316,14 +1391,62 @@ export function createGoosedumpIntegration(
1316
1391
  );
1317
1392
  }
1318
1393
 
1394
+ // Fire counter-driven compaction. ctx.compact() uses manual compaction
1395
+ // semantics and aborts the current run, so a mid-run trigger (resumeAfter)
1396
+ // re-triggers the agent with a synthetic continue message once the
1397
+ // compaction lands. If the session is still too small to compact, the
1398
+ // counter stays armed and retries at a later turn_end/agent_end.
1399
+ function maybeAutoCompact(ctx: ExtensionContext, resumeAfter: boolean): void {
1400
+ if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1401
+ if (!counterTriggered || compactionQueued) return;
1402
+ if (ctx.hasPendingMessages()) return;
1403
+ if (!hasCompactableHistory(ctx)) return;
1404
+
1405
+ resetCompactCounters(ctx.cwd);
1406
+ updateStatus(ctx);
1407
+ compactionQueued = true;
1408
+ ctx.compact({
1409
+ onComplete: () => {
1410
+ compactionQueued = false;
1411
+ resetCompactCounters(ctx.cwd);
1412
+ updateStatus(ctx);
1413
+ if (resumeAfter && !ctx.hasPendingMessages()) {
1414
+ pi.sendMessage(
1415
+ {
1416
+ customType: 'goosedump-autocompact-resume',
1417
+ content:
1418
+ 'The session was compacted to reduce context size. Continue the task you were working on from where you left off.',
1419
+ display: false,
1420
+ },
1421
+ { triggerTurn: true },
1422
+ );
1423
+ }
1424
+ },
1425
+ onError: (error) => {
1426
+ compactionQueued = false;
1427
+ resetCompactCounters(ctx.cwd);
1428
+ updateStatus(ctx);
1429
+ const message = error instanceof Error ? error.message : String(error);
1430
+ if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1431
+ },
1432
+ });
1433
+ }
1434
+
1319
1435
  pi.on('session_start', async (_event, ctx) => {
1320
1436
  goosedumpAvailable = checkGoosedump(ctx);
1321
1437
  resetCompactCounters(ctx.cwd);
1322
1438
  updateStatus(ctx);
1323
1439
  });
1324
1440
 
1325
- pi.on('turn_end', async (_event, ctx) => {
1441
+ pi.on('turn_end', async (event, ctx) => {
1326
1442
  noteCompletedTurn(ctx);
1443
+ // A turn with tool results means the agent loop would keep going; compact
1444
+ // between turns and resume so the run's progress is not lost.
1445
+ if (event.toolResults.length > 0) maybeAutoCompact(ctx, true);
1446
+ });
1447
+
1448
+ pi.on('agent_end', async (_event, ctx) => {
1449
+ maybeAutoCompact(ctx, false);
1327
1450
  });
1328
1451
 
1329
1452
  pi.on('session_before_compact', async (event, ctx) => {
@@ -1346,11 +1469,9 @@ export function createGoosedumpIntegration(
1346
1469
  keep,
1347
1470
  );
1348
1471
  if (compaction && 'cancel' in compaction) {
1349
- pendingKeep = null;
1350
1472
  if (ctx.hasUI) ctx.ui.notify(compaction.reason, 'info');
1351
1473
  return { cancel: true };
1352
1474
  }
1353
- pendingKeep = null;
1354
1475
  return compaction ? { compaction } : undefined;
1355
1476
  } catch (err) {
1356
1477
  if (ctx.hasUI) {
@@ -1361,15 +1482,26 @@ export function createGoosedumpIntegration(
1361
1482
  );
1362
1483
  }
1363
1484
  return undefined;
1485
+ } finally {
1486
+ pendingKeep = null;
1364
1487
  }
1365
1488
  });
1366
1489
 
1367
1490
  pi.on('session_compact', async (event, ctx) => {
1368
- if (!goosedumpAvailable || !event.fromExtension) return;
1491
+ if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1369
1492
 
1370
- const compactionEntry = event.compactionEntry;
1371
- if (!compactionEntry) return;
1493
+ resetCompactCounters(ctx.cwd);
1494
+ updateStatus(ctx);
1372
1495
 
1496
+ const compactionEntry = event.compactionEntry;
1497
+ if (
1498
+ !event.fromExtension ||
1499
+ !compactionEntry ||
1500
+ !isRecord(compactionEntry.details) ||
1501
+ compactionEntry.details.source !== 'goosedump'
1502
+ ) {
1503
+ return;
1504
+ }
1373
1505
  const tokensBefore = compactionEntry.tokensBefore;
1374
1506
  const summaryLen = compactionEntry.summary.length;
1375
1507
  const tokenEstimate = Math.ceil(summaryLen / 4);
@@ -1390,15 +1522,13 @@ export function createGoosedumpIntegration(
1390
1522
  description: 'Edit goosedump compaction settings',
1391
1523
  handler: async (_args, ctx) => {
1392
1524
  if (!goosedumpAvailable) {
1393
- ctx.ui.notify(
1394
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1395
- 'error',
1396
- );
1525
+ ctx.ui.notify(GOOSEDUMP_UNAVAILABLE_MESSAGE, 'error');
1397
1526
  return;
1398
1527
  }
1399
1528
 
1400
1529
  try {
1401
1530
  await runSettingsEditor(ctx);
1531
+ reloadCompactSettings(ctx.cwd);
1402
1532
  updateStatus(ctx);
1403
1533
  } catch (err) {
1404
1534
  const message = err instanceof Error ? err.message : String(err);
@@ -1411,10 +1541,7 @@ export function createGoosedumpIntegration(
1411
1541
  description: 'Compact now with goosedump; optionally keep the last N user turns',
1412
1542
  handler: async (args, ctx) => {
1413
1543
  if (!goosedumpAvailable) {
1414
- ctx.ui.notify(
1415
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1416
- 'error',
1417
- );
1544
+ ctx.ui.notify(GOOSEDUMP_UNAVAILABLE_MESSAGE, 'error');
1418
1545
  return;
1419
1546
  }
1420
1547
 
@@ -1431,7 +1558,12 @@ export function createGoosedumpIntegration(
1431
1558
  }
1432
1559
  }
1433
1560
 
1434
- requestCompaction(ctx, keep);
1561
+ const result = requestCompaction(ctx, keep);
1562
+ if (result === 'busy') {
1563
+ ctx.ui.notify('Compaction already in progress', 'info');
1564
+ } else if (result === 'nothing-to-compact') {
1565
+ ctx.ui.notify('Nothing to compact (session too small)', 'info');
1566
+ }
1435
1567
  },
1436
1568
  });
1437
1569
 
@@ -1439,10 +1571,7 @@ export function createGoosedumpIntegration(
1439
1571
  description: 'Search the current session history',
1440
1572
  handler: async (args, ctx) => {
1441
1573
  if (!goosedumpAvailable) {
1442
- ctx.ui.notify(
1443
- 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1444
- 'error',
1445
- );
1574
+ ctx.ui.notify(GOOSEDUMP_UNAVAILABLE_MESSAGE, 'error');
1446
1575
  return;
1447
1576
  }
1448
1577
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Pi extension for goosedump-backed session-history search, entry expansion, and compaction",
5
5
  "keywords": [
6
6
  "compaction",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "@earendil-works/pi-tui": "^0.78.0",
34
- "@jarkkojs/goosedump": "^0.9.3",
34
+ "@jarkkojs/goosedump": "^0.9.4",
35
35
  "@sinclair/typebox": "^0.34.49"
36
36
  },
37
37
  "devDependencies": {