pi-goosedump 0.1.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -0
  3. package/index.ts +828 -0
  4. package/package.json +57 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jarkko Sakkinen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # pi-goosedump
2
+
3
+ Coding agent context data browser plugin for [pi](https://pi.dev/) using
4
+ [`goosedump`](https://github.com/jarkkojs/goosedump).
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pi install npm:pi-goosedump
10
+ ```
11
+
12
+ This installs `pi-goosedump` and its `@jarkkojs/goosedump` dependency, which
13
+ includes platform-specific native binaries for Linux, macOS, and Windows.
14
+
15
+ ## Usage
16
+
17
+ Once installed, pi-goosedump registers a tool and a slash command:
18
+
19
+ ### Tool: `goosedump`
20
+
21
+ The agent can browse session history with:
22
+
23
+ | Action | Description |
24
+ | -------- | ------------------------------------------ |
25
+ | `list` | List all available sessions |
26
+ | `search` | Search within a session with regex pattern |
27
+ | `expand` | Show full content for specific entry IDs |
28
+ | `view` | View the full session transcript |
29
+
30
+ Examples:
31
+
32
+ ```
33
+ goosedump({ action: "list" })
34
+ goosedump({ action: "search", contextId: "abc123", query: "bug fix" })
35
+ goosedump({ action: "expand", contextId: "abc123", ids: [0, 3, 5] })
36
+ goosedump({ action: "view", contextId: "abc123" })
37
+ ```
38
+
39
+ ### Command: `/goosedump`
40
+
41
+ Opens an interactive session browser.
42
+
43
+ ## License
44
+
45
+ `pi-goosedump` is licensed under `MIT`. See [LICENSE](LICENSE) for more
46
+ information.
package/index.ts ADDED
@@ -0,0 +1,828 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (C) Jarkko Sakkinen 2026
3
+
4
+ import type {
5
+ AgentToolResult,
6
+ ExtensionAPI,
7
+ ExtensionContext,
8
+ } from '@earendil-works/pi-coding-agent';
9
+
10
+ import { defineTool } from '@earendil-works/pi-coding-agent';
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import { existsSync } from 'node:fs';
14
+ import { homedir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
18
+ import { Type } from '@sinclair/typebox';
19
+
20
+ const GOOSEDUMP_VERSION = [0, 1, 0] as const;
21
+
22
+ interface GoosedumpListing {
23
+ id: string;
24
+ created: string;
25
+ modified: string;
26
+ entryCount: number;
27
+ }
28
+
29
+ interface GoosedumpMessage {
30
+ id: string;
31
+ role: string;
32
+ content: string;
33
+ timestamp: string;
34
+ }
35
+
36
+ interface GoosedumpContextResult {
37
+ messages: GoosedumpMessage[];
38
+ totalCount: number;
39
+ page: number;
40
+ totalPages: number;
41
+ }
42
+
43
+ function resolveGoosedumpBinary(): string {
44
+ try {
45
+ return require.resolve('@jarkkojs/goosedump/bin/goosedump.js');
46
+ } catch {
47
+ const dir = join(
48
+ process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'),
49
+ 'package-cache',
50
+ 'node_modules',
51
+ '@jarkkojs',
52
+ 'goosedump',
53
+ );
54
+ const bin = join(dir, 'bin', 'goosedump.js');
55
+ if (existsSync(bin)) return bin;
56
+
57
+ throw new Error('goosedump binary not found. Install with: npm install @jarkkojs/goosedump');
58
+ }
59
+ }
60
+
61
+ function binaryPath(): string {
62
+ return resolveGoosedumpBinary();
63
+ }
64
+
65
+ function goosedumpVersion(): string | null {
66
+ try {
67
+ const result = execFileSync(process.execPath, [binaryPath(), '--version'], {
68
+ encoding: 'utf-8',
69
+ });
70
+ return result.trim();
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ function parseVersion(version: string): [number, number, number] | null {
77
+ const match = version.match(/\b(\d+)\.(\d+)\.(\d+)\b/);
78
+ if (!match) return null;
79
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
80
+ }
81
+
82
+ function hasMinimumVersion(version: string, minimum: readonly [number, number, number]): boolean {
83
+ const parsed = parseVersion(version);
84
+ if (!parsed) return false;
85
+
86
+ for (let i = 0; i < minimum.length; i++) {
87
+ if (parsed[i] > minimum[i]) return true;
88
+ if (parsed[i] < minimum[i]) return false;
89
+ }
90
+
91
+ return true;
92
+ }
93
+
94
+ function parseListings(output: string): GoosedumpListing[] {
95
+ const listings: GoosedumpListing[] = [];
96
+ // Format: <id> <created> <entryCount> entries <modified>
97
+ const regex = /^(\S+)\s+(\S+\s+\S+)\s+(\d+)\s+entries?\s+(.+)$/gm;
98
+ let match: RegExpExecArray | null;
99
+
100
+ while ((match = regex.exec(output)) !== null) {
101
+ listings.push({
102
+ id: match[1],
103
+ created: match[2],
104
+ entryCount: parseInt(match[3], 10),
105
+ modified: match[4],
106
+ });
107
+ }
108
+
109
+ return listings;
110
+ }
111
+
112
+ function parseMessages(output: string): GoosedumpMessage[] {
113
+ const messages: GoosedumpMessage[] = [];
114
+ // Format from goosedump plain output: [id] role: content
115
+ const lines = output.split('\n');
116
+ let currentId = '';
117
+ let currentRole = '';
118
+ let contentLines: string[] = [];
119
+ let currentTimestamp = '';
120
+
121
+ for (const line of lines) {
122
+ const headerMatch = line.match(/^\[([^\]]+)\]\s+(user|assistant|tool|system)\s*(\([^)]+\))?:/);
123
+ if (headerMatch) {
124
+ if (currentId) {
125
+ messages.push({
126
+ id: currentId,
127
+ role: currentRole,
128
+ content: contentLines.join('\n').trim(),
129
+ timestamp: currentTimestamp,
130
+ });
131
+ }
132
+ currentId = headerMatch[1];
133
+ currentRole = headerMatch[2];
134
+ currentTimestamp = headerMatch[3]?.replace(/[()]/g, '') ?? '';
135
+ contentLines = [line.slice(headerMatch[0].length).trim()];
136
+ } else if (currentId) {
137
+ if (line.startsWith('---') && messages.length > 0) continue;
138
+ contentLines.push(line);
139
+ }
140
+ }
141
+
142
+ if (currentId) {
143
+ messages.push({
144
+ id: currentId,
145
+ role: currentRole,
146
+ content: contentLines.join('\n').trim(),
147
+ timestamp: currentTimestamp,
148
+ });
149
+ }
150
+
151
+ return messages;
152
+ }
153
+
154
+ function formatMessagesCompact(messages: GoosedumpMessage[]): string {
155
+ return messages
156
+ .map(
157
+ (m) => `[${m.id}] ${m.role}: ${m.content.slice(0, 80)}${m.content.length > 80 ? '...' : ''}`,
158
+ )
159
+ .join('\n');
160
+ }
161
+
162
+ function formatMessagesFull(messages: GoosedumpMessage[]): string {
163
+ return messages.map((m) => `[${m.id}] ${m.role}:\n${m.content}`).join('\n\n---\n\n');
164
+ }
165
+
166
+ function goosedumpList(): GoosedumpListing[] {
167
+ const output = execFileSync(process.execPath, [binaryPath(), 'pi'], {
168
+ encoding: 'utf-8',
169
+ });
170
+ return parseListings(output);
171
+ }
172
+
173
+ function goosedumpContext(
174
+ contextId: string,
175
+ options: {
176
+ scope?: string;
177
+ grep?: string;
178
+ compact?: boolean;
179
+ ids?: string;
180
+ rank?: boolean;
181
+ page?: number;
182
+ } = {},
183
+ ): GoosedumpContextResult {
184
+ const args = ['pi', contextId];
185
+
186
+ if (options.scope && options.scope !== 'lineage') {
187
+ args.push('--scope', options.scope);
188
+ }
189
+ if (options.grep) {
190
+ args.push('--grep', options.grep);
191
+ }
192
+ if (options.compact) {
193
+ args.push('--compact');
194
+ }
195
+ if (options.ids) {
196
+ args.push('--ids', options.ids);
197
+ }
198
+ if (options.rank && options.grep) {
199
+ args.push('--rank');
200
+ if (options.page && options.page > 1) {
201
+ args.push('--page', String(options.page));
202
+ }
203
+ }
204
+
205
+ const output = execFileSync(process.execPath, [binaryPath(), ...args], {
206
+ encoding: 'utf-8',
207
+ });
208
+
209
+ const messages = parseMessages(output);
210
+ return {
211
+ messages,
212
+ totalCount: messages.length,
213
+ page: options.page ?? 1,
214
+ totalPages: 1,
215
+ };
216
+ }
217
+
218
+ function goosedumpExpand(
219
+ contextId: string,
220
+ entryIds: number[],
221
+ scope: string = 'lineage',
222
+ ): GoosedumpMessage[] {
223
+ const ids = entryIds.join(',');
224
+ const args = ['pi', contextId, '--ids', ids];
225
+ if (scope !== 'lineage') {
226
+ args.push('--scope', scope);
227
+ }
228
+
229
+ const output = execFileSync(process.execPath, [binaryPath(), ...args], {
230
+ encoding: 'utf-8',
231
+ });
232
+
233
+ return parseMessages(output);
234
+ }
235
+
236
+ export interface GoosedumpIntegrationOptions {
237
+ registerTool?: boolean;
238
+ overrideDefaultCompaction?: boolean;
239
+ }
240
+
241
+ interface MessageLike {
242
+ role?: string;
243
+ content?: string | { type: string; text?: string; source?: unknown }[];
244
+ }
245
+
246
+ interface ExtractedContext {
247
+ goal: string;
248
+ outstanding: string[];
249
+ references: string[];
250
+ decisions: string[];
251
+ constraints: string[];
252
+ status: string;
253
+ }
254
+
255
+ export interface GoosedumpIntegration {
256
+ register(pi: ExtensionAPI): void;
257
+ }
258
+
259
+ export default function (pi: ExtensionAPI) {
260
+ createGoosedumpIntegration().register(pi);
261
+ }
262
+
263
+ function getMessageText(m: MessageLike): string {
264
+ if (typeof m.content === 'string') return m.content;
265
+ if (Array.isArray(m.content)) {
266
+ return m.content
267
+ .filter((c) => c.type === 'text' && typeof c.text === 'string')
268
+ .map((c) => c.text!)
269
+ .join(' ');
270
+ }
271
+ return '';
272
+ }
273
+
274
+ function extractContextFromMessages(messages: MessageLike[]): ExtractedContext {
275
+ const goalPatterns = [
276
+ /(?:goal|objective|task|aim|purpose)[\s:]*([^.\n]+)/gi,
277
+ /(?:working on|implementing|building|creating|fixing|adding)\s+([^.\n]+)/gi,
278
+ /#\s*(?:Goal|Task|Objective)[\s:]*([^\n]+)/gi,
279
+ ];
280
+
281
+ const referencePatterns = [
282
+ /(?:https?:\/\/[^\s]+)/gi,
283
+ /(?:[\w./-]+\/[@\w.-]+(?:#\S+)?)/g,
284
+ /(?:`[^`]+\.[a-z]{1,6}`)/g,
285
+ /(?:v?\d+\.\d+(?:\.\d+)?(?:-[\w.]+)?)/g,
286
+ /(?:#[1-9]\d*)/g,
287
+ ];
288
+
289
+ const decisionPatterns = [
290
+ /(?:decided|decision|chosen?|opted|settled|agreed)\s+(?:to\s+)?([^.\n]+)/gi,
291
+ /(?:will|should|must|going to)\s+(?:use|switch|change|keep|stay|go with)\s+([^.\n]+)/gi,
292
+ /(?:let's|lets|we'll)\s+([^.\n]+)/gi,
293
+ ];
294
+
295
+ const constraintPatterns = [
296
+ /(?:cannot|can't|must not|should not|don't|do not|never)\s+([^.\n]+)/gi,
297
+ /(?:constraint|restriction|limitation|requirement)[\s:]*([^.\n]+)/gi,
298
+ /(?:need to|have to|required to)\s+(?:keep|maintain|preserve|ensure|avoid)\s+([^.\n]+)/gi,
299
+ ];
300
+
301
+ const statusPatterns = [
302
+ /(?:status|state|progress)[\s:]*([^.\n]+)/gi,
303
+ /(?:done|completed|finished|resolved|fixed)\s+([^.\n]+)/gi,
304
+ /(?:todo|pending|remaining|left|in progress|wip)\s*:?\s*([^.\n]+)/gi,
305
+ ];
306
+
307
+ const allText = messages.map((m) => getMessageText(m)).join('\n');
308
+
309
+ const recentText = messages
310
+ .slice(-30)
311
+ .map((m) => getMessageText(m))
312
+ .join('\n');
313
+
314
+ const extractMatches = (text: string, patterns: RegExp[]): Set<string> => {
315
+ const matches = new Set<string>();
316
+ for (const pattern of patterns) {
317
+ pattern.lastIndex = 0;
318
+ let match: RegExpExecArray | null;
319
+ while ((match = pattern.exec(text)) !== null) {
320
+ const extracted = (match[1] ?? '').trim().slice(0, 200);
321
+ if (extracted.length > 3) matches.add(extracted);
322
+ }
323
+ }
324
+ return matches;
325
+ };
326
+
327
+ const goalMatches = extractMatches(allText, goalPatterns);
328
+ const goal = goalMatches.size > 0 ? [...goalMatches][0] : 'Ongoing development work';
329
+
330
+ const outstandingItems = messages
331
+ .filter((m: MessageLike) => {
332
+ const text = getMessageText(m);
333
+ return /todo|pending|remaining|left|wip|bug|issue|fixme/gi.test(text);
334
+ })
335
+ .slice(-10)
336
+ .map((m: MessageLike) => {
337
+ const content = getMessageText(m);
338
+ const lines = content.split('\n').filter((l: string) => l.trim().length > 10);
339
+ return lines.length > 0 ? lines[0].trim().slice(0, 120) : null;
340
+ })
341
+ .filter((l: string | null): l is string => l !== null)
342
+ .slice(0, 8);
343
+
344
+ const referenceMatches = extractMatches(allText, referencePatterns);
345
+ const references = [...referenceMatches].slice(0, 15);
346
+
347
+ const decisionMatches = extractMatches(recentText, decisionPatterns);
348
+ const decisions = [...decisionMatches].slice(0, 6);
349
+
350
+ const constraintMatches = extractMatches(allText, constraintPatterns);
351
+ const constraints = [...constraintMatches].slice(0, 6);
352
+
353
+ const statusMatches = extractMatches(recentText, statusPatterns);
354
+ const status = statusMatches.size > 0 ? [...statusMatches][0] : 'In progress';
355
+
356
+ return {
357
+ goal,
358
+ outstanding:
359
+ outstandingItems.length > 0 ? outstandingItems : ['No outstanding items identified'],
360
+ references,
361
+ decisions,
362
+ constraints: constraints.length > 0 ? constraints : ['No specific constraints identified'],
363
+ status,
364
+ };
365
+ }
366
+
367
+ function formatCompactionSummary(ctx: ExtractedContext): string {
368
+ const sections: string[] = [];
369
+
370
+ sections.push(`## Session Goal\n${ctx.goal}`);
371
+
372
+ if (ctx.status) {
373
+ sections.push(`## Status\n${ctx.status}`);
374
+ }
375
+
376
+ if (ctx.outstanding.length > 0) {
377
+ sections.push(`## Outstanding Context\n${ctx.outstanding.map((o) => `- ${o}`).join('\n')}`);
378
+ }
379
+
380
+ if (ctx.decisions.length > 0) {
381
+ sections.push(`## Key Decisions\n${ctx.decisions.map((d) => `- ${d}`).join('\n')}`);
382
+ }
383
+
384
+ if (ctx.constraints.length > 0) {
385
+ sections.push(`## Constraints\n${ctx.constraints.map((c) => `- ${c}`).join('\n')}`);
386
+ }
387
+
388
+ if (ctx.references.length > 0) {
389
+ sections.push(`## References\n${ctx.references.map((r) => `- ${r}`).join('\n')}`);
390
+ }
391
+
392
+ return sections.join('\n\n');
393
+ }
394
+
395
+ export function createGoosedumpIntegration(
396
+ options: GoosedumpIntegrationOptions = {},
397
+ ): GoosedumpIntegration {
398
+ const shouldRegisterTool = options.registerTool ?? true;
399
+ const _overrideDefaultCompaction = options.overrideDefaultCompaction ?? false;
400
+
401
+ let goosedumpReady = false;
402
+ let goosedumpEnabled = false;
403
+
404
+ function checkGoosedump(ctx?: ExtensionContext): boolean {
405
+ const version = goosedumpVersion();
406
+ if (!version) {
407
+ if (ctx) {
408
+ ctx.ui.notify(
409
+ 'goosedump was not found. Install with: npm install @jarkkojs/goosedump',
410
+ 'error',
411
+ );
412
+ }
413
+ return false;
414
+ }
415
+
416
+ if (!hasMinimumVersion(version, GOOSEDUMP_VERSION)) {
417
+ if (ctx) {
418
+ ctx.ui.notify(
419
+ `goosedump ${GOOSEDUMP_VERSION.join('.')} or newer is required; found: ${version}`,
420
+ 'error',
421
+ );
422
+ }
423
+ return false;
424
+ }
425
+
426
+ return true;
427
+ }
428
+
429
+ function register(pi: ExtensionAPI): void {
430
+ if (shouldRegisterTool) {
431
+ pi.registerTool(
432
+ defineTool({
433
+ name: 'goosedump',
434
+ label: 'goosedump',
435
+ description:
436
+ 'Browse coding agent session history. List all sessions, or view/search within a session. Supports regex search, compact overview, and result expansion. Default scope is active lineage.',
437
+ promptSnippet: 'goosedump({ query, scope?, page? }) - search session history',
438
+ promptGuidelines: [
439
+ 'When researching past conversation history, use goosedump to find relevant context.',
440
+ 'Start with compact search (compact: true) for quick overview, then expand interesting entries.',
441
+ 'Default scope is "lineage" (current branch); use scope: "all" to search all sessions.',
442
+ ],
443
+ parameters: Type.Object({
444
+ action: Type.Union(
445
+ [
446
+ Type.Literal('list'),
447
+ Type.Literal('search'),
448
+ Type.Literal('expand'),
449
+ Type.Literal('view'),
450
+ ],
451
+ {
452
+ description:
453
+ 'Operation: list sessions, search within a session, expand entries, or view full session',
454
+ },
455
+ ),
456
+ contextId: Type.Optional(
457
+ Type.String({
458
+ description: 'Context/session ID (required for search, expand, view)',
459
+ }),
460
+ ),
461
+ query: Type.Optional(
462
+ Type.String({ description: 'Regex pattern to search for in messages' }),
463
+ ),
464
+ scope: Type.Optional(
465
+ Type.Union([Type.Literal('lineage'), Type.Literal('all')], {
466
+ default: 'lineage',
467
+ description: 'Scope: lineage (current branch) or all (all sessions)',
468
+ }),
469
+ ),
470
+ compact: Type.Optional(
471
+ Type.Boolean({
472
+ default: true,
473
+ description: 'Display results in compact entry-overview format',
474
+ }),
475
+ ),
476
+ ids: Type.Optional(
477
+ Type.Array(Type.Integer(), {
478
+ description: 'Entry IDs to expand (show full content)',
479
+ }),
480
+ ),
481
+ page: Type.Optional(
482
+ Type.Integer({
483
+ minimum: 1,
484
+ default: 1,
485
+ description: 'Page number for ranked results',
486
+ }),
487
+ ),
488
+ }),
489
+ async execute(_id, params, _signal, _onUpdate, _ctx): Promise<AgentToolResult<void>> {
490
+ if (!goosedumpEnabled || !goosedumpReady) {
491
+ return {
492
+ content: [
493
+ {
494
+ type: 'text',
495
+ text: 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
496
+ },
497
+ ],
498
+ details: undefined,
499
+ };
500
+ }
501
+
502
+ try {
503
+ switch (params.action) {
504
+ case 'list': {
505
+ const listings = goosedumpList();
506
+ if (listings.length === 0) {
507
+ return {
508
+ content: [{ type: 'text', text: 'No sessions found.' }],
509
+ details: undefined,
510
+ };
511
+ }
512
+
513
+ const lines = ['Available sessions:', ''];
514
+ for (const listing of listings) {
515
+ lines.push(
516
+ ` ${listing.id} — ${listing.entryCount} entries (modified ${listing.modified})`,
517
+ );
518
+ }
519
+
520
+ return {
521
+ content: [{ type: 'text', text: lines.join('\n') }],
522
+ details: undefined,
523
+ };
524
+ }
525
+
526
+ case 'search': {
527
+ if (!params.contextId) {
528
+ return {
529
+ content: [{ type: 'text', text: 'contextId is required for search' }],
530
+ details: undefined,
531
+ };
532
+ }
533
+
534
+ if (!params.query) {
535
+ return {
536
+ content: [{ type: 'text', text: 'query is required for search' }],
537
+ details: undefined,
538
+ };
539
+ }
540
+
541
+ const result = goosedumpContext(params.contextId, {
542
+ scope: params.scope ?? 'lineage',
543
+ grep: params.query,
544
+ compact: params.compact ?? true,
545
+ rank: true,
546
+ page: params.page ?? 1,
547
+ });
548
+
549
+ if (result.messages.length === 0) {
550
+ return {
551
+ content: [{ type: 'text', text: `No results for "${params.query}"` }],
552
+ details: undefined,
553
+ };
554
+ }
555
+
556
+ const text = params.compact
557
+ ? formatMessagesCompact(result.messages)
558
+ : formatMessagesFull(result.messages);
559
+
560
+ const header = `Results for "${params.query}" (page ${result.page} of ${result.totalPages}):\n\n`;
561
+ return {
562
+ content: [{ type: 'text', text: header + text }],
563
+ details: undefined,
564
+ };
565
+ }
566
+
567
+ case 'expand': {
568
+ if (!params.contextId) {
569
+ return {
570
+ content: [{ type: 'text', text: 'contextId is required for expand' }],
571
+ details: undefined,
572
+ };
573
+ }
574
+
575
+ if (!params.ids || params.ids.length === 0) {
576
+ return {
577
+ content: [
578
+ { type: 'text', text: 'ids is required for expand (array of entry IDs)' },
579
+ ],
580
+ details: undefined,
581
+ };
582
+ }
583
+
584
+ const messages = goosedumpExpand(
585
+ params.contextId,
586
+ params.ids,
587
+ params.scope ?? 'lineage',
588
+ );
589
+
590
+ if (messages.length === 0) {
591
+ return {
592
+ content: [{ type: 'text', text: 'No entries found for the given IDs.' }],
593
+ details: undefined,
594
+ };
595
+ }
596
+
597
+ return {
598
+ content: [{ type: 'text', text: formatMessagesFull(messages) }],
599
+ details: undefined,
600
+ };
601
+ }
602
+
603
+ case 'view': {
604
+ if (!params.contextId) {
605
+ return {
606
+ content: [{ type: 'text', text: 'contextId is required for view' }],
607
+ details: undefined,
608
+ };
609
+ }
610
+
611
+ const result = goosedumpContext(params.contextId, {
612
+ scope: params.scope ?? 'lineage',
613
+ compact: false,
614
+ });
615
+
616
+ if (result.messages.length === 0) {
617
+ return {
618
+ content: [
619
+ { type: 'text', text: `Session "${params.contextId}" has no messages.` },
620
+ ],
621
+ details: undefined,
622
+ };
623
+ }
624
+
625
+ return {
626
+ content: [{ type: 'text', text: formatMessagesFull(result.messages) }],
627
+ details: undefined,
628
+ };
629
+ }
630
+
631
+ default:
632
+ return {
633
+ content: [{ type: 'text', text: `Unknown action: ${params.action}` }],
634
+ details: undefined,
635
+ };
636
+ }
637
+ } catch (err) {
638
+ const message = err instanceof Error ? err.message : String(err);
639
+ return {
640
+ content: [{ type: 'text', text: `goosedump error: ${message}` }],
641
+ details: undefined,
642
+ };
643
+ }
644
+ },
645
+ }),
646
+ );
647
+ }
648
+
649
+ pi.on('session_start', async (_event, ctx) => {
650
+ goosedumpReady = checkGoosedump(ctx);
651
+ goosedumpEnabled = goosedumpReady;
652
+
653
+ if (goosedumpReady) {
654
+ const theme = ctx.ui.theme;
655
+ const dot = theme.fg('success', '●');
656
+ const label = theme.fg('text', 'Goosedump');
657
+ ctx.ui.setStatus('goosedump', `${dot} ${label}`);
658
+ }
659
+ });
660
+
661
+ pi.on('session_before_compact', async (event, _ctx) => {
662
+ if (!goosedumpEnabled || !goosedumpReady) return;
663
+
664
+ const { messagesToSummarize, firstKeptEntryId, tokensBefore } = event.preparation;
665
+
666
+ if (messagesToSummarize.length === 0) return;
667
+
668
+ const extracted = extractContextFromMessages(messagesToSummarize as MessageLike[]);
669
+ const summary = formatCompactionSummary(extracted);
670
+
671
+ return {
672
+ compaction: {
673
+ summary,
674
+ firstKeptEntryId,
675
+ tokensBefore,
676
+ details: {
677
+ source: 'goosedump',
678
+ extracted,
679
+ },
680
+ },
681
+ };
682
+ });
683
+
684
+ pi.on('session_compact', async (event, _ctx) => {
685
+ if (!goosedumpEnabled || !goosedumpReady) return;
686
+
687
+ const compactionEntry = event.compactionEntry;
688
+ if (!compactionEntry || compactionEntry.fromHook) return;
689
+
690
+ const tokensBefore = compactionEntry.tokensBefore;
691
+ const summaryLen = compactionEntry.summary.length;
692
+ const tokenEstimate = Math.ceil(summaryLen / 4);
693
+ const tokensSaved =
694
+ tokensBefore > 0 ? Math.round((1 - tokenEstimate / tokensBefore) * 100) : 0;
695
+
696
+ _ctx.ui.notify(
697
+ `Goosedump compacted session: ~${tokensSaved}% token reduction (${tokensBefore} → ~${tokenEstimate} tokens)`,
698
+ 'info',
699
+ );
700
+ });
701
+
702
+ const maybePi = pi as ExtensionAPI & {
703
+ registerCommand?: ExtensionAPI['registerCommand'];
704
+ };
705
+
706
+ maybePi.registerCommand?.('goosedump', {
707
+ description: 'Browse coding agent session history',
708
+ handler: async (_args, ctx) => {
709
+ if (!goosedumpEnabled || !goosedumpReady) {
710
+ ctx.ui.notify(
711
+ 'goosedump is not available. Install with: npm install @jarkkojs/goosedump',
712
+ 'error',
713
+ );
714
+ return;
715
+ }
716
+
717
+ try {
718
+ const listings = goosedumpList();
719
+
720
+ if (listings.length === 0) {
721
+ ctx.ui.notify('No sessions found.', 'info');
722
+ return;
723
+ }
724
+
725
+ await ctx.ui.custom<void>(
726
+ (tui, theme, _kb, done) => {
727
+ let selectedIndex = 0;
728
+
729
+ return {
730
+ render(width: number): string[] {
731
+ const innerW = width - 4;
732
+ const border = theme.fg('border', '│');
733
+ const borderFg = (s: string) => theme.fg('border', s);
734
+ const dim = (s: string) => theme.fg('dim', s);
735
+ const accent = (s: string) => theme.fg('accent', s);
736
+ const text = (s: string) => theme.fg('text', s);
737
+ const muted = (s: string) => theme.fg('muted', s);
738
+
739
+ const lines: string[] = [];
740
+
741
+ // Top border
742
+ const title = accent(' Goosedump Sessions ');
743
+ const topFill = borderFg(
744
+ '─'.repeat(Math.max(0, width - 4 - visibleWidth(title))),
745
+ );
746
+ lines.push(`${borderFg('╭─')}${title}${topFill}${borderFg('─╮')}`);
747
+
748
+ // Session count
749
+ const countInfo = `${listings.length} session${listings.length === 1 ? '' : 's'}`;
750
+ lines.push(makeRow(`${muted(countInfo)}`, innerW, border));
751
+
752
+ lines.push(makeRow('', innerW, border));
753
+
754
+ // Session list
755
+ const maxItems = Math.min(listings.length, 20);
756
+ for (let i = 0; i < maxItems; i++) {
757
+ const listing = listings[i];
758
+ const isSelected = i === selectedIndex;
759
+ const cursor = isSelected ? accent('▶') : ' ';
760
+ const entryInfo = dim(`${listing.entryCount} entries`);
761
+ const idText = isSelected ? accent(`[${listing.id}]`) : text(`[${listing.id}]`);
762
+ const line = ` ${cursor} ${idText} ${entryInfo}`;
763
+ lines.push(makeRow(truncateToWidth(line, innerW), innerW, border));
764
+ }
765
+
766
+ if (listings.length > 20) {
767
+ lines.push(
768
+ makeRow(dim(` ... and ${listings.length - 20} more`), innerW, border),
769
+ );
770
+ }
771
+
772
+ // Footer
773
+ lines.push(makeRow('', innerW, border));
774
+ lines.push(makeRow(dim('↑↓ navigate esc close'), innerW, border));
775
+
776
+ // Bottom border
777
+ lines.push(`${borderFg('╰')}${borderFg('─'.repeat(width - 2))}${borderFg('╯')}`);
778
+
779
+ return lines;
780
+ },
781
+
782
+ handleInput(data: string): void {
783
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) {
784
+ done(undefined);
785
+ return;
786
+ }
787
+
788
+ if (matchesKey(data, Key.up)) {
789
+ selectedIndex = Math.max(0, selectedIndex - 1);
790
+ tui.requestRender();
791
+ return;
792
+ }
793
+
794
+ if (matchesKey(data, Key.down)) {
795
+ selectedIndex = Math.min(listings.length - 1, selectedIndex + 1);
796
+ tui.requestRender();
797
+ return;
798
+ }
799
+ },
800
+
801
+ invalidate(): void {},
802
+ };
803
+
804
+ function makeRow(content: string, innerW: number, border: string): string {
805
+ const line = truncateToWidth(content, innerW);
806
+ const pad = Math.max(0, innerW - visibleWidth(line));
807
+ return `${border} ${line}${' '.repeat(pad)} ${border}`;
808
+ }
809
+ },
810
+ {
811
+ overlay: true,
812
+ overlayOptions: {
813
+ anchor: 'center',
814
+ width: 72,
815
+ margin: 2,
816
+ },
817
+ },
818
+ );
819
+ } catch (err) {
820
+ const message = err instanceof Error ? err.message : String(err);
821
+ ctx.ui.notify(`goosedump error: ${message}`, 'error');
822
+ }
823
+ },
824
+ });
825
+ }
826
+
827
+ return { register };
828
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "pi-goosedump",
3
+ "version": "0.1.0",
4
+ "description": "Coding agent context data browser plugin for pi",
5
+ "keywords": [
6
+ "goosedump",
7
+ "pi-extension",
8
+ "pi-package",
9
+ "vcc"
10
+ ],
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/jarkkojs/pi-goosedump.git"
15
+ },
16
+ "files": [
17
+ "index.ts",
18
+ "README.md"
19
+ ],
20
+ "type": "module",
21
+ "scripts": {
22
+ "fmt": "oxfmt index.ts package.json tsconfig.json .oxfmtrc.json README.md",
23
+ "lint": "oxlint index.ts",
24
+ "check": "tsc --noEmit",
25
+ "all": "npm run fmt && npm run lint && npm run check",
26
+ "ci:fmt": "oxfmt --check index.ts package.json tsconfig.json .oxfmtrc.json README.md",
27
+ "ci:lint": "npm run lint",
28
+ "ci:check": "npm run check"
29
+ },
30
+ "dependencies": {
31
+ "@earendil-works/pi-tui": "^0.78.0",
32
+ "@sinclair/typebox": "^0.34.49"
33
+ },
34
+ "devDependencies": {
35
+ "@earendil-works/pi-coding-agent": "^0.78.0",
36
+ "@types/node": "^24.0.0",
37
+ "oxfmt": "^0.53.0",
38
+ "oxlint": "^1.68.0",
39
+ "typescript": "^5.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-coding-agent": "^0.78.0"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@earendil-works/pi-coding-agent": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "optionalDependencies": {
50
+ "@jarkkojs/goosedump": "^0.1.2"
51
+ },
52
+ "pi": {
53
+ "extensions": [
54
+ "./index.ts"
55
+ ]
56
+ }
57
+ }