pi-goosedump 0.9.3 → 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 +148 -36
  2. package/package.json +1 -1
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 {
@@ -449,6 +454,56 @@ function isMessageEntry(entry: SessionEntry): entry is SessionEntry & {
449
454
  return entry.type === 'message';
450
455
  }
451
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
+
452
507
  // Resolution of the `keep:N` boundary: scan the active lineage from the newest
453
508
  // entry backward, count user turns (those whose message role is `user`), and
454
509
  // return the id of the Nth-from-last user entry — the earliest entry to keep.
@@ -595,12 +650,16 @@ class NumberInputSubmenu implements Component {
595
650
  handleInput(data: string): void {
596
651
  const kb = getKeybindings();
597
652
  if (kb.matches(data, 'tui.select.confirm') || data === '\n') {
598
- const digits = this.input.getValue().replace(/\D/g, '');
599
- if (digits === '') {
653
+ const value = this.input.getValue();
654
+ if (value === '') {
600
655
  this.onCancelEdit();
601
656
  return;
602
657
  }
603
- const parsed = Number(digits);
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);
604
663
  if (!Number.isSafeInteger(parsed) || parsed < this.min || parsed > this.max) {
605
664
  this.onInvalidValue(`Enter a whole number between ${this.min} and ${this.max}`);
606
665
  return;
@@ -1017,29 +1076,6 @@ export function createGoosedumpIntegration(
1017
1076
  if (turnsRemaining <= 0) counterTriggered = true;
1018
1077
  }
1019
1078
  updateStatus(ctx);
1020
-
1021
- if (!counterTriggered || compactionQueued) return;
1022
- // ctx.compact() uses manual compaction semantics and aborts the current run.
1023
- // Wait until no queued work remains before triggering plugin-driven compaction.
1024
- if (ctx.hasPendingMessages()) return;
1025
-
1026
- resetCompactCounters(ctx.cwd);
1027
- updateStatus(ctx);
1028
- compactionQueued = true;
1029
- ctx.compact({
1030
- onComplete: () => {
1031
- compactionQueued = false;
1032
- resetCompactCounters(ctx.cwd);
1033
- updateStatus(ctx);
1034
- },
1035
- onError: (error) => {
1036
- compactionQueued = false;
1037
- resetCompactCounters(ctx.cwd);
1038
- updateStatus(ctx);
1039
- const message = error instanceof Error ? error.message : String(error);
1040
- if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1041
- },
1042
- });
1043
1079
  }
1044
1080
 
1045
1081
  function checkGoosedump(ctx?: ExtensionContext): boolean {
@@ -1061,18 +1097,28 @@ export function createGoosedumpIntegration(
1061
1097
  // command and the goose_compact tool). Stashes a turn-based keep override and
1062
1098
  // fires Pi's compaction flow; the session_before_compact hook reads pendingKeep
1063
1099
  // and resets it on use, completion, or error.
1064
- 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;
1065
1108
  pendingKeep = keep;
1066
1109
  ctx.compact({
1067
1110
  onComplete: () => {
1111
+ compactionQueued = false;
1068
1112
  pendingKeep = null;
1069
1113
  },
1070
1114
  onError: (error) => {
1115
+ compactionQueued = false;
1071
1116
  pendingKeep = null;
1072
1117
  const message = error instanceof Error ? error.message : String(error);
1073
1118
  if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1074
1119
  },
1075
1120
  });
1121
+ return 'started';
1076
1122
  }
1077
1123
 
1078
1124
  function register(pi: ExtensionAPI): void {
@@ -1330,7 +1376,11 @@ export function createGoosedumpIntegration(
1330
1376
  if (!goosedumpAvailable) return goosedumpUnavailableResult();
1331
1377
 
1332
1378
  const keep = params.keep ?? null;
1333
- 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
+ }
1334
1384
  return textResult(
1335
1385
  keep !== null
1336
1386
  ? `Compacting with goosedump; keeping the last ${keep} user turn${keep === 1 ? '' : 's'}.`
@@ -1341,14 +1391,62 @@ export function createGoosedumpIntegration(
1341
1391
  );
1342
1392
  }
1343
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
+
1344
1435
  pi.on('session_start', async (_event, ctx) => {
1345
1436
  goosedumpAvailable = checkGoosedump(ctx);
1346
1437
  resetCompactCounters(ctx.cwd);
1347
1438
  updateStatus(ctx);
1348
1439
  });
1349
1440
 
1350
- pi.on('turn_end', async (_event, ctx) => {
1441
+ pi.on('turn_end', async (event, ctx) => {
1351
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);
1352
1450
  });
1353
1451
 
1354
1452
  pi.on('session_before_compact', async (event, ctx) => {
@@ -1371,11 +1469,9 @@ export function createGoosedumpIntegration(
1371
1469
  keep,
1372
1470
  );
1373
1471
  if (compaction && 'cancel' in compaction) {
1374
- pendingKeep = null;
1375
1472
  if (ctx.hasUI) ctx.ui.notify(compaction.reason, 'info');
1376
1473
  return { cancel: true };
1377
1474
  }
1378
- pendingKeep = null;
1379
1475
  return compaction ? { compaction } : undefined;
1380
1476
  } catch (err) {
1381
1477
  if (ctx.hasUI) {
@@ -1386,15 +1482,26 @@ export function createGoosedumpIntegration(
1386
1482
  );
1387
1483
  }
1388
1484
  return undefined;
1485
+ } finally {
1486
+ pendingKeep = null;
1389
1487
  }
1390
1488
  });
1391
1489
 
1392
1490
  pi.on('session_compact', async (event, ctx) => {
1393
- if (!goosedumpAvailable || !event.fromExtension) return;
1491
+ if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1394
1492
 
1395
- const compactionEntry = event.compactionEntry;
1396
- if (!compactionEntry) return;
1493
+ resetCompactCounters(ctx.cwd);
1494
+ updateStatus(ctx);
1397
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
+ }
1398
1505
  const tokensBefore = compactionEntry.tokensBefore;
1399
1506
  const summaryLen = compactionEntry.summary.length;
1400
1507
  const tokenEstimate = Math.ceil(summaryLen / 4);
@@ -1451,7 +1558,12 @@ export function createGoosedumpIntegration(
1451
1558
  }
1452
1559
  }
1453
1560
 
1454
- 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
+ }
1455
1567
  },
1456
1568
  });
1457
1569
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.9.3",
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",