pi-goosedump 0.8.0 → 0.9.0

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 +69 -71
  2. package/index.ts +750 -917
  3. package/package.json +5 -3
package/index.ts CHANGED
@@ -13,15 +13,27 @@ import type {
13
13
  import { defineTool, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
14
14
 
15
15
  import { execFileSync } from 'node:child_process';
16
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import {
17
+ closeSync,
18
+ existsSync,
19
+ mkdirSync,
20
+ openSync,
21
+ readFileSync,
22
+ readSync,
23
+ writeFileSync,
24
+ } from 'node:fs';
17
25
  import { createRequire } from 'node:module';
18
26
  import { homedir } from 'node:os';
19
- import { basename, dirname, extname, isAbsolute, join, relative } from 'node:path';
27
+ import { dirname, join } from 'node:path';
20
28
 
21
29
  import {
30
+ type Component,
31
+ Input,
22
32
  Key,
23
33
  SettingsList,
24
34
  type SettingItem,
35
+ decodeKittyPrintable,
36
+ getKeybindings,
25
37
  matchesKey,
26
38
  truncateToWidth,
27
39
  visibleWidth,
@@ -31,95 +43,33 @@ import { Type } from '@sinclair/typebox';
31
43
 
32
44
  const require = createRequire(import.meta.url);
33
45
 
34
- const GOOSEDUMP_VERSION = [0, 8, 1] as const;
35
-
36
- // One element of goosedump 0.8's `list` JSON output.
37
- interface ListEntryJson {
38
- id: string;
39
- parent: string | null;
40
- provider: string;
41
- cwd: string;
42
- count: number;
43
- label: string | null;
44
- from: string | null;
45
- until: string | null;
46
- native_id?: string;
47
- }
48
-
49
- interface GoosedumpListing {
50
- id: string;
51
- provider: string;
52
- cwd: string;
53
- parent: string | null;
54
- label: string | null;
55
- path: string | null;
56
- }
57
-
46
+ // One rendered message after normalizing a context into pi's native JSONL.
58
47
  interface GoosedumpMessage {
59
48
  id: string;
60
49
  role: string;
61
50
  content: string;
62
- score?: number;
63
51
  }
64
52
 
65
- interface GoosedumpSearchResult {
66
- messages: GoosedumpMessage[];
67
- page: number;
68
- totalPages: number;
69
- }
70
- interface ShowPartJson {
71
- type: 'text' | 'reasoning' | 'toolCall' | 'toolResult' | 'image' | 'bash' | 'passthrough';
53
+ // A `content` part of a pi-native message line.
54
+ interface PiPartJson {
55
+ type: string;
72
56
  text?: string;
57
+ thinking?: string;
73
58
  name?: string;
74
59
  arguments?: unknown;
75
- toolName?: string;
76
- content?: string;
77
- isError?: boolean;
78
60
  mimeType?: string;
79
- ocrText?: string;
80
- command?: string;
81
- output?: string;
82
- kind?: string;
83
- raw?: unknown;
84
- }
85
-
86
- interface ShowMessageJson {
87
- entryId: string;
88
- role: string;
89
- parts: ShowPartJson[];
61
+ data?: string;
90
62
  }
91
63
 
92
- interface ShowJson {
93
- messages: ShowMessageJson[];
94
- }
95
-
96
- interface HitJson {
97
- entryId: string;
98
- role: string;
99
- score: number;
100
- text: string;
101
- files: string[];
102
- terms: string[];
103
- }
104
-
105
- interface SearchJson {
106
- page: number;
107
- totalPages: number;
108
- total: number;
109
- hits: HitJson[];
110
- }
111
-
112
- interface CompactJson {
113
- goals: string[];
114
- files: { modified: string[]; created: string[]; read: string[] };
115
- commits: string[];
116
- outstanding: string[];
117
- preferences: string[];
118
- brief: string;
64
+ // One JSONL line from `show`/`grep`/`search`/`compact` rendered as pi native.
65
+ interface PiEntryJson {
66
+ type?: string;
67
+ id?: string;
68
+ message?: { role?: string; content?: PiPartJson[] | string };
69
+ summary?: string;
119
70
  }
120
71
 
121
72
  interface GoosedumpSettings {
122
- stepsBeforeCompact: number;
123
73
  turnsBeforeCompact: number;
124
74
  }
125
75
 
@@ -127,12 +77,9 @@ type SettingsScope = 'global' | 'project';
127
77
  type GoosedumpSettingKey = keyof GoosedumpSettings;
128
78
 
129
79
  const DEFAULT_GOOSEDUMP_SETTINGS: GoosedumpSettings = {
130
- stepsBeforeCompact: 0,
131
80
  turnsBeforeCompact: 0,
132
81
  };
133
82
 
134
- const COMPACT_SETTING_VALUES = ['0', '5', '10', '20', '50', '100'];
135
-
136
83
  function piAgentDir(): string {
137
84
  return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
138
85
  }
@@ -165,11 +112,9 @@ function readGoosedumpSettingsFrom(path: string): Partial<GoosedumpSettings> {
165
112
  if (!isRecord(raw)) return {};
166
113
 
167
114
  const settings: Partial<GoosedumpSettings> = {};
168
- for (const key of ['stepsBeforeCompact', 'turnsBeforeCompact'] as const) {
169
- const value = raw[key];
170
- if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
171
- settings[key] = value;
172
- }
115
+ const value = raw.turnsBeforeCompact;
116
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= 255) {
117
+ settings.turnsBeforeCompact = value;
173
118
  }
174
119
  return settings;
175
120
  }
@@ -206,48 +151,7 @@ function formatCompactSetting(value: number): string {
206
151
  }
207
152
 
208
153
  function resolveGoosedumpBinary(): string {
209
- try {
210
- return require.resolve('@jarkkojs/goosedump/bin/goosedump.js');
211
- } catch {
212
- const dir = join(
213
- process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'),
214
- 'package-cache',
215
- 'node_modules',
216
- '@jarkkojs',
217
- 'goosedump',
218
- );
219
- const bin = join(dir, 'bin', 'goosedump.js');
220
- if (existsSync(bin)) return bin;
221
-
222
- throw new Error('goosedump binary not found. Install with: npm install @jarkkojs/goosedump');
223
- }
224
- }
225
-
226
- function goosedumpVersion(): string | null {
227
- try {
228
- const pkg = require('@jarkkojs/goosedump/package.json') as { version: string };
229
- return pkg.version;
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
- function parseVersion(version: string): [number, number, number] | null {
236
- const match = version.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
237
- if (!match) return null;
238
- return [Number(match[1]), Number(match[2]), Number(match[3])];
239
- }
240
-
241
- function hasMinimumVersion(version: string, minimum: readonly [number, number, number]): boolean {
242
- const parsed = parseVersion(version);
243
- if (!parsed) return false;
244
-
245
- for (let i = 0; i < minimum.length; i++) {
246
- if (parsed[i] > minimum[i]) return true;
247
- if (parsed[i] < minimum[i]) return false;
248
- }
249
-
250
- return true;
154
+ return require.resolve('@jarkkojs/goosedump/bin/goosedump.js');
251
155
  }
252
156
 
253
157
  function clip(value: string, max: number): string {
@@ -271,47 +175,50 @@ function summarizeToolArgs(args: unknown): string {
271
175
  return clip(JSON.stringify(args), 160);
272
176
  }
273
177
 
274
- function showPartContent(part: ShowPartJson): string {
178
+ function flattenPiPart(part: PiPartJson): string {
275
179
  switch (part.type) {
276
180
  case 'text':
277
- case 'reasoning':
278
181
  return part.text ?? '';
182
+ case 'thinking':
183
+ return part.thinking ?? '';
279
184
  case 'toolCall': {
280
185
  const summary = summarizeToolArgs(part.arguments);
281
186
  return summary ? `${part.name ?? 'tool'}(${summary})` : (part.name ?? 'tool');
282
187
  }
283
- case 'toolResult': {
284
- const name = part.toolName ?? 'tool';
285
- const header = `${name}${part.isError ? ' ⚠' : ''}`;
286
- return part.content ? `${header}\n${part.content}` : header;
287
- }
288
- case 'image': {
289
- const header = `[image ${part.mimeType ?? 'unknown'}]`;
290
- return part.ocrText ? `${header}\n${part.ocrText}` : header;
291
- }
292
- case 'bash': {
293
- const cmd = part.command ? `$ ${part.command}` : '';
294
- if (cmd && part.output) return `${cmd}\n${part.output}`;
295
- return cmd || (part.output ?? '');
296
- }
297
- case 'passthrough': {
298
- const kind = part.kind ? `passthrough:${part.kind}` : 'passthrough';
299
- const raw = part.raw === undefined ? '' : JSON.stringify(part.raw);
300
- return raw ? `[${kind}] ${raw}` : `[${kind}]`;
301
- }
188
+ case 'image':
189
+ return part.mimeType ? `[image ${part.mimeType}]` : '[image]';
190
+ default:
191
+ return clip(JSON.stringify(part), 160);
302
192
  }
303
193
  }
304
194
 
305
- function showMessageContent(m: ShowMessageJson): string {
306
- return m.parts.map(showPartContent).filter(Boolean).join('\n');
307
- }
308
-
309
- function showMessage(m: ShowMessageJson): GoosedumpMessage {
310
- return { id: m.entryId, role: m.role, content: showMessageContent(m) };
311
- }
312
-
313
- function hitMessage(h: HitJson): GoosedumpMessage {
314
- return { id: h.entryId, role: h.role, content: h.text, score: h.score };
195
+ // goosedump's pi render emits a tool result as a message whose `content` is a
196
+ // plain string rather than a part array, so accept both shapes.
197
+ function flattenPiContent(content: PiPartJson[] | string | undefined): string {
198
+ if (typeof content === 'string') return content;
199
+ if (!Array.isArray(content)) return '';
200
+ return content.map(flattenPiPart).filter(Boolean).join('\n');
201
+ }
202
+
203
+ // goosedump 0.9 renders `show`/`grep`/`search` in a provider's native format.
204
+ // Passing `--as-provider pi` normalizes every provider's output to pi's JSONL,
205
+ // so one parser covers all sources: each `{"type":"message"}` line becomes a
206
+ // `GoosedumpMessage` with its `content[]` parts flattened to readable text.
207
+ function parsePiMessages(output: string): GoosedumpMessage[] {
208
+ const messages: GoosedumpMessage[] = [];
209
+ for (const line of output.split('\n')) {
210
+ if (!line) continue;
211
+ let entry: PiEntryJson;
212
+ try {
213
+ entry = JSON.parse(line) as PiEntryJson;
214
+ } catch {
215
+ continue;
216
+ }
217
+ if (entry.type !== 'message' || !entry.message) continue;
218
+ const content = flattenPiContent(entry.message.content);
219
+ messages.push({ id: entry.id ?? '', role: entry.message.role ?? 'unknown', content });
220
+ }
221
+ return messages;
315
222
  }
316
223
 
317
224
  const ANSI_SGR = new RegExp(String.fromCharCode(27) + '\\[[0-9;]*m', 'g');
@@ -334,64 +241,58 @@ function textResult(text: string): AgentToolResult<void> {
334
241
  return { content: [{ type: 'text', text }], details: undefined };
335
242
  }
336
243
 
337
- function missingContextId(action: string, provider = 'pi'): AgentToolResult<void> {
244
+ function missingSessionId(tool: string, provider = 'pi'): AgentToolResult<void> {
338
245
  if (provider !== 'pi') {
339
- return textResult(`contextId is required for ${action} when provider is "${provider}"`);
246
+ return textResult(`session is required for ${tool} when provider is "${provider}"`);
340
247
  }
341
- return textResult(`contextId is required for ${action} when no current session is available`);
248
+ return textResult(`session is required for ${tool} when no current session is available`);
342
249
  }
343
250
 
344
251
  function runGoosedump(args: string[]): string {
345
- return execFileSync('node', [resolveGoosedumpBinary(), ...args], {
346
- encoding: 'utf-8',
347
- });
348
- }
349
-
350
- function goosedumpList(provider = 'pi'): GoosedumpListing[] {
351
- const entries = JSON.parse(runGoosedump(['list'])) as ListEntryJson[];
352
- return entries
353
- .filter((entry) => entry.provider === provider)
354
- .map((entry) => {
355
- const nativeId = entry.native_id ?? entry.id;
356
- return {
357
- id: entry.id,
358
- provider: entry.provider,
359
- cwd: entry.cwd,
360
- parent: entry.parent,
361
- label: entry.label ?? null,
362
- // Only pi sessions map back to a local file we can resume.
363
- path: provider === 'pi' ? piSessionFilePath(nativeId) : null,
364
- };
252
+ try {
253
+ return execFileSync('node', [resolveGoosedumpBinary(), ...args], {
254
+ encoding: 'utf-8',
255
+ maxBuffer: 16 * 1024 * 1024,
365
256
  });
257
+ } catch (err) {
258
+ // Surface goosedump's own stderr instead of Node's truncated generic message.
259
+ const stderr = (err as { stderr?: string }).stderr?.trim();
260
+ const message = stderr ?? (err instanceof Error ? err.message : String(err));
261
+ throw new Error(message);
262
+ }
366
263
  }
367
264
 
368
- function piSessionsDir(): string {
369
- const explicit = process.env.PI_CODING_AGENT_SESSION_DIR;
370
- if (explicit) return explicit;
371
- const agentDir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
372
- return join(agentDir, 'sessions');
265
+ // Read only the first line of a file without loading the whole transcript.
266
+ function readFirstLine(path: string): string | null {
267
+ const fd = openSync(path, 'r');
268
+ try {
269
+ const buf = Buffer.alloc(8192);
270
+ const bytesRead = readSync(fd, buf, 0, buf.length, 0);
271
+ if (bytesRead < 0) return null;
272
+ const slice = buf.subarray(0, bytesRead);
273
+ const newline = slice.indexOf(0x0a);
274
+ return (newline < 0 ? slice : slice.subarray(0, newline)).toString('utf8');
275
+ } catch {
276
+ return null;
277
+ } finally {
278
+ closeSync(fd);
279
+ }
373
280
  }
374
281
 
375
- // goosedump derives a pi native id from the session file path relative to the
376
- // sessions base (without extension), so reconstruct the file path the same way.
377
- function piSessionFilePath(nativeId: string): string | null {
378
- const path = join(piSessionsDir(), `${nativeId}.jsonl`);
379
- return existsSync(path) ? path : null;
282
+ // goosedump 0.9 addresses every context as an unambiguous `provider:id` target.
283
+ function formatTarget(provider: string, id: string): string {
284
+ return `${provider}:${id}`;
380
285
  }
381
286
 
382
287
  function goosedumpSearch(
383
288
  target: string,
384
289
  query: string,
385
290
  options: { scope?: string; page?: number } = {},
386
- ): GoosedumpSearchResult {
291
+ ): GoosedumpMessage[] {
387
292
  const scope = options.scope ?? 'lineage';
388
- const args = ['search', target, query];
389
- if (options.page) args.push('--page', String(options.page));
390
- args.push('--scope', scope);
391
-
392
- const json = JSON.parse(runGoosedump(args)) as SearchJson;
393
- const messages = (json.hits ?? []).map(hitMessage);
394
- return { messages, page: json.page ?? options.page ?? 1, totalPages: json.totalPages ?? 1 };
293
+ const args = ['search', target, query, '--as-provider', 'pi', '--scope', scope];
294
+ if (options.page) args.push('-p', String(options.page));
295
+ return parsePiMessages(runGoosedump(args));
395
296
  }
396
297
 
397
298
  function goosedumpGrep(
@@ -400,10 +301,9 @@ function goosedumpGrep(
400
301
  options: { scope?: string } = {},
401
302
  ): GoosedumpMessage[] {
402
303
  const scope = options.scope ?? 'lineage';
403
- const json = JSON.parse(runGoosedump(['grep', target, pattern, '--scope', scope])) as {
404
- hits?: HitJson[];
405
- };
406
- return (json.hits ?? []).map(hitMessage);
304
+ return parsePiMessages(
305
+ runGoosedump(['grep', target, pattern, '--as-provider', 'pi', '--scope', scope]),
306
+ );
407
307
  }
408
308
 
409
309
  function goosedumpShow(
@@ -411,100 +311,44 @@ function goosedumpShow(
411
311
  options: { scope?: string; ids?: string[] } = {},
412
312
  ): GoosedumpMessage[] {
413
313
  const scope = options.scope ?? 'lineage';
414
- const args = ['show', target];
314
+ const args = ['show', target, '--as-provider', 'pi', '--scope', scope];
415
315
  for (const id of options.ids ?? []) args.push('-m', id);
416
- args.push('--scope', scope);
417
-
418
- const json = JSON.parse(runGoosedump(args)) as ShowJson;
419
- return (json.messages ?? []).map(showMessage);
316
+ return parsePiMessages(runGoosedump(args));
420
317
  }
421
318
 
422
- // goosedump accepts --output-format on show/grep/search to emit a context in a
423
- // provider's native, round-trippable format. That output is raw native text (not
424
- // the ShowJson/HitJson shapes), so this bypasses the JSON-parsing wrappers and
425
- // returns it verbatim. Only used when the caller explicitly sets outputFormat.
426
- function goosedumpConvert(
427
- subcommand: 'show' | 'grep' | 'search',
428
- target: string,
429
- outputFormat: string,
430
- options: { positionals?: string[]; scope?: string; ids?: string[]; page?: number } = {},
431
- ): string {
432
- const args = [subcommand, target, ...(options.positionals ?? [])];
433
- for (const id of options.ids ?? []) args.push('-m', id);
434
- if (options.page) args.push('--page', String(options.page));
435
- args.push('--scope', options.scope ?? 'lineage', '--output-format', outputFormat);
436
- return runGoosedump(args);
437
- }
438
-
439
- // Resolve a goosedump target id. In 0.7 every context is addressed by its 8-hex
440
- // index id, so an explicit contextId is already the target. It falls back to the
441
- // current session only for pi; other providers have no current session, so they
442
- // require an explicit contextId.
319
+ // Resolve a goosedump 0.9 `provider:id` target. An explicit contextId is the
320
+ // target id already; for pi it falls back to the current session. Other
321
+ // providers have no current session and require an explicit id.
443
322
  function resolveTarget(
444
323
  ctx: ExtensionContext,
445
324
  provider: string,
446
325
  contextId: string | undefined,
447
326
  ): string | null {
448
- if (contextId) return contextId;
449
- return provider === 'pi' ? currentSessionContextId(ctx) : null;
450
- }
451
-
452
- function capList(items: string[], limit: number): string {
453
- const shown = items.slice(0, limit).join(', ');
454
- return items.length > limit ? `${shown} (+${items.length - limit} more)` : shown;
455
- }
456
-
457
- function bulletSection(title: string, items: string[]): string | null {
458
- if (items.length === 0) return null;
459
- return `[${title}]\n${items.map((item) => `- ${item}`).join('\n')}`;
460
- }
461
-
462
- function renderCompactSummary(c: CompactJson): string {
463
- const fileLines: string[] = [];
464
- if (c.files.modified.length > 0) fileLines.push(`Modified: ${capList(c.files.modified, 10)}`);
465
- if (c.files.created.length > 0) fileLines.push(`Created: ${capList(c.files.created, 10)}`);
466
- if (c.files.read.length > 0) fileLines.push(`Read: ${capList(c.files.read, 10)}`);
467
-
468
- const parts = [
469
- bulletSection('Session Goal', c.goals),
470
- bulletSection('Files And Changes', fileLines),
471
- bulletSection('Commits', c.commits),
472
- bulletSection('Outstanding Context', c.outstanding),
473
- bulletSection('User Preferences', c.preferences),
474
- c.brief.trim() ? c.brief : null,
475
- ].filter((part): part is string => part !== null);
476
-
477
- return parts.join('\n\n---\n\n');
327
+ if (contextId) return formatTarget(provider, contextId);
328
+ if (provider === 'pi') return currentSessionTarget(ctx);
329
+ return null;
478
330
  }
479
331
 
480
332
  function goosedumpCompact(
481
333
  target: string,
482
- options: {
483
- scope?: string;
484
- from?: string;
485
- until?: string;
486
- ids?: string[];
487
- },
334
+ options: { scope?: string; from?: string; until: string },
488
335
  ): string {
489
336
  const scope = options.scope ?? 'lineage';
490
337
  const args = ['compact', target, '--scope', scope];
491
-
492
- if (options.ids && options.ids.length > 0) {
493
- for (const id of options.ids) args.push('-m', id);
494
- } else {
495
- if (options.from) args.push('--from', options.from);
496
- if (options.until) args.push('--until', options.until);
338
+ if (options.from) args.push('--from', options.from);
339
+ args.push('--until', options.until);
340
+
341
+ const output = runGoosedump(args);
342
+ for (const line of output.split('\n')) {
343
+ if (!line) continue;
344
+ try {
345
+ const entry = JSON.parse(line) as PiEntryJson;
346
+ if (entry.type === 'compaction' && typeof entry.summary === 'string') return entry.summary;
347
+ } catch {
348
+ continue;
349
+ }
497
350
  }
498
-
499
- const json = JSON.parse(runGoosedump(args)) as CompactJson;
500
- return renderCompactSummary(json);
501
- }
502
-
503
- function goosedumpDelete(id: string, options: { dryRun?: boolean } = {}): string {
504
- const args = ['delete'];
505
- if (options.dryRun) args.push('--dry-run');
506
- args.push(id);
507
- return runGoosedump(args);
351
+ return output.trim();
508
352
  }
509
353
 
510
354
  export interface GoosedumpIntegrationOptions {
@@ -516,18 +360,14 @@ interface GoosedumpCompactRange {
516
360
  scope: string;
517
361
  from?: string;
518
362
  until: string;
519
- ids?: string[];
520
363
  }
521
364
 
522
365
  interface GoosedumpCompactionDetails {
523
366
  source: 'goosedump';
524
- goosedumpVersion: string | null;
525
- contextId: string;
367
+ target: string;
526
368
  scope: string;
527
369
  from?: string;
528
- until?: string;
529
- ids?: string[];
530
- previousSummaryIncluded: boolean;
370
+ until: string;
531
371
  fileOps: unknown;
532
372
  }
533
373
 
@@ -539,34 +379,56 @@ export default function (pi: ExtensionAPI) {
539
379
  createGoosedumpIntegration().register(pi);
540
380
  }
541
381
 
542
- // Compute the pi native id goosedump assigns to a session file: its path relative
543
- // to the sessions base without the .jsonl extension, or the bare stem when the
544
- // file lives outside that base (mirroring goosedump's own fallback).
545
- function sessionFileNativeId(sessionFile: string): string {
546
- const rel = relative(piSessionsDir(), sessionFile);
547
- if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
548
- const name = basename(sessionFile);
549
- const ext = extname(name);
550
- return ext ? name.slice(0, -ext.length) : name;
551
- }
552
- const ext = extname(rel);
553
- return ext ? rel.slice(0, -ext.length) : rel;
554
- }
555
-
556
- // Resolve the current pi session to its goosedump 8-hex context id by matching the
557
- // session file's native id against goosedump's index (via `list`).
558
- function currentSessionContextId(ctx: ExtensionContext): string | null {
382
+ // Resolve the current pi session to its goosedump `pi:<id>` target. goosedump
383
+ // 0.9 uses the session header's own `id` field as the context id, so read it
384
+ // from the session file's first line and provider-qualify it; every target
385
+ // passed to the binary is `provider:id`, never bare.
386
+ function currentSessionTarget(ctx: ExtensionContext): string | null {
559
387
  const sessionFile = ctx.sessionManager.getSessionFile();
560
388
  if (!sessionFile) return null;
561
- const nativeId = sessionFileNativeId(sessionFile);
562
- const listing = goosedumpList('pi').find((entry) => entry.id === nativeId);
563
- return listing ? listing.id : null;
389
+ const line = readFirstLine(sessionFile);
390
+ if (!line) return null;
391
+ try {
392
+ const entry = JSON.parse(line) as PiEntryJson;
393
+ if (entry.type === 'session' && typeof entry.id === 'string') {
394
+ return formatTarget('pi', entry.id);
395
+ }
396
+ } catch {
397
+ return null;
398
+ }
399
+ return null;
564
400
  }
565
401
 
566
402
  function isCompactionEntry(entry: SessionEntry): entry is CompactionEntry {
567
403
  return entry.type === 'compaction';
568
404
  }
569
405
 
406
+ function isMessageEntry(entry: SessionEntry): entry is SessionEntry & {
407
+ type: 'message';
408
+ message: { role?: string };
409
+ } {
410
+ return entry.type === 'message';
411
+ }
412
+
413
+ // Resolution of the `keep:N` boundary: scan the active lineage from the newest
414
+ // entry backward, count user turns (those whose message role is `user`), and
415
+ // return the id of the Nth-from-last user entry — the earliest entry to keep.
416
+ // Returns undefined when there are fewer than `keep` user turns (nothing to
417
+ // summarize without orphaning the kept tail) or when every entry would be kept.
418
+ function keepBoundaryId(branchEntries: SessionEntry[], keep: number): string | undefined {
419
+ let seen = 0;
420
+ for (let i = branchEntries.length - 1; i >= 0; i -= 1) {
421
+ const entry = branchEntries[i];
422
+ if (isMessageEntry(entry) && entry.message.role === 'user') {
423
+ seen += 1;
424
+ if (seen === keep) {
425
+ return i === 0 ? undefined : entry.id;
426
+ }
427
+ }
428
+ }
429
+ return undefined;
430
+ }
431
+
570
432
  function latestCompactionBefore(
571
433
  branchEntries: SessionEntry[],
572
434
  untilIndex: number,
@@ -613,107 +475,8 @@ function buildCompactRange(
613
475
  : { scope: 'lineage', until: firstKeptEntryId };
614
476
  }
615
477
 
616
- const LEGACY_GOOSEDUMP_NOTE =
617
- 'Use `goosedump` to search prior active-lineage work, decisions, and context from before this summary. Do not redo work already completed.';
618
- const GOOSEDUMP_SEPARATOR = '\n\n---\n\n';
619
- const GOOSEDUMP_HEADERS = [
620
- 'Session Goal',
621
- 'Files And Changes',
622
- 'Commits',
623
- 'Outstanding Context',
624
- 'User Preferences',
625
- ] as const;
626
-
627
- function isGoosedumpSection(value: string): boolean {
628
- return GOOSEDUMP_HEADERS.some((header) => value.startsWith(`[${header}]`));
629
- }
630
-
631
- function stripGoosedumpRecallNote(summary: string): string {
632
- const idx = summary.lastIndexOf(LEGACY_GOOSEDUMP_NOTE);
633
- if (idx < 0) return summary.trim();
634
- return summary
635
- .slice(0, idx)
636
- .replace(/\s*(?:\n\n---\n\n)?\s*$/, '')
637
- .trim();
638
- }
639
-
640
- function sectionOf(summary: string, header: string): string {
641
- const tag = `[${header}]`;
642
- const start = summary.indexOf(tag);
643
- if (start < 0) return '';
644
-
645
- const rest = summary.slice(start);
646
- const nextHeader = GOOSEDUMP_HEADERS.filter((h) => h !== header)
647
- .map((h) => rest.indexOf(`[${h}]`))
648
- .filter((idx) => idx > 0);
649
- const nextSeparator = rest.indexOf(GOOSEDUMP_SEPARATOR);
650
- const candidates = [...nextHeader, ...(nextSeparator > 0 ? [nextSeparator] : [])].sort(
651
- (a, b) => a - b,
652
- );
653
- const end = candidates[0];
654
- return (end ? rest.slice(0, end) : rest).trim();
655
- }
656
-
657
- function briefOf(summary: string): string {
658
- return stripGoosedumpRecallNote(summary)
659
- .split(GOOSEDUMP_SEPARATOR)
660
- .map((part) => part.trim())
661
- .filter((part) => part && !isGoosedumpSection(part))
662
- .join(GOOSEDUMP_SEPARATOR);
663
- }
664
-
665
- function mergeSection(header: string, previous: string, next: string): string {
666
- if (header === 'Outstanding Context') return next;
667
- if (!previous) return next;
668
- if (!next) return previous;
669
-
670
- const lines = [
671
- ...new Set(
672
- [...previous.split('\n'), ...next.split('\n')].filter((line) => line.startsWith('- ')),
673
- ),
674
- ];
675
- const cap = header === 'Session Goal' ? 8 : header === 'User Preferences' ? 15 : 10;
676
- const capped = lines.length > cap ? lines.slice(-cap) : lines;
677
- return capped.length > 0 ? `[${header}]\n${capped.join('\n')}` : '';
678
- }
679
-
680
- function capBriefTranscript(brief: string): string {
681
- const lines = brief.split('\n');
682
- if (lines.length <= 120) return brief;
683
- const kept = lines.slice(-120);
684
- const firstHeader = kept.findIndex((line) => /^\[(user|assistant)\]$/.test(line));
685
- const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
686
- return `...(${lines.length - clean.length} earlier lines omitted)\n\n${clean.join('\n')}`;
687
- }
688
-
689
- function mergeWithPreviousSummary(
690
- summary: string,
691
- previousSummary: string | undefined,
692
- ): { summary: string; previousSummaryIncluded: boolean } {
693
- const nextSummary = stripGoosedumpRecallNote(summary);
694
- const priorSummary = previousSummary ? stripGoosedumpRecallNote(previousSummary) : '';
695
-
696
- if (!priorSummary) {
697
- return { summary: summary.trim(), previousSummaryIncluded: false };
698
- }
699
- if (!nextSummary) {
700
- return { summary: previousSummary?.trim() ?? '', previousSummaryIncluded: true };
701
- }
702
-
703
- const sections = GOOSEDUMP_HEADERS.map((header) =>
704
- mergeSection(header, sectionOf(priorSummary, header), sectionOf(nextSummary, header)),
705
- ).filter(Boolean);
706
- const brief = capBriefTranscript(
707
- [briefOf(priorSummary), briefOf(nextSummary)].filter(Boolean).join('\n\n'),
708
- );
709
- const parts = [...sections, ...(brief ? [brief] : [])];
710
- const merged = parts.join(GOOSEDUMP_SEPARATOR);
711
-
712
- return { summary: merged.trim(), previousSummaryIncluded: true };
713
- }
714
-
715
478
  function buildGoosedumpCompaction(
716
- contextId: string,
479
+ target: string,
717
480
  preparation: {
718
481
  firstKeptEntryId: string;
719
482
  tokensBefore: number;
@@ -721,103 +484,138 @@ function buildGoosedumpCompaction(
721
484
  fileOps: unknown;
722
485
  },
723
486
  branchEntries: SessionEntry[],
724
- ): CompactionResult<GoosedumpCompactionDetails> | undefined {
725
- const range = buildCompactRange(branchEntries, preparation.firstKeptEntryId);
487
+ keep?: number,
488
+ ): CompactionResult<GoosedumpCompactionDetails> | { cancel: true; reason: string } | undefined {
489
+ // A `keep:N` override replaces Pi's token-based cut with a turn-based one:
490
+ // summarize everything before the Nth-from-last user entry and keep the tail.
491
+ let firstKeptEntryId = preparation.firstKeptEntryId;
492
+ if (keep !== undefined && keep > 0) {
493
+ const id = keepBoundaryId(branchEntries, keep);
494
+ if (!id) {
495
+ return { cancel: true, reason: `fewer than ${keep} user turns; nothing to compact` };
496
+ }
497
+ firstKeptEntryId = id;
498
+ }
499
+
500
+ const range = buildCompactRange(branchEntries, firstKeptEntryId);
726
501
  if (!range) return undefined;
727
502
 
728
- const summary = goosedumpCompact(contextId, range);
729
- const merged = mergeWithPreviousSummary(summary, preparation.previousSummary);
730
- if (!merged.summary) return undefined;
503
+ const summary = goosedumpCompact(target, range);
504
+ if (!summary) return undefined;
731
505
 
732
506
  return {
733
- summary: merged.summary,
734
- firstKeptEntryId: preparation.firstKeptEntryId,
507
+ summary,
508
+ firstKeptEntryId,
735
509
  tokensBefore: preparation.tokensBefore,
736
510
  details: {
737
511
  source: 'goosedump',
738
- goosedumpVersion: goosedumpVersion(),
739
- contextId,
512
+ target,
740
513
  scope: range.scope,
741
514
  from: range.from,
742
515
  until: range.until,
743
- ids: range.ids,
744
- previousSummaryIncluded: merged.previousSummaryIncluded,
745
516
  fileOps: preparation.fileOps,
746
517
  },
747
518
  };
748
519
  }
749
520
 
750
- async function runSessionBrowser(
751
- ctx: ExtensionContext,
752
- listings: GoosedumpListing[],
753
- ): Promise<string | null | undefined> {
754
- let resumePath: string | null | undefined;
755
-
756
- const OVERLAY_WIDTH = 84;
757
- const LIST_VIEWPORT = 18;
758
- const COMPACT_VIEWPORT = 18;
759
- const location = ctx.cwd;
760
- const settingValues = COMPACT_SETTING_VALUES;
761
-
762
- function locationListings(): GoosedumpListing[] {
763
- return listings.filter((listing) => listing.cwd === location);
521
+ function isControlChar(char: string): boolean {
522
+ const code = char.charCodeAt(0);
523
+ return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
524
+ }
525
+
526
+ class NumberInputSubmenu implements Component {
527
+ private readonly input = new Input();
528
+ private readonly hint: (text: string) => string;
529
+ private readonly onSubmitValue: (value: string) => void;
530
+ private readonly onCancelEdit: () => void;
531
+ private readonly title: string;
532
+
533
+ constructor(
534
+ title: string,
535
+ initialValue: string,
536
+ onSubmit: (value: string) => void,
537
+ onCancel: () => void,
538
+ ) {
539
+ this.title = title;
540
+ this.onSubmitValue = onSubmit;
541
+ this.onCancelEdit = onCancel;
542
+ this.input.setValue(initialValue);
543
+ this.hint = getSettingsListTheme().hint;
764
544
  }
765
545
 
766
- function refreshListings(): void {
767
- listings.splice(0, listings.length, ...goosedumpList());
546
+ handleInput(data: string): void {
547
+ const kb = getKeybindings();
548
+ if (kb.matches(data, 'tui.select.confirm') || data === '\n') {
549
+ const digits = this.input.getValue().replace(/\D/g, '');
550
+ const parsed = Number(digits);
551
+ this.onSubmitValue(digits !== '' && Number.isSafeInteger(parsed) ? String(parsed) : '0');
552
+ return;
553
+ }
554
+ if (kb.matches(data, 'tui.select.cancel')) {
555
+ this.onCancelEdit();
556
+ return;
557
+ }
558
+ const kittyChar = decodeKittyPrintable(data);
559
+ const printable = kittyChar ?? (data.length === 1 && !isControlChar(data) ? data : undefined);
560
+ if (printable !== undefined && !/^[0-9]$/.test(printable)) return;
561
+ this.input.handleInput(data);
768
562
  }
769
563
 
770
- function shortPath(path: string, maxLen: number): string {
771
- if (path.length <= maxLen) return path;
772
- const head = Math.floor(maxLen / 2) + 2;
773
- const tail = maxLen - head - 3;
774
- if (tail < 4) return `${path.slice(0, maxLen - 3)}...`;
775
- return `${path.slice(0, head)}...${path.slice(path.length - tail)}`;
564
+ invalidate(): void {
565
+ this.input.invalidate();
776
566
  }
777
567
 
778
- function settingLabel(scope: SettingsScope, key: GoosedumpSettingKey): string {
779
- const scopeLabel = scope === 'global' ? 'Global' : 'Project';
780
- const keyLabel = key === 'stepsBeforeCompact' ? 'stepsBeforeCompact' : 'turnsBeforeCompact';
781
- return `${scopeLabel} ${keyLabel}`;
568
+ render(width: number): string[] {
569
+ return [
570
+ this.hint(` ${this.title}`),
571
+ '',
572
+ ...this.input.render(width),
573
+ '',
574
+ this.hint(' enter submit · esc cancel'),
575
+ ];
782
576
  }
577
+ }
783
578
 
784
- function buildSettingItems(): SettingItem[] {
785
- const global = readScopedGoosedumpSettings(location, 'global');
786
- const project = readScopedGoosedumpSettings(location, 'project');
787
- const settingsByScope = { global, project } satisfies Record<SettingsScope, GoosedumpSettings>;
788
-
789
- return (['global', 'project'] as const).flatMap((scope) =>
790
- (['stepsBeforeCompact', 'turnsBeforeCompact'] as const).map((key) => ({
791
- id: `${scope}:${key}`,
792
- label: settingLabel(scope, key),
793
- description:
794
- key === 'stepsBeforeCompact'
795
- ? 'Tool steps before goosedump starts compaction; 0 disables this trigger.'
796
- : 'LLM turns before goosedump starts compaction; 0 disables this trigger.',
797
- currentValue: String(settingsByScope[scope][key]),
798
- values: settingValues,
799
- })),
800
- );
801
- }
579
+ function makeRow(content: string, innerW: number, border: string): string {
580
+ const line = truncateToWidth(content, innerW);
581
+ const pad = Math.max(0, innerW - visibleWidth(line));
582
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
583
+ }
584
+
585
+ async function runSettingsEditor(ctx: ExtensionContext): Promise<void> {
586
+ const OVERLAY_WIDTH = 84;
587
+ const location = ctx.cwd;
802
588
 
803
589
  await ctx.ui.custom<void>(
804
590
  (tui, theme, _kb, done) => {
805
- let tab: 'sessions' | 'settings' = 'sessions';
806
- let selectedIndex = 0;
807
- let listScroll = 0;
808
- let mode: 'list' | 'compact' = 'list';
809
- let compactLines: string[] = [];
810
- let compactScroll = 0;
811
- let compactTitle = '';
812
- let pendingDeletionId: string | null = null;
813
591
  let settingsList: SettingsList;
814
592
 
815
- const wrapWidth = OVERLAY_WIDTH - 4;
593
+ function settingLabel(scope: SettingsScope, key: GoosedumpSettingKey): string {
594
+ const scopeLabel = scope === 'global' ? 'Global' : 'Project';
595
+ return `${scopeLabel} ${key}`;
596
+ }
816
597
 
817
- function resetSelection(): void {
818
- const count = locationListings().length;
819
- selectedIndex = Math.min(selectedIndex, Math.max(0, count - 1));
820
- listScroll = Math.min(listScroll, Math.max(0, count - LIST_VIEWPORT));
598
+ function buildSettingItems(): SettingItem[] {
599
+ const global = readScopedGoosedumpSettings(location, 'global');
600
+ const project = readScopedGoosedumpSettings(location, 'project');
601
+ const settingsByScope = { global, project } satisfies Record<
602
+ SettingsScope,
603
+ GoosedumpSettings
604
+ >;
605
+
606
+ return (['global', 'project'] as const).map((scope) => ({
607
+ id: `${scope}:turnsBeforeCompact`,
608
+ label: settingLabel(scope, 'turnsBeforeCompact'),
609
+ description: 'LLM turns before compaction; 0 disables.',
610
+ currentValue: String(settingsByScope[scope].turnsBeforeCompact),
611
+ submenu: (currentValue, subDone) =>
612
+ new NumberInputSubmenu(
613
+ settingLabel(scope, 'turnsBeforeCompact'),
614
+ currentValue,
615
+ (value) => subDone(value),
616
+ () => subDone(),
617
+ ),
618
+ }));
821
619
  }
822
620
 
823
621
  function rebuildSettingsList(): void {
@@ -828,305 +626,266 @@ async function runSessionBrowser(
828
626
  (id, newValue) => {
829
627
  const [scope, key] = id.split(':') as [SettingsScope, GoosedumpSettingKey];
830
628
  const parsed = Number(newValue);
831
- if (!Number.isSafeInteger(parsed) || parsed < 0) return;
629
+ if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 255) return;
832
630
  writeScopedGoosedumpSetting(location, scope, key, parsed);
833
631
  settingsList.updateValue(id, newValue);
834
632
  ctx.ui.notify(`${settingLabel(scope, key)} = ${formatCompactSetting(parsed)}`, 'info');
835
633
  tui.requestRender();
836
634
  },
837
635
  () => {
838
- tab = 'sessions';
839
- tui.requestRender();
636
+ done(undefined);
840
637
  },
841
638
  );
842
639
  }
843
640
 
844
641
  rebuildSettingsList();
845
642
 
846
- function executeDeletion(): void {
643
+ return {
644
+ render(width: number): string[] {
645
+ const innerW = width - 4;
646
+ const border = theme.fg('border', '│');
647
+ const borderFg = (s: string) => theme.fg('border', s);
648
+ const accent = (s: string) => theme.fg('accent', s);
649
+ const dim = (s: string) => theme.fg('dim', s);
650
+ const lines: string[] = [];
651
+ const title = accent(' Goosedump Settings ');
652
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
653
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
654
+ for (const line of settingsList.render(innerW)) lines.push(makeRow(line, innerW, border));
655
+ lines.push(makeRow('', innerW, border));
656
+ const effective = resolveGoosedumpSettings(location);
657
+ lines.push(
658
+ makeRow(
659
+ dim(`Effective: turns=${formatCompactSetting(effective.turnsBeforeCompact)}`),
660
+ innerW,
661
+ border,
662
+ ),
663
+ );
664
+ lines.push(makeRow(dim('↑↓ select enter/space edit esc close'), innerW, border));
665
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
666
+ return lines;
667
+ },
668
+ handleInput(data: string): void {
669
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
670
+ done(undefined);
671
+ return;
672
+ }
673
+ settingsList.handleInput(data);
674
+ tui.requestRender();
675
+ },
676
+ invalidate(): void {},
677
+ };
678
+ },
679
+ {
680
+ overlay: true,
681
+ overlayOptions: {
682
+ anchor: 'center',
683
+ width: OVERLAY_WIDTH,
684
+ margin: 2,
685
+ },
686
+ },
687
+ );
688
+ }
689
+
690
+ async function runSearchPanel(ctx: ExtensionContext, query: string): Promise<void> {
691
+ const OVERLAY_WIDTH = 84;
692
+ const VIEWPORT = 18;
693
+ const PAGE_SIZE = 5;
694
+ const wrapWidth = OVERLAY_WIDTH - 4;
695
+
696
+ await ctx.ui.custom<void>(
697
+ (tui, theme, _kb, done) => {
698
+ const target = currentSessionTarget(ctx);
699
+ const borderFg = (s: string) => theme.fg('border', s);
700
+ const border = theme.fg('border', '│');
701
+ const accent = (s: string) => theme.fg('accent', s);
702
+ const text = (s: string) => theme.fg('text', s);
703
+ const dim = (s: string) => theme.fg('dim', s);
704
+ const muted = (s: string) => theme.fg('muted', s);
705
+
706
+ let entries: GoosedumpMessage[] = [];
707
+ let error: string | null = null;
708
+ let mode: 'results' | 'detail' = 'results';
709
+ let selected = 0;
710
+ let scroll = 0;
711
+ let detailLines: string[] = [];
712
+ let detailScroll = 0;
713
+
714
+ function runSearch(): void {
847
715
  try {
848
- const id = pendingDeletionId!;
849
- goosedumpDelete(id);
850
- refreshListings();
851
- resetSelection();
852
- pendingDeletionId = null;
716
+ const first = goosedumpSearch(target!, query, { scope: 'lineage', page: 1 });
717
+ if (first.length === 0) {
718
+ entries = [];
719
+ error = `No results for "${query}"`;
720
+ selected = 0;
721
+ scroll = 0;
722
+ return;
723
+ }
724
+
725
+ const all: GoosedumpMessage[] = [...first];
726
+ for (let page = 2; page <= 10; page += 1) {
727
+ const result = goosedumpSearch(target!, query, { scope: 'lineage', page });
728
+ if (result.length === 0) break;
729
+ all.push(...result);
730
+ if (result.length < PAGE_SIZE) break;
731
+ }
732
+
733
+ entries = all;
734
+ error = null;
735
+ selected = 0;
736
+ scroll = 0;
853
737
  } catch (err) {
854
- const msg = err instanceof Error ? err.message : String(err);
855
- ctx.ui.notify(`Failed to delete session: ${msg}`, 'error');
856
- pendingDeletionId = null;
738
+ entries = [];
739
+ error = err instanceof Error ? err.message : String(err);
857
740
  }
858
- tui.requestRender();
859
- }
860
-
861
- function makeRow(content: string, innerW: number, border: string): string {
862
- const line = truncateToWidth(content, innerW);
863
- const pad = Math.max(0, innerW - visibleWidth(line));
864
- return `${border} ${line}${' '.repeat(pad)} ${border}`;
865
741
  }
866
742
 
867
- function renderTabs(innerW: number, border: string): string {
868
- const sessions = tab === 'sessions' ? theme.fg('accent', '[Sessions]') : '[Sessions]';
869
- const settings = tab === 'settings' ? theme.fg('accent', '[Settings]') : '[Settings]';
870
- return makeRow(`${sessions} ${settings}`, innerW, border);
743
+ if (!target) {
744
+ error = 'No current Pi session available.';
745
+ } else {
746
+ runSearch();
871
747
  }
872
748
 
873
- function renderSettings(width: number, innerW: number, border: string): string[] {
874
- const borderFg = (s: string) => theme.fg('border', s);
875
- const accent = (s: string) => theme.fg('accent', s);
876
- const dim = (s: string) => theme.fg('dim', s);
877
- const lines: string[] = [];
878
- const title = accent(' Goosedump Dashboard ');
879
- const topFill = borderFg(''.repeat(Math.max(0, width - 4 - visibleWidth(title))));
880
- lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
881
- lines.push(renderTabs(innerW, border));
882
- lines.push(
883
- makeRow(
884
- dim('Settings are written to ~/.pi/agent/settings.json and .pi/settings.json.'),
885
- innerW,
886
- border,
887
- ),
888
- );
889
- lines.push(makeRow('', innerW, border));
890
- for (const line of settingsList.render(innerW)) lines.push(makeRow(line, innerW, border));
891
- lines.push(makeRow('', innerW, border));
892
- const effective = resolveGoosedumpSettings(location);
893
- lines.push(
894
- makeRow(
895
- dim(
896
- `Effective: steps=${formatCompactSetting(effective.stepsBeforeCompact)} turns=${formatCompactSetting(effective.turnsBeforeCompact)}`,
897
- ),
898
- innerW,
899
- border,
900
- ),
901
- );
902
- lines.push(
903
- makeRow(dim('tab switch ↑↓ select enter/space cycle esc close'), innerW, border),
904
- );
905
- lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
906
- return lines;
749
+ function loadDetail(id: string): void {
750
+ try {
751
+ const messages = goosedumpShow(target!, { ids: [id] });
752
+ const content = messages.length > 0 ? messages[0].content : 'No content for this entry.';
753
+ detailLines = wrapTextWithAnsi(content, wrapWidth);
754
+ detailScroll = 0;
755
+ mode = 'detail';
756
+ } catch (err) {
757
+ const message = err instanceof Error ? err.message : String(err);
758
+ ctx.ui.notify(`goosedump error: ${message}`, 'error');
759
+ }
907
760
  }
908
761
 
909
762
  return {
910
763
  render(width: number): string[] {
911
764
  const innerW = width - 4;
912
- const border = theme.fg('border', '│');
913
- const borderFg = (s: string) => theme.fg('border', s);
914
- const dim = (s: string) => theme.fg('dim', s);
915
- const accent = (s: string) => theme.fg('accent', s);
916
- const text = (s: string) => theme.fg('text', s);
917
- const muted = (s: string) => theme.fg('muted', s);
765
+ const lines: string[] = [];
766
+ const title = accent(` Goosedump search: ${truncateToWidth(query, 44)} `);
767
+ const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
768
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
918
769
 
919
- if (mode === 'compact') {
920
- const lines: string[] = [];
921
- const title = accent(` Compact: ${truncateToWidth(compactTitle, 48)} `);
922
- const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
923
- lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
770
+ if (error) {
771
+ lines.push(makeRow(muted(error), innerW, border));
772
+ lines.push(makeRow(dim('esc close'), innerW, border));
773
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
774
+ return lines;
775
+ }
924
776
 
925
- const total = compactLines.length;
926
- const window = compactLines.slice(compactScroll, compactScroll + COMPACT_VIEWPORT);
777
+ if (mode === 'detail') {
778
+ const total = detailLines.length;
779
+ const window = detailLines.slice(detailScroll, detailScroll + VIEWPORT);
927
780
  for (const row of window) lines.push(makeRow(text(row), innerW, border));
928
- for (let i = window.length; i < COMPACT_VIEWPORT; i++)
929
- lines.push(makeRow('', innerW, border));
930
-
781
+ for (let i = window.length; i < VIEWPORT; i++) lines.push(makeRow('', innerW, border));
931
782
  const position =
932
- total > COMPACT_VIEWPORT
933
- ? `${compactScroll + 1}-${Math.min(compactScroll + COMPACT_VIEWPORT, total)} of ${total}`
783
+ total > VIEWPORT
784
+ ? `${detailScroll + 1}-${Math.min(detailScroll + VIEWPORT, total)} of ${total}`
934
785
  : `${total} line${total === 1 ? '' : 's'}`;
935
786
  lines.push(makeRow('', innerW, border));
936
- lines.push(makeRow(dim(`↑↓ scroll esc back (${position})`), innerW, border));
787
+ lines.push(makeRow(dim(`esc back ↑↓ scroll (${position})`), innerW, border));
937
788
  lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
938
789
  return lines;
939
790
  }
940
791
 
941
- if (tab === 'settings') return renderSettings(width, innerW, border);
942
-
943
- const currentListings = locationListings();
944
- const lines: string[] = [];
945
- const title = accent(' Goosedump Dashboard ');
946
- const topFill = borderFg('─'.repeat(Math.max(0, width - 4 - visibleWidth(title))));
947
- lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
948
- lines.push(renderTabs(innerW, border));
949
- lines.push(
950
- makeRow(muted(`Location: ${shortPath(location, innerW - 10)}`), innerW, border),
951
- );
952
- lines.push(
953
- makeRow(
954
- muted(
955
- `${currentListings.length} session${currentListings.length === 1 ? '' : 's'} here`,
956
- ),
957
- innerW,
958
- border,
959
- ),
960
- );
961
- lines.push(makeRow('', innerW, border));
962
-
963
- const window = currentListings.slice(listScroll, listScroll + LIST_VIEWPORT);
792
+ const maxScroll = Math.max(0, entries.length - VIEWPORT);
793
+ const clampScroll = Math.min(scroll, maxScroll);
794
+ const window = entries.slice(clampScroll, clampScroll + VIEWPORT);
964
795
  for (let i = 0; i < window.length; i++) {
965
- const absolute = listScroll + i;
966
- const listing = window[i];
967
- const isSelected = absolute === selectedIndex;
968
- const deletePending = pendingDeletionId !== null && listing.id === pendingDeletionId;
969
- const cursor = deletePending ? dim('␡') : isSelected ? accent('▶') : ' ';
970
- const idText = deletePending
971
- ? dim(listing.id)
972
- : isSelected
973
- ? accent(listing.id)
974
- : text(listing.id);
975
- const label = listing.label ? ` ${listing.label}` : '';
976
- const count = muted(
977
- ` (${listing.provider}, ${listing.path ? 'resumable' : 'no file'})`,
978
- );
979
- lines.push(makeRow(` ${cursor} ${idText}${muted(label)}${count}`, innerW, border));
980
- }
981
-
982
- for (let i = window.length; i < LIST_VIEWPORT; i++)
983
- lines.push(makeRow('', innerW, border));
984
-
985
- if (currentListings.length > LIST_VIEWPORT) {
986
- const position = `${listScroll + 1}-${Math.min(listScroll + LIST_VIEWPORT, currentListings.length)} of ${currentListings.length}`;
987
- lines.push(makeRow(dim(`(${position})`), innerW, border));
988
- }
989
-
990
- if (pendingDeletionId) {
991
- lines.push(makeRow('', innerW, border));
796
+ const absolute = clampScroll + i;
797
+ const entry = window[i];
798
+ const isSelected = absolute === selected;
799
+ const cursor = isSelected ? accent('▶') : ' ';
800
+ const idText = isSelected ? accent(entry.id) : text(entry.id);
801
+ const snippet = compactLine(entry.content);
992
802
  lines.push(
993
- makeRow(
994
- dim(`Delete session ${pendingDeletionId}? enter=confirm esc=cancel`),
995
- innerW,
996
- border,
997
- ),
803
+ makeRow(` ${cursor} ${idText} ${muted(entry.role)} ${dim(snippet)}`, innerW, border),
998
804
  );
999
805
  }
1000
-
806
+ for (let i = window.length; i < VIEWPORT; i++) lines.push(makeRow('', innerW, border));
807
+ const count = entries.length;
808
+ const position =
809
+ count > VIEWPORT
810
+ ? `${clampScroll + 1}-${Math.min(clampScroll + VIEWPORT, count)} of ${count}`
811
+ : `${count} entr${count === 1 ? 'y' : 'ies'}`;
1001
812
  lines.push(makeRow('', innerW, border));
1002
813
  lines.push(
1003
- makeRow(
1004
- dim(
1005
- 'tab settings ↑↓ navigate enter compact shift+enter resume ctrl+d delete esc close',
1006
- ),
1007
- innerW,
1008
- border,
1009
- ),
814
+ makeRow(dim(`↑↓ navigate enter expand esc close (${position})`), innerW, border),
1010
815
  );
1011
816
  lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
1012
817
  return lines;
1013
818
  },
1014
-
1015
819
  handleInput(data: string): void {
1016
- if (mode === 'compact') {
1017
- const maxScroll = Math.max(0, compactLines.length - COMPACT_VIEWPORT);
1018
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
1019
- mode = 'list';
820
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
821
+ if (mode === 'detail') {
822
+ mode = 'results';
1020
823
  tui.requestRender();
1021
824
  return;
1022
825
  }
826
+ done(undefined);
827
+ return;
828
+ }
829
+ if (error) return;
830
+
831
+ if (mode === 'detail') {
832
+ const maxScroll = Math.max(0, detailLines.length - VIEWPORT);
1023
833
  if (matchesKey(data, Key.up)) {
1024
- compactScroll = Math.max(0, compactScroll - 1);
834
+ detailScroll = Math.max(0, detailScroll - 1);
1025
835
  tui.requestRender();
1026
836
  return;
1027
837
  }
1028
838
  if (matchesKey(data, Key.down)) {
1029
- compactScroll = Math.min(maxScroll, compactScroll + 1);
839
+ detailScroll = Math.min(maxScroll, detailScroll + 1);
1030
840
  tui.requestRender();
1031
841
  return;
1032
842
  }
1033
843
  if (matchesKey(data, Key.pageUp)) {
1034
- compactScroll = Math.max(0, compactScroll - COMPACT_VIEWPORT);
844
+ detailScroll = Math.max(0, detailScroll - VIEWPORT);
1035
845
  tui.requestRender();
1036
846
  return;
1037
847
  }
1038
848
  if (matchesKey(data, Key.pageDown)) {
1039
- compactScroll = Math.min(maxScroll, compactScroll + COMPACT_VIEWPORT);
849
+ detailScroll = Math.min(maxScroll, detailScroll + VIEWPORT);
1040
850
  tui.requestRender();
1041
851
  return;
1042
852
  }
1043
853
  return;
1044
854
  }
1045
855
 
1046
- if (matchesKey(data, Key.tab) || matchesKey(data, Key.shift(Key.tab))) {
1047
- tab = tab === 'sessions' ? 'settings' : 'sessions';
1048
- if (tab === 'settings') rebuildSettingsList();
1049
- tui.requestRender();
1050
- return;
1051
- }
1052
-
1053
- if (tab === 'settings') {
1054
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
1055
- done(undefined);
1056
- return;
1057
- }
1058
- settingsList.handleInput(data);
1059
- tui.requestRender();
1060
- return;
1061
- }
1062
-
1063
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
1064
- done(undefined);
1065
- return;
1066
- }
1067
-
1068
- const currentListings = locationListings();
856
+ const maxScroll = Math.max(0, entries.length - VIEWPORT);
1069
857
  if (matchesKey(data, Key.up)) {
1070
- selectedIndex = Math.max(0, selectedIndex - 1);
1071
- if (selectedIndex < listScroll) listScroll = selectedIndex;
858
+ selected = Math.max(0, selected - 1);
859
+ if (selected < scroll) scroll = selected;
1072
860
  tui.requestRender();
1073
861
  return;
1074
862
  }
1075
-
1076
863
  if (matchesKey(data, Key.down)) {
1077
- selectedIndex = Math.min(Math.max(0, currentListings.length - 1), selectedIndex + 1);
1078
- if (selectedIndex >= listScroll + LIST_VIEWPORT) {
1079
- listScroll = selectedIndex - LIST_VIEWPORT + 1;
1080
- }
864
+ selected = Math.min(Math.max(0, entries.length - 1), selected + 1);
865
+ if (selected >= scroll + VIEWPORT) scroll = selected - VIEWPORT + 1;
1081
866
  tui.requestRender();
1082
867
  return;
1083
868
  }
1084
-
1085
- if (pendingDeletionId) {
1086
- if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
1087
- pendingDeletionId = null;
1088
- tui.requestRender();
1089
- return;
1090
- }
1091
- if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
1092
- executeDeletion();
1093
- return;
1094
- }
1095
- return;
1096
- }
1097
-
1098
- if (matchesKey(data, Key.ctrl('d'))) {
1099
- const listing = currentListings[selectedIndex];
1100
- if (listing) pendingDeletionId = listing.id;
869
+ if (matchesKey(data, Key.pageUp)) {
870
+ selected = Math.max(0, selected - VIEWPORT);
871
+ scroll = Math.max(0, Math.min(scroll, selected));
1101
872
  tui.requestRender();
1102
873
  return;
1103
874
  }
1104
-
1105
- if (matchesKey(data, Key.shift(Key.enter))) {
1106
- resumePath = currentListings[selectedIndex]?.path;
1107
- done(undefined);
875
+ if (matchesKey(data, Key.pageDown)) {
876
+ selected = Math.min(entries.length - 1, selected + VIEWPORT);
877
+ scroll = Math.min(maxScroll, Math.max(scroll, selected - VIEWPORT + 1));
878
+ tui.requestRender();
1108
879
  return;
1109
880
  }
1110
-
1111
881
  if (matchesKey(data, Key.enter) || matchesKey(data, Key.return)) {
1112
- const listing = currentListings[selectedIndex];
1113
- if (!listing) return;
1114
- compactTitle = listing.id;
1115
- compactScroll = 0;
1116
- try {
1117
- const summary = goosedumpCompact(listing.id, { scope: 'lineage' }).trim();
1118
- compactLines = summary
1119
- ? wrapTextWithAnsi(summary, wrapWidth)
1120
- : ['No compact summary available for this session.'];
1121
- } catch (err) {
1122
- const msg = err instanceof Error ? err.message : String(err);
1123
- compactLines = wrapTextWithAnsi(`Failed to compact session: ${msg}`, wrapWidth);
1124
- }
1125
- mode = 'compact';
882
+ const entry = entries[selected];
883
+ if (!entry) return;
884
+ loadDetail(entry.id);
1126
885
  tui.requestRender();
886
+ return;
1127
887
  }
1128
888
  },
1129
-
1130
889
  invalidate(): void {},
1131
890
  };
1132
891
  },
@@ -1139,8 +898,6 @@ async function runSessionBrowser(
1139
898
  },
1140
899
  },
1141
900
  );
1142
-
1143
- return resumePath;
1144
901
  }
1145
902
 
1146
903
  export function createGoosedumpIntegration(
@@ -1152,21 +909,21 @@ export function createGoosedumpIntegration(
1152
909
  let goosedumpAvailable = false;
1153
910
  let compactSettings = DEFAULT_GOOSEDUMP_SETTINGS;
1154
911
  let settingsSignature = '';
1155
- let stepsRemaining = 0;
1156
912
  let turnsRemaining = 0;
1157
913
  let counterTriggered = false;
1158
914
  let compactionQueued = false;
915
+ let pendingKeep: number | null = null;
1159
916
 
1160
917
  function compactSettingsSignature(settings: GoosedumpSettings): string {
1161
- return `${settings.stepsBeforeCompact}:${settings.turnsBeforeCompact}`;
918
+ return String(settings.turnsBeforeCompact);
1162
919
  }
1163
920
 
1164
921
  function resetCompactCounters(cwd: string): void {
1165
922
  compactSettings = resolveGoosedumpSettings(cwd);
1166
923
  settingsSignature = compactSettingsSignature(compactSettings);
1167
- stepsRemaining = compactSettings.stepsBeforeCompact;
1168
924
  turnsRemaining = compactSettings.turnsBeforeCompact;
1169
925
  counterTriggered = false;
926
+ pendingKeep = null;
1170
927
  }
1171
928
 
1172
929
  function reloadCompactSettings(cwd: string): void {
@@ -1175,13 +932,13 @@ export function createGoosedumpIntegration(
1175
932
  if (nextSignature !== settingsSignature) resetCompactCounters(cwd);
1176
933
  }
1177
934
 
1178
- function noteCompletedStep(ctx: ExtensionContext): void {
1179
- if (!goosedumpAvailable || !shouldOverrideDefaultCompaction) return;
1180
- reloadCompactSettings(ctx.cwd);
1181
- if (compactSettings.stepsBeforeCompact === 0) return;
1182
-
1183
- stepsRemaining -= 1;
1184
- if (stepsRemaining <= 0) counterTriggered = true;
935
+ function updateStatus(ctx: ExtensionContext): void {
936
+ if (!goosedumpAvailable) return;
937
+ const showCount = shouldOverrideDefaultCompaction && compactSettings.turnsBeforeCompact > 0;
938
+ const suffix = showCount
939
+ ? `: ${Math.max(0, turnsRemaining).toString(16).toUpperCase().padStart(2, '0')}`
940
+ : '';
941
+ ctx.ui.setStatus('goosedump', ctx.ui.theme.fg('text', `GD${suffix}`));
1185
942
  }
1186
943
 
1187
944
  function noteCompletedTurn(ctx: ExtensionContext): void {
@@ -1191,18 +948,26 @@ export function createGoosedumpIntegration(
1191
948
  turnsRemaining -= 1;
1192
949
  if (turnsRemaining <= 0) counterTriggered = true;
1193
950
  }
951
+ updateStatus(ctx);
1194
952
 
1195
953
  if (!counterTriggered || compactionQueued) return;
954
+ // ctx.compact() uses manual compaction semantics and aborts the current run.
955
+ // Wait until no queued work remains before triggering plugin-driven compaction.
956
+ if (ctx.hasPendingMessages()) return;
1196
957
 
1197
958
  resetCompactCounters(ctx.cwd);
959
+ updateStatus(ctx);
1198
960
  compactionQueued = true;
1199
961
  ctx.compact({
1200
962
  onComplete: () => {
1201
963
  compactionQueued = false;
1202
964
  resetCompactCounters(ctx.cwd);
965
+ updateStatus(ctx);
1203
966
  },
1204
967
  onError: (error) => {
1205
968
  compactionQueued = false;
969
+ resetCompactCounters(ctx.cwd);
970
+ updateStatus(ctx);
1206
971
  const message = error instanceof Error ? error.message : String(error);
1207
972
  if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1208
973
  },
@@ -1210,8 +975,10 @@ export function createGoosedumpIntegration(
1210
975
  }
1211
976
 
1212
977
  function checkGoosedump(ctx?: ExtensionContext): boolean {
1213
- const version = goosedumpVersion();
1214
- if (!version) {
978
+ try {
979
+ resolveGoosedumpBinary();
980
+ return true;
981
+ } catch {
1215
982
  if (ctx) {
1216
983
  ctx.ui.notify(
1217
984
  'goosedump was not found. Install with: npm install @jarkkojs/goosedump',
@@ -1220,84 +987,74 @@ export function createGoosedumpIntegration(
1220
987
  }
1221
988
  return false;
1222
989
  }
990
+ }
1223
991
 
1224
- if (!hasMinimumVersion(version, GOOSEDUMP_VERSION)) {
1225
- if (ctx) {
1226
- ctx.ui.notify(
1227
- `goosedump ${GOOSEDUMP_VERSION.join('.')} or newer is required; found: ${version}`,
1228
- 'error',
1229
- );
1230
- }
1231
- return false;
1232
- }
1233
-
1234
- return true;
992
+ // Shared trigger for on-demand goosedump compaction (from the /goose-compact
993
+ // command and the goose_compact tool). Stashes a turn-based keep override and
994
+ // fires Pi's compaction flow; the session_before_compact hook reads pendingKeep
995
+ // and resets it on use, completion, or error.
996
+ function requestCompaction(ctx: ExtensionContext, keep: number | null): void {
997
+ pendingKeep = keep;
998
+ ctx.compact({
999
+ onComplete: () => {
1000
+ pendingKeep = null;
1001
+ },
1002
+ onError: (error) => {
1003
+ pendingKeep = null;
1004
+ const message = error instanceof Error ? error.message : String(error);
1005
+ if (ctx.hasUI) ctx.ui.notify(`goosedump compact failed: ${message}`, 'error');
1006
+ },
1007
+ });
1235
1008
  }
1236
1009
 
1237
1010
  function register(pi: ExtensionAPI): void {
1238
1011
  if (shouldRegisterTool) {
1239
1012
  pi.registerTool(
1240
1013
  defineTool({
1241
- name: 'goosedump',
1242
- label: 'goosedump',
1014
+ name: 'goose_search',
1015
+ label: 'goose_search',
1243
1016
  description:
1244
- "Browse coding agent session history across providers. List sessions, or view/search/grep the current Pi session or a named session. Set provider to browse another tool's sessions (claude, codex, gemini, ...); set outputFormat for raw native output. Supports ranked search, glob filtering, compact overview, and result expansion. Default scope is active lineage.",
1017
+ 'Search coding agent session history by ranked relevance. Returns compact entry overviews with entry IDs; use goose_get to expand specific IDs to full content. Defaults to the current Pi session.',
1245
1018
  promptSnippet:
1246
- 'goosedump({ action: "search", query, contextId?, provider?, scope?, page? }) - search session history; omit contextId for current Pi session',
1019
+ 'goose_search({ query, provider?, session?, scope?, page? }) - rank messages by query relevance; defaults to current Pi session',
1247
1020
  promptGuidelines: [
1248
- 'When researching past conversation history, use goosedump to find relevant context.',
1249
- 'Start with compact ranked search (compact: true) for quick overview, then expand interesting entries.',
1021
+ "Use goose_search to find relevant context in this or another coding agent's session history.",
1022
+ 'Start with compact ranked results, then use goose_get to expand interesting entry IDs to full content.',
1250
1023
  'Default scope is "lineage" (current branch); use scope: "all" to include all entries in the session.',
1251
- 'Omit contextId to search, expand, or view the current Pi session; use list first for other sessions.',
1252
- 'Set provider (e.g. "claude", "gemini") with a contextId to browse another tool\'s sessions; results render the same way as Pi sessions.',
1253
- 'Set outputFormat (e.g. "claude", "json") only when you explicitly need the raw native session instead of the rendered view.',
1024
+ 'Omit session to search the current Pi session; set provider (e.g. "claude", "gemini") with a session id to browse another tool\'s sessions.',
1254
1025
  ],
1255
1026
  parameters: Type.Object({
1256
- action: Type.Optional(
1027
+ query: Type.String({ description: 'Search query to rank messages by relevance' }),
1028
+ provider: Type.Optional(
1257
1029
  Type.Union(
1258
1030
  [
1259
- Type.Literal('list'),
1260
- Type.Literal('search'),
1261
- Type.Literal('grep'),
1262
- Type.Literal('expand'),
1263
- Type.Literal('view'),
1031
+ Type.Literal('pi'),
1032
+ Type.Literal('claude'),
1033
+ Type.Literal('codex'),
1034
+ Type.Literal('crush'),
1035
+ Type.Literal('gemini'),
1036
+ Type.Literal('goose'),
1037
+ Type.Literal('opencode'),
1264
1038
  ],
1265
1039
  {
1266
- default: 'list',
1040
+ default: 'pi',
1267
1041
  description:
1268
- 'Operation: list sessions, search within a session, grep by glob pattern, expand entries, or view full session. Defaults to list.',
1042
+ 'Source provider whose sessions to search. Defaults to pi. Non-pi providers have no current session, so they require an explicit session id.',
1269
1043
  },
1270
1044
  ),
1271
1045
  ),
1272
- contextId: Type.Optional(
1046
+ session: Type.Optional(
1273
1047
  Type.String({
1274
1048
  description:
1275
- 'Context/session ID. Defaults to the current Pi session for search, grep, expand, view; required when provider is not pi.',
1049
+ 'Context/session ID. Defaults to the current Pi session for pi; required when provider is not pi.',
1276
1050
  }),
1277
1051
  ),
1278
- query: Type.Optional(
1279
- Type.String({ description: 'Search query to rank messages by relevance' }),
1280
- ),
1281
- pattern: Type.Optional(
1282
- Type.String({ description: 'Glob pattern to filter messages (e.g. *rand*)' }),
1283
- ),
1284
1052
  scope: Type.Optional(
1285
1053
  Type.Union([Type.Literal('lineage'), Type.Literal('all')], {
1286
1054
  default: 'lineage',
1287
1055
  description: 'Scope: lineage (current branch) or all entries in the session',
1288
1056
  }),
1289
1057
  ),
1290
- compact: Type.Optional(
1291
- Type.Boolean({
1292
- default: true,
1293
- description: 'Display results in compact entry-overview format',
1294
- }),
1295
- ),
1296
- ids: Type.Optional(
1297
- Type.Array(Type.String(), {
1298
- description: 'Entry IDs to expand (show full content)',
1299
- }),
1300
- ),
1301
1058
  page: Type.Optional(
1302
1059
  Type.Integer({
1303
1060
  minimum: 1,
@@ -1305,6 +1062,57 @@ export function createGoosedumpIntegration(
1305
1062
  description: 'Page number for ranked results',
1306
1063
  }),
1307
1064
  ),
1065
+ }),
1066
+ async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1067
+ if (!goosedumpAvailable) {
1068
+ return textResult(
1069
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1070
+ );
1071
+ }
1072
+
1073
+ try {
1074
+ const provider = params.provider ?? 'pi';
1075
+ const target = resolveTarget(ctx, provider, params.session);
1076
+ if (!target) return missingSessionId('goose_search', provider);
1077
+
1078
+ const page = params.page ?? 1;
1079
+ const messages = goosedumpSearch(target, params.query, {
1080
+ scope: params.scope ?? 'lineage',
1081
+ page,
1082
+ });
1083
+
1084
+ if (messages.length === 0) {
1085
+ return textResult(`No results for "${params.query}"`);
1086
+ }
1087
+
1088
+ const text = formatMessagesCompact(messages);
1089
+ const header = `Results for "${params.query}" (page ${page}):\n\n`;
1090
+ return textResult(header + text);
1091
+ } catch (err) {
1092
+ const message = err instanceof Error ? err.message : String(err);
1093
+ return textResult(`goosedump error: ${message}`);
1094
+ }
1095
+ },
1096
+ }),
1097
+ );
1098
+
1099
+ pi.registerTool(
1100
+ defineTool({
1101
+ name: 'goose_get',
1102
+ label: 'goose_get',
1103
+ description:
1104
+ 'Expand specific entry IDs (from goose_search or goose_grep) to their full content. Defaults to the current Pi session.',
1105
+ promptSnippet:
1106
+ 'goose_get({ ids, provider?, session? }) - expand entry IDs to full content; defaults to current Pi session',
1107
+ promptGuidelines: [
1108
+ 'Use goose_get to expand entry IDs returned by goose_search or goose_grep into their full content.',
1109
+ 'Pass only the specific IDs you need; full transcripts are expensive.',
1110
+ ],
1111
+ parameters: Type.Object({
1112
+ ids: Type.Array(Type.String(), {
1113
+ minItems: 1,
1114
+ description: 'Entry IDs to expand (show full content)',
1115
+ }),
1308
1116
  provider: Type.Optional(
1309
1117
  Type.Union(
1310
1118
  [
@@ -1319,28 +1127,97 @@ export function createGoosedumpIntegration(
1319
1127
  {
1320
1128
  default: 'pi',
1321
1129
  description:
1322
- 'Source provider whose sessions to browse (list, view, search, grep, expand). Defaults to pi. Non-pi providers have no current session, so they require an explicit contextId. Results render through the normal structured view regardless of provider.',
1130
+ 'Source provider whose sessions to read. Defaults to pi. Non-pi providers have no current session, so they require an explicit session id.',
1323
1131
  },
1324
1132
  ),
1325
1133
  ),
1326
- outputFormat: Type.Optional(
1134
+ session: Type.Optional(
1135
+ Type.String({
1136
+ description:
1137
+ 'Context/session ID. Defaults to the current Pi session for pi; required when provider is not pi.',
1138
+ }),
1139
+ ),
1140
+ scope: Type.Optional(
1141
+ Type.Union([Type.Literal('lineage'), Type.Literal('all')], {
1142
+ default: 'lineage',
1143
+ description: 'Scope: lineage (current branch) or all entries in the session',
1144
+ }),
1145
+ ),
1146
+ }),
1147
+ async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1148
+ if (!goosedumpAvailable) {
1149
+ return textResult(
1150
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1151
+ );
1152
+ }
1153
+
1154
+ try {
1155
+ const provider = params.provider ?? 'pi';
1156
+ const target = resolveTarget(ctx, provider, params.session);
1157
+ if (!target) return missingSessionId('goose_get', provider);
1158
+ const messages = goosedumpShow(target, {
1159
+ scope: params.scope ?? 'lineage',
1160
+ ids: params.ids,
1161
+ });
1162
+
1163
+ if (messages.length === 0) {
1164
+ return textResult('No entries found for the given IDs.');
1165
+ }
1166
+
1167
+ return textResult(formatMessagesFull(messages));
1168
+ } catch (err) {
1169
+ const message = err instanceof Error ? err.message : String(err);
1170
+ return textResult(`goosedump error: ${message}`);
1171
+ }
1172
+ },
1173
+ }),
1174
+ );
1175
+
1176
+ pi.registerTool(
1177
+ defineTool({
1178
+ name: 'goose_grep',
1179
+ label: 'goose_grep',
1180
+ description:
1181
+ 'Filter session messages by glob pattern (e.g. *rand*). Returns compact entry overviews with entry IDs; use goose_get to expand. Defaults to the current Pi session.',
1182
+ promptSnippet:
1183
+ 'goose_grep({ pattern, provider?, session?, scope? }) - filter messages by glob; defaults to current Pi session',
1184
+ promptGuidelines: [
1185
+ 'Use goose_grep to filter session messages by glob pattern when you need exact substring/glob matching instead of ranked relevance.',
1186
+ 'Expand interesting entry IDs with goose_get.',
1187
+ 'Default scope is "lineage" (current branch); use scope: "all" to include all entries in the session.',
1188
+ ],
1189
+ parameters: Type.Object({
1190
+ pattern: Type.String({ description: 'Glob pattern to filter messages (e.g. *rand*)' }),
1191
+ provider: Type.Optional(
1327
1192
  Type.Union(
1328
1193
  [
1329
- Type.Literal('json'),
1194
+ Type.Literal('pi'),
1330
1195
  Type.Literal('claude'),
1331
1196
  Type.Literal('codex'),
1332
1197
  Type.Literal('crush'),
1333
1198
  Type.Literal('gemini'),
1334
1199
  Type.Literal('goose'),
1335
1200
  Type.Literal('opencode'),
1336
- Type.Literal('pi'),
1337
1201
  ],
1338
1202
  {
1203
+ default: 'pi',
1339
1204
  description:
1340
- "Explicitly emit raw native output instead of the normal rendered view (view, expand, grep, search). JSONL providers (claude, codex, pi, gemini) emit raw session JSONL; SQLite providers (crush, goose, opencode) emit a JSON table projection; json emits goosedump's structured JSON. Omit to render normally.",
1205
+ 'Source provider whose sessions to grep. Defaults to pi. Non-pi providers have no current session, so they require an explicit session id.',
1341
1206
  },
1342
1207
  ),
1343
1208
  ),
1209
+ session: Type.Optional(
1210
+ Type.String({
1211
+ description:
1212
+ 'Context/session ID. Defaults to the current Pi session for pi; required when provider is not pi.',
1213
+ }),
1214
+ ),
1215
+ scope: Type.Optional(
1216
+ Type.Union([Type.Literal('lineage'), Type.Literal('all')], {
1217
+ default: 'lineage',
1218
+ description: 'Scope: lineage (current branch) or all entries in the session',
1219
+ }),
1220
+ ),
1344
1221
  }),
1345
1222
  async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1346
1223
  if (!goosedumpAvailable) {
@@ -1350,144 +1227,19 @@ export function createGoosedumpIntegration(
1350
1227
  }
1351
1228
 
1352
1229
  try {
1353
- switch (params.action ?? 'list') {
1354
- case 'list': {
1355
- const listings = goosedumpList(params.provider ?? 'pi');
1356
- if (listings.length === 0) {
1357
- return textResult('No sessions found.');
1358
- }
1359
-
1360
- const lines = ['Available sessions:', ''];
1361
- for (const listing of listings) {
1362
- const suffix = listing.label ? `${listing.cwd} ${listing.label}` : listing.cwd;
1363
- lines.push(` ${listing.id} ${suffix}`);
1364
- }
1365
-
1366
- return textResult(lines.join('\n'));
1367
- }
1368
-
1369
- case 'search': {
1370
- const provider = params.provider ?? 'pi';
1371
- const target = resolveTarget(ctx, provider, params.contextId);
1372
- if (!target) return missingContextId('search', provider);
1373
-
1374
- if (!params.query) return textResult('query is required for search');
1375
-
1376
- if (params.outputFormat) {
1377
- return textResult(
1378
- goosedumpConvert('search', target, params.outputFormat, {
1379
- positionals: [params.query],
1380
- scope: params.scope,
1381
- page: params.page ?? 1,
1382
- }),
1383
- );
1384
- }
1385
-
1386
- const result = goosedumpSearch(target, params.query, {
1387
- scope: params.scope ?? 'lineage',
1388
- page: params.page ?? 1,
1389
- });
1390
-
1391
- if (result.messages.length === 0) {
1392
- return textResult(`No results for "${params.query}"`);
1393
- }
1394
-
1395
- const useCompact = params.compact ?? true;
1396
- const text = useCompact
1397
- ? formatMessagesCompact(result.messages)
1398
- : formatMessagesFull(result.messages);
1399
-
1400
- const header = `Results for "${params.query}" (page ${result.page} of ${result.totalPages}):\n\n`;
1401
- return textResult(header + text);
1402
- }
1403
-
1404
- case 'grep': {
1405
- const provider = params.provider ?? 'pi';
1406
- const target = resolveTarget(ctx, provider, params.contextId);
1407
- if (!target) return missingContextId('grep', provider);
1408
-
1409
- if (!params.pattern) return textResult('pattern is required for grep');
1410
-
1411
- if (params.outputFormat) {
1412
- return textResult(
1413
- goosedumpConvert('grep', target, params.outputFormat, {
1414
- positionals: [params.pattern],
1415
- scope: params.scope,
1416
- }),
1417
- );
1418
- }
1419
-
1420
- const messages = goosedumpGrep(target, params.pattern, {
1421
- scope: params.scope ?? 'lineage',
1422
- });
1423
-
1424
- if (messages.length === 0) {
1425
- return textResult(`No matches for "${params.pattern}"`);
1426
- }
1427
-
1428
- const useCompact = params.compact ?? true;
1429
- const text = useCompact
1430
- ? formatMessagesCompact(messages)
1431
- : formatMessagesFull(messages);
1432
-
1433
- return textResult(text);
1434
- }
1435
-
1436
- case 'expand': {
1437
- const provider = params.provider ?? 'pi';
1438
- const target = resolveTarget(ctx, provider, params.contextId);
1439
- if (!target) return missingContextId('expand', provider);
1440
-
1441
- if (!params.ids || params.ids.length === 0) {
1442
- return textResult('ids is required for expand (array of entry IDs)');
1443
- }
1444
-
1445
- if (params.outputFormat) {
1446
- return textResult(
1447
- goosedumpConvert('show', target, params.outputFormat, {
1448
- ids: params.ids,
1449
- scope: params.scope,
1450
- }),
1451
- );
1452
- }
1453
-
1454
- const messages = goosedumpShow(target, {
1455
- scope: params.scope ?? 'lineage',
1456
- ids: params.ids,
1457
- });
1458
-
1459
- if (messages.length === 0) {
1460
- return textResult('No entries found for the given IDs.');
1461
- }
1462
-
1463
- return textResult(formatMessagesFull(messages));
1464
- }
1465
-
1466
- case 'view': {
1467
- const provider = params.provider ?? 'pi';
1468
- const target = resolveTarget(ctx, provider, params.contextId);
1469
- if (!target) return missingContextId('view', provider);
1470
-
1471
- if (params.outputFormat) {
1472
- return textResult(
1473
- goosedumpConvert('show', target, params.outputFormat, {
1474
- scope: params.scope,
1475
- }),
1476
- );
1477
- }
1478
-
1479
- const messages = goosedumpShow(target, { scope: params.scope ?? 'lineage' });
1480
-
1481
- if (messages.length === 0) {
1482
- return textResult(`Session "${target}" has no messages.`);
1483
- }
1484
-
1485
- return textResult(formatMessagesFull(messages));
1486
- }
1487
-
1488
- default:
1489
- return textResult(`Unknown action: ${params.action ?? 'list'}`);
1230
+ const provider = params.provider ?? 'pi';
1231
+ const target = resolveTarget(ctx, provider, params.session);
1232
+ if (!target) return missingSessionId('goose_grep', provider);
1233
+
1234
+ const messages = goosedumpGrep(target, params.pattern, {
1235
+ scope: params.scope ?? 'lineage',
1236
+ });
1237
+
1238
+ if (messages.length === 0) {
1239
+ return textResult(`No matches for "${params.pattern}"`);
1490
1240
  }
1241
+
1242
+ return textResult(formatMessagesCompact(messages));
1491
1243
  } catch (err) {
1492
1244
  const message = err instanceof Error ? err.message : String(err);
1493
1245
  return textResult(`goosedump error: ${message}`);
@@ -1495,22 +1247,52 @@ export function createGoosedumpIntegration(
1495
1247
  },
1496
1248
  }),
1497
1249
  );
1250
+
1251
+ pi.registerTool(
1252
+ defineTool({
1253
+ name: 'goose_compact',
1254
+ label: 'goose_compact',
1255
+ description:
1256
+ "Trigger a goosedump compaction of the current session now, optionally keeping the last N user turns verbatim while summarizing everything before. Compaction runs asynchronously via Pi's compaction flow.",
1257
+ promptSnippet:
1258
+ "goose_compact({ keep? }) - compact now; keep last N user turns (turn-based), omit to compact up to Pi's cut",
1259
+ promptGuidelines: [
1260
+ 'Use goose_compact to compact the session proactively with goosedump when context is large or nearing limits.',
1261
+ "Pass keep:N (N >= 1) to keep the last N user turns verbatim and summarize everything before them; omit to compact up to Pi's token-based cut.",
1262
+ 'Compaction is asynchronous; the session is notified when it completes.',
1263
+ ],
1264
+ parameters: Type.Object({
1265
+ keep: Type.Optional(
1266
+ Type.Integer({
1267
+ minimum: 1,
1268
+ description:
1269
+ "User turns to keep verbatim; summarize everything before them. Omit to compact up to Pi's token-based cut.",
1270
+ }),
1271
+ ),
1272
+ }),
1273
+ async execute(_id, params, _signal, _onUpdate, ctx): Promise<AgentToolResult<void>> {
1274
+ if (!goosedumpAvailable) {
1275
+ return textResult(
1276
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1277
+ );
1278
+ }
1279
+
1280
+ const keep = params.keep ?? null;
1281
+ requestCompaction(ctx, keep);
1282
+ return textResult(
1283
+ keep !== null
1284
+ ? `Compacting with goosedump; keeping the last ${keep} user turn${keep === 1 ? '' : 's'}.`
1285
+ : "Compacting with goosedump up to Pi's cut.",
1286
+ );
1287
+ },
1288
+ }),
1289
+ );
1498
1290
  }
1499
1291
 
1500
1292
  pi.on('session_start', async (_event, ctx) => {
1501
1293
  goosedumpAvailable = checkGoosedump(ctx);
1502
1294
  resetCompactCounters(ctx.cwd);
1503
-
1504
- if (goosedumpAvailable) {
1505
- const theme = ctx.ui.theme;
1506
- const dot = theme.fg('success', '●');
1507
- const label = theme.fg('text', 'Goosedump');
1508
- ctx.ui.setStatus('goosedump', `${dot} ${label}`);
1509
- }
1510
- });
1511
-
1512
- pi.on('tool_execution_end', async (_event, ctx) => {
1513
- noteCompletedStep(ctx);
1295
+ updateStatus(ctx);
1514
1296
  });
1515
1297
 
1516
1298
  pi.on('turn_end', async (_event, ctx) => {
@@ -1525,15 +1307,23 @@ export function createGoosedumpIntegration(
1525
1307
  const { messagesToSummarize, turnPrefixMessages } = event.preparation;
1526
1308
  if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0) return;
1527
1309
 
1528
- const contextId = currentSessionContextId(ctx);
1529
- if (!contextId) return;
1310
+ const target = currentSessionTarget(ctx);
1311
+ if (!target) return;
1530
1312
 
1531
1313
  try {
1314
+ const keep = pendingKeep ?? undefined;
1532
1315
  const compaction = buildGoosedumpCompaction(
1533
- contextId,
1316
+ target,
1534
1317
  event.preparation,
1535
1318
  event.branchEntries,
1319
+ keep,
1536
1320
  );
1321
+ if (compaction && 'cancel' in compaction) {
1322
+ pendingKeep = null;
1323
+ if (ctx.hasUI) ctx.ui.notify(compaction.reason, 'info');
1324
+ return { cancel: true };
1325
+ }
1326
+ pendingKeep = null;
1537
1327
  return compaction ? { compaction } : undefined;
1538
1328
  } catch (err) {
1539
1329
  if (ctx.hasUI) {
@@ -1569,8 +1359,8 @@ export function createGoosedumpIntegration(
1569
1359
  registerCommand?: ExtensionAPI['registerCommand'];
1570
1360
  };
1571
1361
 
1572
- maybePi.registerCommand?.('goosedump', {
1573
- description: 'Browse coding agent session history',
1362
+ maybePi.registerCommand?.('goose-settings', {
1363
+ description: 'Edit goosedump compaction settings',
1574
1364
  handler: async (_args, ctx) => {
1575
1365
  if (!goosedumpAvailable) {
1576
1366
  ctx.ui.notify(
@@ -1581,20 +1371,63 @@ export function createGoosedumpIntegration(
1581
1371
  }
1582
1372
 
1583
1373
  try {
1584
- const listings = goosedumpList();
1374
+ await runSettingsEditor(ctx);
1375
+ updateStatus(ctx);
1376
+ } catch (err) {
1377
+ const message = err instanceof Error ? err.message : String(err);
1378
+ ctx.ui.notify(`goosedump error: ${message}`, 'error');
1379
+ }
1380
+ },
1381
+ });
1382
+
1383
+ maybePi.registerCommand?.('goose-compact', {
1384
+ description: 'Compact now with goosedump; optionally keep the last N user turns',
1385
+ handler: async (args, ctx) => {
1386
+ if (!goosedumpAvailable) {
1387
+ ctx.ui.notify(
1388
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1389
+ 'error',
1390
+ );
1391
+ return;
1392
+ }
1585
1393
 
1586
- if (listings.length === 0) {
1587
- ctx.ui.notify('No sessions found.', 'info');
1394
+ // `/goose-compact [keep:N]` — an optional leading `keep:N` (N >= 1) keeps
1395
+ // the last N user turns verbatim and summarizes everything before; with
1396
+ // no argument goosedump summarizes up to Pi's token-based cut.
1397
+ let keep: number | null = null;
1398
+ if (typeof args === 'string') {
1399
+ const match = args.trim().match(/^keep:(\d+)$/);
1400
+ if (match) keep = Number(match[1]);
1401
+ if (match && (keep === null || keep < 1)) {
1402
+ ctx.ui.notify('keep:N requires N >= 1', 'error');
1588
1403
  return;
1589
1404
  }
1405
+ }
1590
1406
 
1591
- const resumePath = await runSessionBrowser(ctx, listings);
1407
+ requestCompaction(ctx, keep);
1408
+ },
1409
+ });
1592
1410
 
1593
- if (resumePath) {
1594
- await ctx.switchSession(resumePath);
1595
- } else if (resumePath === null) {
1596
- ctx.ui.notify('Cannot resume: session file path is unavailable.', 'error');
1597
- }
1411
+ maybePi.registerCommand?.('goose-search', {
1412
+ description: 'Search the current session history',
1413
+ handler: async (args, ctx) => {
1414
+ if (!goosedumpAvailable) {
1415
+ ctx.ui.notify(
1416
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
1417
+ 'error',
1418
+ );
1419
+ return;
1420
+ }
1421
+
1422
+ let query = typeof args === 'string' ? args.trim() : '';
1423
+ if (query.length === 0) {
1424
+ const input = await ctx.ui.input('Search query:', '');
1425
+ query = (input ?? '').trim();
1426
+ }
1427
+
1428
+ if (query.length === 0) return;
1429
+ try {
1430
+ await runSearchPanel(ctx, query);
1598
1431
  } catch (err) {
1599
1432
  const message = err instanceof Error ? err.message : String(err);
1600
1433
  ctx.ui.notify(`goosedump error: ${message}`, 'error');