pi-goosedump 0.9.14 → 0.10.1

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 +17 -10
  2. package/index.ts +340 -95
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -20,6 +20,8 @@ If the goosedump binary cannot be resolved, the extension disables itself.
20
20
 
21
21
  - **goose_search:** ranks messages by query relevance; returns compact entry
22
22
  overviews with IDs.
23
+ - **goose_recall:** searches durable memory harvested from earlier compactions,
24
+ scoped to the current working directory by default.
23
25
  - **goose_get:** expands specific entry IDs to their full content.
24
26
  - **goose_grep:** filters messages by glob pattern; returns compact entries
25
27
  with IDs.
@@ -35,6 +37,7 @@ current branch) or `all`. Targets are always provider-qualified
35
37
 
36
38
  ```
37
39
  goose_search({ query: "bug fix" })
40
+ goose_recall({ query: "prior architecture decision" })
38
41
  goose_search({ query: "auth", scope: "all", page: 2 })
39
42
  goose_get({ ids: ["entry-a", "entry-b"] })
40
43
  goose_get({ ids: ["entry-a"], provider: "claude", session: "1f6b1eba-…" })
@@ -71,9 +74,12 @@ section and are optional (defaults shown):
71
74
  ```
72
75
 
73
76
  `turnsBeforeCompact` sets how many LLM turns elapse before goosedump triggers
74
- compaction, as an integer 0–255. `0` disables the counter; when enabled, the
75
- footer status shows `GD: XX` counting down in hex (e.g. `GD: 0A`), or
76
- `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.
77
83
 
78
84
  ### Compaction flow
79
85
 
@@ -88,13 +94,14 @@ header line, then runs `goosedump compact` over the replacement range:
88
94
  compaction.
89
95
  - `--until` is the first entry Pi is keeping (`firstKeptEntryId`).
90
96
 
91
- goosedump composes the structured summary itself — including merging any prior
92
- summary — and emits a native Pi compaction entry, from which the extension
93
- extracts the `summary` field. A `keep:N` override (via `/goose-compact` or the
94
- `goose_compact` tool) replaces Pi's token-based cut with a turn-based one:
95
- summarize everything before the Nth-from-last user entry and keep the tail
96
- verbatim. Fewer than N user turns cancels with a notice. On completion Pi
97
- 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.
98
105
 
99
106
  ## License
100
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';
@@ -55,6 +58,17 @@ interface GoosedumpMessage {
55
58
  content: string;
56
59
  }
57
60
 
61
+ interface GoosedumpMemoryHit {
62
+ hash: string;
63
+ kind: string;
64
+ text: string;
65
+ score: number;
66
+ provider: string;
67
+ context_id: string;
68
+ entry_id: string;
69
+ path: string;
70
+ }
71
+
58
72
  // A `content` part of a pi-native message line.
59
73
  interface PiPartJson {
60
74
  type: string;
@@ -115,6 +129,30 @@ function readJsonObject(path: string): Record<string, unknown> {
115
129
  }
116
130
  }
117
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
+
118
156
  function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
119
157
  const raw = readJsonObject(path).goosedump;
120
158
  if (!isRecord(raw)) return {};
@@ -140,9 +178,33 @@ function resolveGoosedumpSettings(cwd: string): GoosedumpSettings {
140
178
  };
141
179
  }
142
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
+
143
192
  function readScopedGoosedumpSettings(cwd: string, scope: SettingsScope): GoosedumpSettings {
144
193
  const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
145
- 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 };
146
208
  }
147
209
 
148
210
  function writeScopedGoosedumpSetting(
@@ -152,11 +214,26 @@ function writeScopedGoosedumpSetting(
152
214
  value: number,
153
215
  ): void {
154
216
  const path = scope === 'global' ? globalSettingsPath() : projectSettingsPath(cwd);
155
- const root = readJsonObject(path);
217
+ const root = readJsonObjectStrict(path);
156
218
  const goosedump = isRecord(root.goosedump) ? root.goosedump : {};
157
219
  root.goosedump = { ...goosedump, [key]: value };
158
- mkdirSync(dirname(path), { recursive: true });
159
- 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);
160
237
  }
161
238
 
162
239
  function formatCompactSetting(value: number): string {
@@ -234,6 +311,32 @@ function parsePiMessages(output: string): GoosedumpMessage[] {
234
311
  return messages;
235
312
  }
236
313
 
314
+ function parseMemoryHits(output: string): GoosedumpMemoryHit[] {
315
+ const value = JSON.parse(output) as unknown;
316
+ if (!Array.isArray(value)) throw new Error('goosedump recall returned invalid JSON');
317
+ return value.filter(
318
+ (item): item is GoosedumpMemoryHit =>
319
+ isRecord(item) &&
320
+ typeof item.hash === 'string' &&
321
+ typeof item.kind === 'string' &&
322
+ typeof item.text === 'string' &&
323
+ typeof item.score === 'number' &&
324
+ typeof item.provider === 'string' &&
325
+ typeof item.context_id === 'string' &&
326
+ typeof item.entry_id === 'string' &&
327
+ typeof item.path === 'string',
328
+ );
329
+ }
330
+
331
+ function formatMemoryHits(hits: GoosedumpMemoryHit[]): string {
332
+ return hits
333
+ .map(
334
+ (hit) =>
335
+ `[${hit.hash}] ${hit.kind} (${hit.provider}:${hit.context_id}/${hit.entry_id})\n${hit.text}`,
336
+ )
337
+ .join('\n\n---\n\n');
338
+ }
339
+
237
340
  const ANSI_SGR = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
238
341
 
239
342
  function compactLine(content: string): string {
@@ -315,8 +418,9 @@ function runGoosedumpAsync(args: string[], signal?: AbortSignal): Promise<string
315
418
 
316
419
  // Read only the first line of a file without loading the whole transcript.
317
420
  function readFirstLine(path: string): string | null {
318
- const fd = openSync(path, 'r');
421
+ let fd: number | undefined;
319
422
  try {
423
+ fd = openSync(path, 'r');
320
424
  const buf = Buffer.alloc(8192);
321
425
  const bytesRead = readSync(fd, buf, 0, buf.length, 0);
322
426
  if (bytesRead < 0) return null;
@@ -326,7 +430,7 @@ function readFirstLine(path: string): string | null {
326
430
  } catch {
327
431
  return null;
328
432
  } finally {
329
- closeSync(fd);
433
+ if (fd !== undefined) closeSync(fd);
330
434
  }
331
435
  }
332
436
 
@@ -367,6 +471,18 @@ function goosedumpShow(
367
471
  return parsePiMessages(runGoosedump(args));
368
472
  }
369
473
 
474
+ async function goosedumpRecall(
475
+ query: string,
476
+ options: { provider?: string; path?: string; limit?: number } = {},
477
+ signal?: AbortSignal,
478
+ ): Promise<GoosedumpMemoryHit[]> {
479
+ const args = ['recall', query, '--limit', String(options.limit ?? 5)];
480
+ if (options.provider) args.push('--provider', options.provider);
481
+ if (options.path) args.push('--path', options.path);
482
+ const output = await runGoosedumpAsync(args, signal);
483
+ return parseMemoryHits(output);
484
+ }
485
+
370
486
  // Resolve a goosedump 0.9 `provider:id` target. An explicit contextId is the
371
487
  // target id already; for pi it falls back to the current session. Other
372
488
  // providers have no current session and require an explicit id.
@@ -524,17 +640,10 @@ function hasCompactableHistory(ctx: ExtensionContext): boolean {
524
640
  if (branch.length === 0) return false;
525
641
  if (branch[branch.length - 1].type === 'compaction') return false;
526
642
 
527
- let boundaryStart = 0;
528
- for (let i = branch.length - 1; i >= 0; i -= 1) {
529
- const entry = branch[i];
530
- if (isCompactionEntry(entry)) {
531
- const keptIndex = branch.findIndex((e) => e.id === entry.firstKeptEntryId);
532
- boundaryStart = keptIndex >= 0 ? keptIndex : i + 1;
533
- break;
534
- }
535
- }
643
+ const boundaryStart = activeHistoryStartIndex(branch);
536
644
 
537
645
  const cut = findCutPoint(branch, boundaryStart, branch.length, piKeepRecentTokens(ctx.cwd));
646
+
538
647
  if (!branch[cut.firstKeptEntryIndex]?.id) return false;
539
648
 
540
649
  const historyEnd = cut.isSplitTurn ? cut.turnStartIndex : cut.firstKeptEntryIndex;
@@ -549,32 +658,71 @@ function hasCompactableHistory(ctx: ExtensionContext): boolean {
549
658
  return false;
550
659
  }
551
660
 
552
- // Resolution of the `keep:N` boundary: scan the active lineage from the newest
553
- // entry backward, count user turns (those whose message role is `user`), and
554
- // return the id of the Nth-from-last user entry — the earliest entry to keep.
555
- // Returns undefined when there are fewer than `keep` user turns (nothing to
556
- // 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.
557
705
  function keepBoundaryId(branchEntries: SessionEntry[], keep: number): string | undefined {
706
+ const historyStart = activeHistoryStartIndex(branchEntries);
558
707
  let seen = 0;
559
- for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
708
+ for (let i = branchEntries.length - 1; i >= historyStart; i -= 1) {
560
709
  const entry = branchEntries[i];
561
710
  if (isMessageEntry(entry) && entry.message.role === 'user') {
562
711
  seen += 1;
563
- if (seen === keep) {
564
- return i === 0 ? undefined : entry.id;
565
- }
712
+ if (seen === keep) return i === historyStart ? undefined : entry.id;
566
713
  }
567
714
  }
568
715
  return undefined;
569
716
  }
570
717
 
571
- function latestCompactionBefore(
718
+ function latestCompactionForRange(
572
719
  branchEntries: SessionEntry[],
573
720
  untilIndex: number,
574
721
  ): CompactionEntry | undefined {
575
- for (let i = untilIndex - 1; i >= 0; i -= 1) {
722
+ for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
576
723
  const entry = branchEntries[i];
577
- if (isCompactionEntry(entry)) return entry;
724
+ if (!isCompactionEntry(entry)) continue;
725
+ if (compactionBoundaryStartIndex(branchEntries, entry) < untilIndex) return entry;
578
726
  }
579
727
  return undefined;
580
728
  }
@@ -584,17 +732,8 @@ function compactionStartId(
584
732
  previousCompaction: CompactionEntry,
585
733
  untilIndex: number,
586
734
  ): string | undefined {
587
- const keptIndex = branchEntries.findIndex(
588
- (entry) => entry.id === previousCompaction.firstKeptEntryId,
589
- );
590
- if (keptIndex >= 0 && keptIndex < untilIndex) return previousCompaction.firstKeptEntryId;
591
-
592
- const compactionIndex = branchEntries.findIndex((entry) => entry.id === previousCompaction.id);
593
- if (compactionIndex >= 0 && compactionIndex + 1 < untilIndex) {
594
- return branchEntries[compactionIndex + 1].id;
595
- }
596
-
597
- return undefined;
735
+ const boundaryStart = compactionBoundaryStartIndex(branchEntries, previousCompaction);
736
+ return boundaryStart < untilIndex ? branchEntries[boundaryStart]?.id : undefined;
598
737
  }
599
738
 
600
739
  function buildCompactRange(
@@ -604,7 +743,7 @@ function buildCompactRange(
604
743
  const untilIndex = branchEntries.findIndex((entry) => entry.id === firstKeptEntryId);
605
744
  if (untilIndex < 0) return undefined;
606
745
 
607
- const previousCompaction = latestCompactionBefore(branchEntries, untilIndex);
746
+ const previousCompaction = latestCompactionForRange(branchEntries, untilIndex);
608
747
  const from = previousCompaction
609
748
  ? compactionStartId(branchEntries, previousCompaction, untilIndex)
610
749
  : undefined;
@@ -614,6 +753,14 @@ function buildCompactRange(
614
753
  : { scope: 'lineage', until: firstKeptEntryId };
615
754
  }
616
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
+
617
764
  async function buildGoosedumpCompaction(
618
765
  target: string,
619
766
  preparation: {
@@ -642,8 +789,9 @@ async function buildGoosedumpCompaction(
642
789
  const range = buildCompactRange(branchEntries, firstKeptEntryId);
643
790
  if (!range) return undefined;
644
791
 
645
- const summary = await goosedumpCompact(target, range, signal);
646
- if (!summary) return undefined;
792
+ const generatedSummary = await goosedumpCompact(target, range, signal);
793
+ if (!generatedSummary) return undefined;
794
+ const summary = mergeCompactionSummary(preparation.previousSummary, generatedSummary);
647
795
 
648
796
  return {
649
797
  summary,
@@ -670,23 +818,30 @@ class NumberInputSubmenu implements Component {
670
818
  private readonly hint: (text: string) => string;
671
819
  private readonly onSubmitValue: (value: number) => void;
672
820
  private readonly onCancelEdit: () => void;
821
+ private readonly onClearValue?: () => void;
673
822
  private readonly onInvalidValue: (message: string) => void;
674
823
  private readonly title: string;
675
- private readonly currentValue: number;
824
+ private readonly currentValue: number | null;
676
825
  private readonly min: number;
677
826
  private readonly max: number;
678
827
 
679
828
  constructor(
680
829
  title: string,
681
- currentValue: number,
830
+ currentValue: number | null,
682
831
  onSubmit: (value: number) => void,
683
832
  onCancel: () => void,
684
- 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
+ },
685
839
  ) {
686
840
  this.title = title;
687
841
  this.currentValue = currentValue;
688
842
  this.onSubmitValue = onSubmit;
689
843
  this.onCancelEdit = onCancel;
844
+ this.onClearValue = options?.onClear;
690
845
  this.onInvalidValue = options?.onInvalid ?? (() => {});
691
846
  this.min = options?.min ?? 0;
692
847
  this.max = options?.max ?? 255;
@@ -698,7 +853,8 @@ class NumberInputSubmenu implements Component {
698
853
  if (kb.matches(data, 'tui.select.confirm') || data === '\n') {
699
854
  const value = this.input.getValue();
700
855
  if (value === '') {
701
- this.onCancelEdit();
856
+ if (this.onClearValue) this.onClearValue();
857
+ else this.onCancelEdit();
702
858
  return;
703
859
  }
704
860
  if (!/^\d+$/.test(value)) {
@@ -729,11 +885,17 @@ class NumberInputSubmenu implements Component {
729
885
 
730
886
  render(width: number): string[] {
731
887
  return [
732
- 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
+ ),
733
891
  '',
734
892
  ...this.input.render(width),
735
893
  '',
736
- 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
+ ),
737
899
  ];
738
900
  }
739
901
  }
@@ -759,26 +921,37 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
759
921
 
760
922
  function buildSettingItems(): SettingItem[] {
761
923
  const global = readScopedGoosedumpSettings(location, 'global');
762
- const project = readScopedGoosedumpSettings(location, 'project');
763
- const settingsByScope = { global, project } satisfies Record<
764
- SettingsScope,
765
- GoosedumpSettings
766
- >;
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;
767
934
 
768
935
  return (['global', 'project'] as const).map((scope) => ({
769
936
  id: `${scope}:turnsBeforeCompact`,
770
937
  label: settingLabel(scope, 'turnsBeforeCompact'),
771
- description: 'LLM turns before compaction; 0 disables.',
772
- 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
+ ),
773
945
  submenu: (currentValue, subDone) =>
774
946
  new NumberInputSubmenu(
775
947
  settingLabel(scope, 'turnsBeforeCompact'),
776
- Number(currentValue),
948
+ currentValue === '' ? null : Number(currentValue),
777
949
  (value) => subDone(String(value)),
778
950
  () => subDone(),
779
951
  {
780
952
  min: TURNS_BEFORE_COMPACT_MIN,
781
953
  max: TURNS_BEFORE_COMPACT_MAX,
954
+ onClear: scope === 'project' ? () => subDone('') : undefined,
782
955
  onInvalid: (message) => ctx.ui.notify(message, 'error'),
783
956
  },
784
957
  ),
@@ -792,6 +965,19 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
792
965
  getSettingsListTheme(),
793
966
  (id, newValue) => {
794
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
+
795
981
  const parsed = Number(newValue);
796
982
  if (
797
983
  !Number.isSafeInteger(parsed) ||
@@ -799,9 +985,18 @@ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
799
985
  parsed > TURNS_BEFORE_COMPACT_MAX
800
986
  )
801
987
  return;
802
- writeScopedGoosedumpSetting(location, scope, key, parsed);
803
- settingsList.updateValue(id, newValue);
804
- 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
+ }
805
1000
  tui.requestRender();
806
1001
  },
807
1002
  () => {
@@ -1080,47 +1275,33 @@ export function createGoosedumpIntegration(
1080
1275
 
1081
1276
  let goosedumpAvailable = false;
1082
1277
  let compactSettings = DEFAULT_GOOSEDUMP_SETTINGS;
1083
- let settingsSignature = '';
1084
1278
  let turnsRemaining = 0;
1085
1279
  let counterTriggered = false;
1086
1280
  let compactionQueued = false;
1087
1281
  let pendingKeep: number | null = null;
1088
1282
 
1089
- function compactSettingsSignature(settings: GoosedumpSettings): string {
1090
- return String(settings.turnsBeforeCompact);
1091
- }
1092
-
1093
- function resetCompactCounters(cwd: string): void {
1094
- compactSettings = resolveGoosedumpSettings(cwd);
1095
- settingsSignature = compactSettingsSignature(compactSettings);
1096
- turnsRemaining = compactSettings.turnsBeforeCompact;
1097
- counterTriggered = false;
1098
- pendingKeep = null;
1099
- }
1100
-
1101
- function reloadCompactSettings(cwd: string): void {
1102
- const next = resolveGoosedumpSettings(cwd);
1103
- const nextSignature = compactSettingsSignature(next);
1104
- 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;
1105
1291
  }
1106
1292
 
1107
1293
  function updateStatus(ctx: ExtensionContext): void {
1108
1294
  if (!goosedumpAvailable) return;
1109
1295
  const active = shouldOverrideDefaultCompaction && compactSettings.turnsBeforeCompact > 0;
1110
- const count = active ? Math.max(0, turnsRemaining) : 0;
1111
- ctx.ui.setStatus(
1112
- 'goosedump',
1113
- ctx.ui.theme.fg('text', `GD: ${count.toString(16).toUpperCase().padStart(2, '0')}`),
1114
- );
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}`));
1115
1300
  }
1116
1301
 
1117
1302
  function noteCompletedTurn(ctx: ExtensionContext): void {
1118
1303
  if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1119
- reloadCompactSettings(ctx.cwd);
1120
- if (compactSettings.turnsBeforeCompact > 0) {
1121
- turnsRemaining -= 1;
1122
- if (turnsRemaining <= 0) counterTriggered = true;
1123
- }
1304
+ syncCompactCounters(ctx);
1124
1305
  updateStatus(ctx);
1125
1306
  }
1126
1307
 
@@ -1169,6 +1350,65 @@ export function createGoosedumpIntegration(
1169
1350
 
1170
1351
  function register(pi: ExtensionAPI): void {
1171
1352
  if (shouldRegisterTool) {
1353
+ pi.registerTool(
1354
+ defineTool({
1355
+ name: 'goose_recall',
1356
+ label: 'goose_recall',
1357
+ description:
1358
+ 'Recall durable context harvested from earlier compactions. Searches the current project by default and returns full remembered entries with provenance.',
1359
+ promptSnippet: 'Recall durable context from earlier sessions',
1360
+ promptGuidelines: [
1361
+ 'Use goose_recall for decisions, evidence, and context that may have disappeared from the current session.',
1362
+ 'Results include their original provider, session, entry, and working-directory provenance.',
1363
+ 'The current project is searched by default; set allProjects only when cross-project context is appropriate.',
1364
+ ],
1365
+ parameters: Type.Object({
1366
+ query: Type.String({ description: 'Persistent-memory search query' }),
1367
+ provider: Type.Optional(
1368
+ Type.Union(
1369
+ GOOSE_PROVIDERS.map((provider) => Type.Literal(provider)),
1370
+ {
1371
+ description: 'Optional source-provider filter',
1372
+ },
1373
+ ),
1374
+ ),
1375
+ allProjects: Type.Optional(
1376
+ Type.Boolean({
1377
+ default: false,
1378
+ description: 'Search memory from every working directory',
1379
+ }),
1380
+ ),
1381
+ limit: Type.Optional(
1382
+ Type.Integer({
1383
+ minimum: 1,
1384
+ maximum: 20,
1385
+ default: 5,
1386
+ description: 'Maximum remembered entries to return',
1387
+ }),
1388
+ ),
1389
+ }),
1390
+ async execute(_id, params, signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1391
+ if (!goosedumpAvailable) return goosedumpUnavailableResult();
1392
+ try {
1393
+ const hits = await goosedumpRecall(
1394
+ params.query,
1395
+ {
1396
+ provider: params.provider,
1397
+ path: params.allProjects ? undefined : ctx.cwd,
1398
+ limit: params.limit,
1399
+ },
1400
+ signal,
1401
+ );
1402
+ if (hits.length === 0) return textResult(`No durable memory for "${params.query}"`);
1403
+ return textResult(formatMemoryHits(hits));
1404
+ } catch (err) {
1405
+ const message = err instanceof Error ? err.message : String(err);
1406
+ return textResult(`goosedump error: ${message}`);
1407
+ }
1408
+ },
1409
+ }),
1410
+ );
1411
+
1172
1412
  pi.registerTool(
1173
1413
  defineTool({
1174
1414
  name: 'goose_search',
@@ -1362,13 +1602,13 @@ export function createGoosedumpIntegration(
1362
1602
  if (ctx.hasPendingMessages()) return;
1363
1603
  if (!hasCompactableHistory(ctx)) return;
1364
1604
 
1365
- resetCompactCounters(ctx.cwd);
1605
+ syncCompactCounters(ctx, true);
1366
1606
  updateStatus(ctx);
1367
1607
  compactionQueued = true;
1368
1608
  ctx.compact({
1369
1609
  onComplete: () => {
1370
1610
  compactionQueued = false;
1371
- resetCompactCounters(ctx.cwd);
1611
+ syncCompactCounters(ctx, true);
1372
1612
  updateStatus(ctx);
1373
1613
  if (resumeAfter && !ctx.hasPendingMessages()) {
1374
1614
  pi.sendMessage(
@@ -1384,7 +1624,7 @@ export function createGoosedumpIntegration(
1384
1624
  },
1385
1625
  onError: (error) => {
1386
1626
  compactionQueued = false;
1387
- resetCompactCounters(ctx.cwd);
1627
+ syncCompactCounters(ctx, true);
1388
1628
  updateStatus(ctx);
1389
1629
  const message = error instanceof Error ? error.message : String(error);
1390
1630
  if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
@@ -1394,7 +1634,8 @@ export function createGoosedumpIntegration(
1394
1634
 
1395
1635
  pi.on('session_start', async (_event, ctx) => {
1396
1636
  goosedumpAvailable = checkGoosedump(ctx);
1397
- resetCompactCounters(ctx.cwd);
1637
+ notifyInvalidSettings(ctx);
1638
+ syncCompactCounters(ctx, true);
1398
1639
  updateStatus(ctx);
1399
1640
  });
1400
1641
 
@@ -1458,7 +1699,7 @@ export function createGoosedumpIntegration(
1458
1699
  pi.on('session_compact', async (event, ctx) => {
1459
1700
  if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1460
1701
 
1461
- resetCompactCounters(ctx.cwd);
1702
+ syncCompactCounters(ctx, true);
1462
1703
  updateStatus(ctx);
1463
1704
 
1464
1705
  const compactionEntry = event.compactionEntry;
@@ -1496,7 +1737,7 @@ export function createGoosedumpIntegration(
1496
1737
 
1497
1738
  try {
1498
1739
  await runSettingsEditor(ctx);
1499
- reloadCompactSettings(ctx.cwd);
1740
+ syncCompactCounters(ctx);
1500
1741
  updateStatus(ctx);
1501
1742
  } catch (err) {
1502
1743
  const message = err instanceof Error ? err.message : String(err);
@@ -1517,11 +1758,15 @@ export function createGoosedumpIntegration(
1517
1758
  // the last N user turns verbatim and summarizes everything before; with
1518
1759
  // no argument goosedump summarizes up to Pi's token-based cut.
1519
1760
  let keep: number | null = null;
1520
- if (typeof args === 'string') {
1761
+ if (typeof args === 'string' && args.trim()) {
1521
1762
  const match = args.trim().match(/^keep:(\d+)$/);
1522
- if (match) keep = Number(match[1]);
1523
- if (match && (keep === null || keep < 1)) {
1524
- 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');
1525
1770
  return;
1526
1771
  }
1527
1772
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-goosedump",
3
- "version": "0.9.14",
3
+ "version": "0.10.1",
4
4
  "description": "Pi extension for goosedump-backed session-history search, entry expansion, and compaction",
5
5
  "keywords": [
6
6
  "compaction",