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.
- package/README.md +17 -10
- package/index.ts +340 -95
- 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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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 =
|
|
217
|
+
const root = readJsonObjectStrict(path);
|
|
156
218
|
const goosedump = isRecord(root.goosedump) ? root.goosedump : {};
|
|
157
219
|
root.goosedump = { ...goosedump, [key]: value };
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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 >=
|
|
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
|
|
718
|
+
function latestCompactionForRange(
|
|
572
719
|
branchEntries: SessionEntry[],
|
|
573
720
|
untilIndex: number,
|
|
574
721
|
): CompactionEntry | undefined {
|
|
575
|
-
for (let i =
|
|
722
|
+
for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
|
|
576
723
|
const entry = branchEntries[i];
|
|
577
|
-
if (isCompactionEntry(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
|
|
588
|
-
|
|
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 =
|
|
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
|
|
646
|
-
if (!
|
|
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?: {
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
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:
|
|
772
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
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
|
|
1111
|
-
|
|
1112
|
-
'
|
|
1113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1605
|
+
syncCompactCounters(ctx, true);
|
|
1366
1606
|
updateStatus(ctx);
|
|
1367
1607
|
compactionQueued = true;
|
|
1368
1608
|
ctx.compact({
|
|
1369
1609
|
onComplete: () => {
|
|
1370
1610
|
compactionQueued = false;
|
|
1371
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
}
|