pi-goosedump 0.10.0 → 0.10.2

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 +14 -10
  2. package/index.ts +232 -95
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -74,9 +74,12 @@ section and are optional (defaults shown):
74
74
  ```
75
75
 
76
76
  `turnsBeforeCompact` sets how many LLM turns elapse before goosedump triggers
77
- compaction, as an integer 0–255. `0` disables the counter; when enabled, the
78
- footer status shows `GD: XX` counting down in hex (e.g. `GD: 0A`), or
79
- `GD: 00` when the counter is disabled.
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.
80
83
 
81
84
  ### Compaction flow
82
85
 
@@ -91,13 +94,14 @@ header line, then runs `goosedump compact` over the replacement range:
91
94
  compaction.
92
95
  - `--until` is the first entry Pi is keeping (`firstKeptEntryId`).
93
96
 
94
- goosedump composes the structured summary itself — including merging any prior
95
- summary — and emits a native Pi compaction entry, from which the extension
96
- extracts the `summary` field. A `keep:N` override (via `/goose-compact` or the
97
- `goose_compact` tool) replaces Pi's token-based cut with a turn-based one:
98
- summarize everything before the Nth-from-last user entry and keep the tail
99
- verbatim. Fewer than N user turns cancels with a notice. On completion Pi
100
- notifies the token reduction.
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.
101
105
 
102
106
  ## License
103
107
 
package/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  } from '@earendil-works/pi-coding-agent';
19
19
 
20
20
  import { execFile, execFileSync } from 'node:child_process';
21
+ import { randomUUID } from 'node:crypto';
21
22
  import {
22
23
  closeSync,
23
24
  existsSync,
@@ -25,6 +26,8 @@ import {
25
26
  openSync,
26
27
  readFileSync,
27
28
  readSync,
29
+ renameSync,
30
+ unlinkSync,
28
31
  writeFileSync,
29
32
  } from 'node:fs';
30
33
  import { createRequire } from 'node:module';
@@ -126,6 +129,30 @@ function readJsonObject(path: string): Record<string, unknown> {
126
129
  }
127
130
  }
128
131
 
132
+ function readJsonObjectStrict(path: string): Record<string, unknown> {
133
+ if (!existsSync(path)) return {};
134
+
135
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown;
136
+ if (!isRecord(parsed)) throw new Error(`${path} must contain a JSON object`);
137
+ return parsed;
138
+ }
139
+
140
+ function writeJsonObjectAtomically(path: string, value: Record<string, unknown>): void {
141
+ mkdirSync(dirname(path), { recursive: true });
142
+ const temporaryPath = `${path}.${randomUUID()}.tmp`;
143
+ try {
144
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' });
145
+ renameSync(temporaryPath, path);
146
+ } catch (error) {
147
+ try {
148
+ unlinkSync(temporaryPath);
149
+ } catch {
150
+ // The temporary file was either never created or has already been renamed.
151
+ }
152
+ throw error;
153
+ }
154
+ }
155
+
129
156
  function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
130
157
  const raw = readJsonObject(path).goosedump;
131
158
  if (!isRecord(raw)) return {};
@@ -151,9 +178,33 @@ function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
151
178
  };
152
179
  }
153
180
 
181
+ function notifyInvalidSettings(ctx: ExtensionContext): void {
182
+ for (const path of [globalSettingsPath(), projectSettingsPath(ctx.cwd)]) {
183
+ try {
184
+ readJsonObjectStrict(path);
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ if (ctx.hasUI) ctx.ui.notify(`goosedump settings error: ${message}`, 'error');
188
+ }
189
+ }
190
+ }
191
+
154
192
  function readScopedGoosedumpSettings(cwd: string, scope: SettingsScope): GoosedumpSettings {
155
193
  const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
156
- return { ...DEFAULT_GOOSEDUMP_SETTINGS, ...readGoosedumpSettingsFrom(path) };
194
+ const raw = readJsonObjectStrict(path).goosedump;
195
+ if (!isRecord(raw)) return { ...DEFAULT_GOOSEDUMP_SETTINGS };
196
+
197
+ const settings: Partial<GoosedumpSettings> = {};
198
+ 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
+ }
207
+ return { ...DEFAULT_GOOSEDUMP_SETTINGS, ...settings };
157
208
  }
158
209
 
159
210
  function writeScopedGoosedumpSetting(
@@ -163,11 +214,26 @@ function writeScopedGoosedumpSetting(
163
214
  value: number,
164
215
  ): void {
165
216
  const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
166
- const root = readJsonObject(path);
217
+ const root = readJsonObjectStrict(path);
167
218
  const goosedump = isRecord(root.goosedump) ? root.goosedump : {};
168
219
  root.goosedump = { ...goosedump, [key]: value };
169
- mkdirSync(dirname(path), { recursive: true });
170
- writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
220
+ writeJsonObjectAtomically(path, root);
221
+ }
222
+
223
+ function clearScopedGoosedumpSetting(
224
+ cwd: string,
225
+ scope: SettingsScope,
226
+ key: GoosedumpSettingKey,
227
+ ): void {
228
+ const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
229
+ const root = readJsonObjectStrict(path);
230
+ if (!isRecord(root.goosedump)) return;
231
+
232
+ const goosedump = { ...root.goosedump };
233
+ delete goosedump[key];
234
+ if (Object.keys(goosedump).length === 0) delete root.goosedump;
235
+ else root.goosedump = goosedump;
236
+ writeJsonObjectAtomically(path, root);
171
237
  }
172
238
 
173
239
  function formatCompactSetting(value: number): string {
@@ -352,8 +418,9 @@ function runGoosedumpAsync(args: string[], signal?: AbortSignal): Promise<string
352
418
 
353
419
  // Read only the first line of a file without loading the whole transcript.
354
420
  function readFirstLine(path: string): string | null {
355
- const fd = openSync(path, 'r');
421
+ let fd: number | undefined;
356
422
  try {
423
+ fd = openSync(path, 'r');
357
424
  const buf = Buffer.alloc(8192);
358
425
  const bytesRead = readSync(fd, buf, 0, buf.length, 0);
359
426
  if (bytesRead < 0) return null;
@@ -363,7 +430,7 @@ function readFirstLine(path: string): string | null {
363
430
  } catch {
364
431
  return null;
365
432
  } finally {
366
- closeSync(fd);
433
+ if (fd !== undefined) closeSync(fd);
367
434
  }
368
435
  }
369
436
 
@@ -573,17 +640,10 @@ function hasCompactableHistory(ctx: ExtensionContext): boolean {
573
640
  if (branch.length === 0) return false;
574
641
  if (branch[branch.length - 1].type === 'compaction') return false;
575
642
 
576
- let boundaryStart = 0;
577
- for (let i = branch.length - 1; i >= 0; i -= 1) {
578
- const entry = branch[i];
579
- if (isCompactionEntry(entry)) {
580
- const keptIndex = branch.findIndex((e) => e.id === entry.firstKeptEntryId);
581
- boundaryStart = keptIndex >= 0 ? keptIndex : i + 1;
582
- break;
583
- }
584
- }
643
+ const boundaryStart = activeHistoryStartIndex(branch);
585
644
 
586
645
  const cut = findCutPoint(branch, boundaryStart, branch.length, piKeepRecentTokens(ctx.cwd));
646
+
587
647
  if (!branch[cut.firstKeptEntryIndex]?.id) return false;
588
648
 
589
649
  const historyEnd = cut.isSplitTurn ? cut.turnStartIndex : cut.firstKeptEntryIndex;
@@ -598,32 +658,71 @@ function hasCompactableHistory(ctx: ExtensionContext): boolean {
598
658
  return false;
599
659
  }
600
660
 
601
- // Resolution of the `keep:N` boundary: scan the active lineage from the newest
602
- // entry backward, count user turns (those whose message role is `user`), and
603
- // return the id of the Nth-from-last user entry — the earliest entry to keep.
604
- // Returns undefined when there are fewer than `keep` user turns (nothing to
605
- // summarize without orphaning the kept tail) or when every entry would be kept.
661
+ function latestCompaction(branchEntries: SessionEntry[]): CompactionEntry | undefined {
662
+ for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
663
+ const entry = branchEntries[i];
664
+ if (isCompactionEntry(entry)) return entry;
665
+ }
666
+ return undefined;
667
+ }
668
+
669
+ function compactionBoundaryStartIndex(
670
+ branchEntries: SessionEntry[],
671
+ compaction: CompactionEntry,
672
+ ): number {
673
+ const keptIndex = branchEntries.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
674
+ if (keptIndex >= 0) return keptIndex;
675
+
676
+ const compactionIndex = branchEntries.findIndex((entry) => entry.id === compaction.id);
677
+ return compactionIndex >= 0 ? compactionIndex + 1 : 0;
678
+ }
679
+
680
+ function activeHistoryStartIndex(branchEntries: SessionEntry[]): number {
681
+ const compaction = latestCompaction(branchEntries);
682
+ return compaction ? compactionBoundaryStartIndex(branchEntries, compaction) : 0;
683
+ }
684
+
685
+ function completedTurnsSinceCompaction(branchEntries: SessionEntry[]): number {
686
+ let start = 0;
687
+ for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
688
+ if (isCompactionEntry(branchEntries[i])) {
689
+ start = i + 1;
690
+ break;
691
+ }
692
+ }
693
+
694
+ let completed = 0;
695
+ for (let i = start; i < branchEntries.length; i += 1) {
696
+ const entry = branchEntries[i];
697
+ if (isMessageEntry(entry) && entry.message.role === 'assistant') completed += 1;
698
+ }
699
+ return completed;
700
+ }
701
+
702
+ // Resolution of the `keep:N` boundary: scan only the active, uncompacted
703
+ // history from newest to oldest. Kept messages from the prior compaction remain
704
+ // eligible, while history represented by its summary is never selected again.
606
705
  function keepBoundaryId(branchEntries: SessionEntry[], keep: number): string | undefined {
706
+ const historyStart = activeHistoryStartIndex(branchEntries);
607
707
  let seen = 0;
608
- for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
708
+ for (let i = branchEntries.length - 1; i >= historyStart; i -= 1) {
609
709
  const entry = branchEntries[i];
610
710
  if (isMessageEntry(entry) && entry.message.role === 'user') {
611
711
  seen += 1;
612
- if (seen === keep) {
613
- return i === 0 ? undefined : entry.id;
614
- }
712
+ if (seen === keep) return i === historyStart ? undefined : entry.id;
615
713
  }
616
714
  }
617
715
  return undefined;
618
716
  }
619
717
 
620
- function latestCompactionBefore(
718
+ function latestCompactionForRange(
621
719
  branchEntries: SessionEntry[],
622
720
  untilIndex: number,
623
721
  ): CompactionEntry | undefined {
624
- for (let i = untilIndex - 1; i >= 0; i -= 1) {
722
+ for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
625
723
  const entry = branchEntries[i];
626
- if (isCompactionEntry(entry)) return entry;
724
+ if (!isCompactionEntry(entry)) continue;
725
+ if (compactionBoundaryStartIndex(branchEntries, entry) < untilIndex) return entry;
627
726
  }
628
727
  return undefined;
629
728
  }
@@ -633,17 +732,8 @@ function compactionStartId(
633
732
  previousCompaction: CompactionEntry,
634
733
  untilIndex: number,
635
734
  ): string | undefined {
636
- const keptIndex = branchEntries.findIndex(
637
- (entry) => entry.id === previousCompaction.firstKeptEntryId,
638
- );
639
- if (keptIndex >= 0 && keptIndex < untilIndex) return previousCompaction.firstKeptEntryId;
640
-
641
- const compactionIndex = branchEntries.findIndex((entry) => entry.id === previousCompaction.id);
642
- if (compactionIndex >= 0 && compactionIndex + 1 < untilIndex) {
643
- return branchEntries[compactionIndex + 1].id;
644
- }
645
-
646
- return undefined;
735
+ const boundaryStart = compactionBoundaryStartIndex(branchEntries, previousCompaction);
736
+ return boundaryStart < untilIndex ? branchEntries[boundaryStart]?.id : undefined;
647
737
  }
648
738
 
649
739
  function buildCompactRange(
@@ -653,7 +743,7 @@ function buildCompactRange(
653
743
  const untilIndex = branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
654
744
  if (untilIndex < 0) return undefined;
655
745
 
656
- const previousCompaction = latestCompactionBefore(branchEntries, untilIndex);
746
+ const previousCompaction = latestCompactionForRange(branchEntries, untilIndex);
657
747
  const from = previousCompaction
658
748
  ? compactionStartId(branchEntries, previousCompaction, untilIndex)
659
749
  : undefined;
@@ -663,6 +753,14 @@ function buildCompactRange(
663
753
  : { scope: 'lineage', until: firstKeptEntryId };
664
754
  }
665
755
 
756
+ function mergeCompactionSummary(
757
+ previousSummary: string | undefined,
758
+ generatedSummary: string,
759
+ ): string {
760
+ const previous = previousSummary?.trim();
761
+ return previous ? `${previous}\n\n${generatedSummary}` : generatedSummary;
762
+ }
763
+
666
764
  async function buildGoosedumpCompaction(
667
765
  target: string,
668
766
  preparation: {
@@ -691,8 +789,9 @@ async function buildGoosedumpCompaction(
691
789
  const range = buildCompactRange(branchEntries, firstKeptEntryId);
692
790
  if (!range) return undefined;
693
791
 
694
- const summary = await goosedumpCompact(target, range, signal);
695
- if (!summary) return undefined;
792
+ const generatedSummary = await goosedumpCompact(target, range, signal);
793
+ if (!generatedSummary) return undefined;
794
+ const summary = mergeCompactionSummary(preparation.previousSummary, generatedSummary);
696
795
 
697
796
  return {
698
797
  summary,
@@ -719,23 +818,30 @@ class NumberInputSubmenu implements Component {
719
818
  private readonly hint: (text: string) => string;
720
819
  private readonly onSubmitValue: (value: number) => void;
721
820
  private readonly onCancelEdit: () => void;
821
+ private readonly onClearValue?: () => void;
722
822
  private readonly onInvalidValue: (message: string) => void;
723
823
  private readonly title: string;
724
- private readonly currentValue: number;
824
+ private readonly currentValue: number | null;
725
825
  private readonly min: number;
726
826
  private readonly max: number;
727
827
 
728
828
  constructor(
729
829
  title: string,
730
- currentValue: number,
830
+ currentValue: number | null,
731
831
  onSubmit: (value: number) => void,
732
832
  onCancel: () => void,
733
- options?: { min?: number; max?: number; onInvalid?: (message: string) => void },
833
+ options?: {
834
+ min?: number;
835
+ max?: number;
836
+ onClear?: () => void;
837
+ onInvalid?: (message: string) => void;
838
+ },
734
839
  ) {
735
840
  this.title = title;
736
841
  this.currentValue = currentValue;
737
842
  this.onSubmitValue = onSubmit;
738
843
  this.onCancelEdit = onCancel;
844
+ this.onClearValue = options?.onClear;
739
845
  this.onInvalidValue = options?.onInvalid ?? (() => {});
740
846
  this.min = options?.min ?? 0;
741
847
  this.max = options?.max ?? 255;
@@ -747,7 +853,8 @@ class NumberInputSubmenu implements Component {
747
853
  if (kb.matches(data, 'tui.select.confirm') || data === '\n') {
748
854
  const value = this.input.getValue();
749
855
  if (value === '') {
750
- this.onCancelEdit();
856
+ if (this.onClearValue) this.onClearValue();
857
+ else this.onCancelEdit();
751
858
  return;
752
859
  }
753
860
  if (!/^\d+$/.test(value)) {
@@ -778,11 +885,17 @@ class NumberInputSubmenu implements Component {
778
885
 
779
886
  render(width: number): string[] {
780
887
  return [
781
- this.hint(` ${this.title} · current ${this.currentValue} · ${this.min}-${this.max}`),
888
+ this.hint(
889
+ ` ${this.title} · current ${this.currentValue ?? 'inherit'} · ${this.min}-${this.max}`,
890
+ ),
782
891
  '',
783
892
  ...this.input.render(width),
784
893
  '',
785
- this.hint(' enter submit · esc cancel'),
894
+ this.hint(
895
+ this.onClearValue
896
+ ? ' empty inherit · enter submit · esc cancel'
897
+ : ' enter submit · esc cancel',
898
+ ),
786
899
  ];
787
900
  }
788
901
  }
@@ -808,26 +921,37 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
808
921
 
809
922
  function buildSettingItems(): SettingItem[] {
810
923
  const global = readScopedGoosedumpSettings(location, 'global');
811
- const project = readScopedGoosedumpSettings(location, 'project');
812
- const settingsByScope = { global, project } satisfies Record<
813
- SettingsScope,
814
- GoosedumpSettings
815
- >;
924
+ const projectPath = projectSettingsPath(location);
925
+ const projectRaw = readJsonObjectStrict(projectPath).goosedump;
926
+ 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
932
+ ? projectRaw.turnsBeforeCompact
933
+ : undefined;
816
934
 
817
935
  return (['global', 'project'] as const).map((scope) => ({
818
936
  id: `${scope}:turnsBeforeCompact`,
819
937
  label: settingLabel(scope, 'turnsBeforeCompact'),
820
- description: 'LLM turns before compaction; 0 disables.',
821
- currentValue: String(settingsByScope[scope].turnsBeforeCompact),
938
+ description:
939
+ scope === 'project'
940
+ ? 'LLM turns before compaction; empty inherits Global.'
941
+ : 'LLM turns before compaction; 0 disables.',
942
+ currentValue: String(
943
+ scope === 'project' ? (projectValue ?? '') : global.turnsBeforeCompact,
944
+ ),
822
945
  submenu: (currentValue, subDone) =>
823
946
  new NumberInputSubmenu(
824
947
  settingLabel(scope, 'turnsBeforeCompact'),
825
- Number(currentValue),
948
+ currentValue === '' ? null : Number(currentValue),
826
949
  (value) => subDone(String(value)),
827
950
  () => subDone(),
828
951
  {
829
952
  min: TURNS_BEFORE_COMPACT_MIN,
830
953
  max: TURNS_BEFORE_COMPACT_MAX,
954
+ onClear: scope === 'project' ? () => subDone('') : undefined,
831
955
  onInvalid: (message) => ctx.ui.notify(message, 'error'),
832
956
  },
833
957
  ),
@@ -841,6 +965,19 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
841
965
  getSettingsListTheme(),
842
966
  (id, newValue) => {
843
967
  const [scope, key] = id.split(':') as [SettingsScope, GoosedumpSettingKey];
968
+ if (scope === 'project' && newValue === '') {
969
+ try {
970
+ clearScopedGoosedumpSetting(location, scope, key);
971
+ settingsList.updateValue(id, '');
972
+ ctx.ui.notify(`${settingLabel(scope, key)} inherits Global`, 'info');
973
+ tui.requestRender();
974
+ } catch (error) {
975
+ const message = error instanceof Error ? error.message : String(error);
976
+ ctx.ui.notify(`goosedump settings error: ${message}`, 'error');
977
+ }
978
+ return;
979
+ }
980
+
844
981
  const parsed = Number(newValue);
845
982
  if (
846
983
  !Number.isSafeInteger(parsed) ||
@@ -848,9 +985,18 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
848
985
  parsed > TURNS_BEFORE_COMPACT_MAX
849
986
  )
850
987
  return;
851
- writeScopedGoosedumpSetting(location, scope, key, parsed);
852
- settingsList.updateValue(id, newValue);
853
- ctx.ui.notify(`${settingLabel(scope, key)} = ${formatCompactSetting(parsed)}`, 'info');
988
+ try {
989
+ writeScopedGoosedumpSetting(location, scope, key, parsed);
990
+ settingsList.updateValue(id, newValue);
991
+ ctx.ui.notify(
992
+ `${settingLabel(scope, key)} = ${formatCompactSetting(parsed)}`,
993
+ 'info',
994
+ );
995
+ tui.requestRender();
996
+ } catch (error) {
997
+ const message = error instanceof Error ? error.message : String(error);
998
+ ctx.ui.notify(`goosedump settings error: ${message}`, 'error');
999
+ }
854
1000
  tui.requestRender();
855
1001
  },
856
1002
  () => {
@@ -1129,47 +1275,33 @@ export function createGoosedumpIntegration(
1129
1275
 
1130
1276
  let goosedumpAvailable = false;
1131
1277
  let compactSettings = DEFAULT_GOOSEDUMP_SETTINGS;
1132
- let settingsSignature = '';
1133
1278
  let turnsRemaining = 0;
1134
1279
  let counterTriggered = false;
1135
1280
  let compactionQueued = false;
1136
1281
  let pendingKeep: number | null = null;
1137
1282
 
1138
- function compactSettingsSignature(settings: GoosedumpSettings): string {
1139
- return String(settings.turnsBeforeCompact);
1140
- }
1141
-
1142
- function resetCompactCounters(cwd: string): void {
1143
- compactSettings = resolveGoosedumpSettings(cwd);
1144
- settingsSignature = compactSettingsSignature(compactSettings);
1145
- turnsRemaining = compactSettings.turnsBeforeCompact;
1146
- counterTriggered = false;
1147
- pendingKeep = null;
1148
- }
1149
-
1150
- function reloadCompactSettings(cwd: string): void {
1151
- const next = resolveGoosedumpSettings(cwd);
1152
- const nextSignature = compactSettingsSignature(next);
1153
- if (nextSignature !== settingsSignature) resetCompactCounters(cwd);
1283
+ function syncCompactCounters(ctx: ExtensionContext, clearPending = false): void {
1284
+ compactSettings = resolveGoosedumpSettings(ctx.cwd);
1285
+ const completedTurns = completedTurnsSinceCompaction(ctx.sessionManager.getBranch());
1286
+ turnsRemaining = Math.max(0, compactSettings.turnsBeforeCompact - completedTurns);
1287
+ counterTriggered =
1288
+ compactSettings.turnsBeforeCompact > 0 &&
1289
+ completedTurns >= compactSettings.turnsBeforeCompact;
1290
+ if (clearPending) pendingKeep = null;
1154
1291
  }
1155
1292
 
1156
1293
  function updateStatus(ctx: ExtensionContext): void {
1157
1294
  if (!goosedumpAvailable) return;
1158
1295
  const active = shouldOverrideDefaultCompaction && compactSettings.turnsBeforeCompact > 0;
1159
- const count = active ? Math.max(0, turnsRemaining) : 0;
1160
- ctx.ui.setStatus(
1161
- 'goosedump',
1162
- ctx.ui.theme.fg('text', `GD: ${count.toString(16).toUpperCase().padStart(2, '0')}`),
1163
- );
1296
+ const counter = active
1297
+ ? Math.max(0, turnsRemaining).toString(16).toUpperCase().padStart(2, '0')
1298
+ : '--';
1299
+ ctx.ui.setStatus('goosedump', ctx.ui.theme.fg('text', `GD: ${counter}`));
1164
1300
  }
1165
1301
 
1166
1302
  function noteCompletedTurn(ctx: ExtensionContext): void {
1167
1303
  if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1168
- reloadCompactSettings(ctx.cwd);
1169
- if (compactSettings.turnsBeforeCompact > 0) {
1170
- turnsRemaining -= 1;
1171
- if (turnsRemaining <= 0) counterTriggered = true;
1172
- }
1304
+ syncCompactCounters(ctx);
1173
1305
  updateStatus(ctx);
1174
1306
  }
1175
1307
 
@@ -1470,13 +1602,13 @@ export function createGoosedumpIntegration(
1470
1602
  if (ctx.hasPendingMessages()) return;
1471
1603
  if (!hasCompactableHistory(ctx)) return;
1472
1604
 
1473
- resetCompactCounters(ctx.cwd);
1605
+ syncCompactCounters(ctx, true);
1474
1606
  updateStatus(ctx);
1475
1607
  compactionQueued = true;
1476
1608
  ctx.compact({
1477
1609
  onComplete: () => {
1478
1610
  compactionQueued = false;
1479
- resetCompactCounters(ctx.cwd);
1611
+ syncCompactCounters(ctx, true);
1480
1612
  updateStatus(ctx);
1481
1613
  if (resumeAfter && !ctx.hasPendingMessages()) {
1482
1614
  pi.sendMessage(
@@ -1492,7 +1624,7 @@ export function createGoosedumpIntegration(
1492
1624
  },
1493
1625
  onError: (error) => {
1494
1626
  compactionQueued = false;
1495
- resetCompactCounters(ctx.cwd);
1627
+ syncCompactCounters(ctx, true);
1496
1628
  updateStatus(ctx);
1497
1629
  const message = error instanceof Error ? error.message : String(error);
1498
1630
  if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
@@ -1502,7 +1634,8 @@ export function createGoosedumpIntegration(
1502
1634
 
1503
1635
  pi.on('session_start', async (_event, ctx) => {
1504
1636
  goosedumpAvailable = checkGoosedump(ctx);
1505
- resetCompactCounters(ctx.cwd);
1637
+ notifyInvalidSettings(ctx);
1638
+ syncCompactCounters(ctx, true);
1506
1639
  updateStatus(ctx);
1507
1640
  });
1508
1641
 
@@ -1566,7 +1699,7 @@ export function createGoosedumpIntegration(
1566
1699
  pi.on('session_compact', async (event, ctx) => {
1567
1700
  if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1568
1701
 
1569
- resetCompactCounters(ctx.cwd);
1702
+ syncCompactCounters(ctx, true);
1570
1703
  updateStatus(ctx);
1571
1704
 
1572
1705
  const compactionEntry = event.compactionEntry;
@@ -1604,7 +1737,7 @@ export function createGoosedumpIntegration(
1604
1737
 
1605
1738
  try {
1606
1739
  await runSettingsEditor(ctx);
1607
- reloadCompactSettings(ctx.cwd);
1740
+ syncCompactCounters(ctx);
1608
1741
  updateStatus(ctx);
1609
1742
  } catch (err) {
1610
1743
  const message = err instanceof Error ? err.message : String(err);
@@ -1625,11 +1758,15 @@ export function createGoosedumpIntegration(
1625
1758
  // the last N user turns verbatim and summarizes everything before; with
1626
1759
  // no argument goosedump summarizes up to Pi's token-based cut.
1627
1760
  let keep: number | null = null;
1628
- if (typeof args === 'string') {
1761
+ if (typeof args === 'string' && args.trim()) {
1629
1762
  const match = args.trim().match(/^keep:(\d+)$/);
1630
- if (match) keep = Number(match[1]);
1631
- if (match && (keep === null || keep < 1)) {
1632
- ctx.ui.notify('keep:N requires N >= 1', 'error');
1763
+ if (!match) {
1764
+ ctx.ui.notify('Usage: /goose-compact [keep:N], where N >= 1', 'error');
1765
+ return;
1766
+ }
1767
+ keep = Number(match[1]);
1768
+ if (!Number.isSafeInteger(keep) || keep < 1) {
1769
+ ctx.ui.notify('keep:N requires a safe integer N >= 1', 'error');
1633
1770
  return;
1634
1771
  }
1635
1772
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
4
4
  "description": "Pi extension for goosedump-backed session-history search, entry expansion, and compaction",
5
5
  "keywords": [
6
6
  "compaction",