@gi4nks/ph 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,3885 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import os6 from "os";
5
+ import path6 from "path";
6
+ import fs7 from "fs";
7
+ import { spawnSync } from "child_process";
8
+ import React7 from "react";
9
+ import { render } from "ink";
10
+
11
+ // src/config/index.ts
12
+ import fs from "fs";
13
+ import path from "path";
14
+ import os from "os";
15
+ var CONFIG_PATH = path.join(os.homedir(), ".ph_config.json");
16
+ function load() {
17
+ try {
18
+ if (fs.existsSync(CONFIG_PATH)) {
19
+ return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf-8"));
20
+ }
21
+ } catch (_) {
22
+ }
23
+ return {};
24
+ }
25
+ function save(cfg) {
26
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 384 });
27
+ }
28
+
29
+ // src/db/index.ts
30
+ import Database from "better-sqlite3";
31
+ import path2 from "path";
32
+ import os2 from "os";
33
+ import { createHash } from "crypto";
34
+ function defaultPath() {
35
+ return path2.join(os2.homedir(), ".prompt_history.db");
36
+ }
37
+ var PhDB = class {
38
+ db;
39
+ constructor(dbPath) {
40
+ this.db = new Database(dbPath);
41
+ this.db.pragma("journal_mode = WAL");
42
+ this.migrate();
43
+ }
44
+ migrate() {
45
+ this.db.exec(`
46
+ CREATE TABLE IF NOT EXISTS prompts (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ timestamp TEXT NOT NULL,
49
+ tool TEXT NOT NULL,
50
+ prompt TEXT NOT NULL,
51
+ args TEXT NOT NULL DEFAULT '',
52
+ workdir TEXT NOT NULL DEFAULT '',
53
+ hostname TEXT NOT NULL DEFAULT '',
54
+ exit_code INTEGER NOT NULL DEFAULT 0,
55
+ metadata TEXT NOT NULL DEFAULT '{}'
56
+ );
57
+
58
+ CREATE VIRTUAL TABLE IF NOT EXISTS prompts_fts USING fts5(
59
+ prompt,
60
+ tool UNINDEXED,
61
+ content='prompts',
62
+ content_rowid='id'
63
+ );
64
+
65
+ CREATE TRIGGER IF NOT EXISTS prompts_ai AFTER INSERT ON prompts BEGIN
66
+ INSERT INTO prompts_fts(rowid, prompt, tool) VALUES (new.id, new.prompt, new.tool);
67
+ END;
68
+
69
+ CREATE TRIGGER IF NOT EXISTS prompts_ad AFTER DELETE ON prompts BEGIN
70
+ INSERT INTO prompts_fts(prompts_fts, rowid, prompt, tool)
71
+ VALUES('delete', old.id, old.prompt, old.tool);
72
+ END;
73
+
74
+ CREATE INDEX IF NOT EXISTS idx_prompts_tool ON prompts(tool);
75
+ CREATE INDEX IF NOT EXISTS idx_prompts_timestamp ON prompts(timestamp);
76
+
77
+ CREATE TABLE IF NOT EXISTS embeddings (
78
+ prompt_id INTEGER PRIMARY KEY,
79
+ vector BLOB NOT NULL,
80
+ FOREIGN KEY(prompt_id) REFERENCES prompts(id) ON DELETE CASCADE
81
+ );
82
+ `);
83
+ try {
84
+ this.db.exec("ALTER TABLE prompts ADD COLUMN metadata TEXT NOT NULL DEFAULT '{}'");
85
+ } catch {
86
+ }
87
+ try {
88
+ this.db.exec("ALTER TABLE prompts ADD COLUMN response TEXT NOT NULL DEFAULT ''");
89
+ } catch {
90
+ }
91
+ const ftsRow = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='prompts_fts'").get();
92
+ if (!ftsRow?.sql?.includes("response")) {
93
+ this.db.exec(`
94
+ DROP TABLE IF EXISTS prompts_fts;
95
+ DROP TRIGGER IF EXISTS prompts_ai;
96
+ DROP TRIGGER IF EXISTS prompts_ad;
97
+
98
+ CREATE VIRTUAL TABLE prompts_fts USING fts5(
99
+ prompt,
100
+ response,
101
+ tool UNINDEXED,
102
+ content='prompts',
103
+ content_rowid='id'
104
+ );
105
+
106
+ CREATE TRIGGER prompts_ai AFTER INSERT ON prompts BEGIN
107
+ INSERT INTO prompts_fts(rowid, prompt, response, tool)
108
+ VALUES (new.id, new.prompt, new.response, new.tool);
109
+ END;
110
+
111
+ CREATE TRIGGER prompts_ad AFTER DELETE ON prompts BEGIN
112
+ INSERT INTO prompts_fts(prompts_fts, rowid, prompt, response, tool)
113
+ VALUES('delete', old.id, old.prompt, old.response, old.tool);
114
+ END;
115
+
116
+ INSERT INTO prompts_fts(prompts_fts) VALUES('rebuild');
117
+ `);
118
+ }
119
+ }
120
+ insert(entry) {
121
+ const meta = entry.metadata || "{}";
122
+ const response = entry.response ?? "";
123
+ const info = this.db.prepare(
124
+ `INSERT INTO prompts (timestamp, tool, prompt, response, args, workdir, hostname, exit_code, metadata)
125
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
126
+ ).run(
127
+ entry.timestamp,
128
+ entry.tool,
129
+ entry.prompt,
130
+ response,
131
+ entry.args,
132
+ entry.workdir,
133
+ entry.hostname,
134
+ entry.exit_code,
135
+ meta
136
+ );
137
+ return info.lastInsertRowid;
138
+ }
139
+ updateExitCode(id, code) {
140
+ this.db.prepare("UPDATE prompts SET exit_code = ? WHERE id = ?").run(code, id);
141
+ }
142
+ updateMetadata(id, metadata) {
143
+ this.db.prepare("UPDATE prompts SET metadata = ? WHERE id = ?").run(metadata, id);
144
+ }
145
+ updateResponse(id, response) {
146
+ this.db.prepare("UPDATE prompts SET response = ? WHERE id = ?").run(response, id);
147
+ }
148
+ getById(id) {
149
+ return this.db.prepare("SELECT * FROM prompts WHERE id = ?").get(id);
150
+ }
151
+ search(opts) {
152
+ const ftsArgs = [];
153
+ const ftsFilters = [];
154
+ const scanArgs = [];
155
+ const scanFilters = [];
156
+ if (opts.tool) {
157
+ ftsFilters.push("p.tool = ?");
158
+ ftsArgs.push(opts.tool);
159
+ scanFilters.push("tool = ?");
160
+ scanArgs.push(opts.tool);
161
+ }
162
+ if (opts.project) {
163
+ ftsFilters.push("json_extract(p.metadata, '$.project') = ?");
164
+ ftsArgs.push(opts.project);
165
+ scanFilters.push("json_extract(metadata, '$.project') = ?");
166
+ scanArgs.push(opts.project);
167
+ }
168
+ if (opts.language) {
169
+ ftsFilters.push("json_extract(p.metadata, '$.language') = ?");
170
+ ftsArgs.push(opts.language);
171
+ scanFilters.push("json_extract(metadata, '$.language') = ?");
172
+ scanArgs.push(opts.language);
173
+ }
174
+ if (opts.role) {
175
+ ftsFilters.push("json_extract(p.metadata, '$.role') = ?");
176
+ ftsArgs.push(opts.role);
177
+ scanFilters.push("json_extract(metadata, '$.role') = ?");
178
+ scanArgs.push(opts.role);
179
+ }
180
+ if (opts.tag) {
181
+ ftsFilters.push("json_extract(p.metadata, '$.tags') LIKE ?");
182
+ ftsArgs.push(`%"${opts.tag}"%`);
183
+ scanFilters.push("json_extract(metadata, '$.tags') LIKE ?");
184
+ scanArgs.push(`%"${opts.tag}"%`);
185
+ }
186
+ if (opts.starred) {
187
+ ftsFilters.push("json_extract(p.metadata, '$.starred') = 1");
188
+ scanFilters.push("json_extract(metadata, '$.starred') = 1");
189
+ }
190
+ if (opts.minQuality !== void 0) {
191
+ ftsFilters.push("json_extract(p.metadata, '$.quality') >= ?");
192
+ ftsArgs.push(opts.minQuality);
193
+ scanFilters.push("json_extract(metadata, '$.quality') >= ?");
194
+ scanArgs.push(opts.minQuality);
195
+ }
196
+ if (opts.minRelevance !== void 0) {
197
+ ftsFilters.push("json_extract(p.metadata, '$.relevance') >= ?");
198
+ ftsArgs.push(opts.minRelevance);
199
+ scanFilters.push("json_extract(metadata, '$.relevance') >= ?");
200
+ scanArgs.push(opts.minRelevance);
201
+ }
202
+ if (opts.since) {
203
+ ftsFilters.push("p.timestamp >= ?");
204
+ ftsArgs.push(opts.since.toISOString());
205
+ scanFilters.push("timestamp >= ?");
206
+ scanArgs.push(opts.since.toISOString());
207
+ }
208
+ if (opts.until) {
209
+ ftsFilters.push("p.timestamp <= ?");
210
+ ftsArgs.push(opts.until.toISOString());
211
+ scanFilters.push("timestamp <= ?");
212
+ scanArgs.push(opts.until.toISOString());
213
+ }
214
+ if (opts.query) {
215
+ const sanitized = `"${opts.query.replace(/"/g, '""')}"`;
216
+ const filterClause = ftsFilters.length > 0 ? " AND " + ftsFilters.join(" AND ") : "";
217
+ const sql = `
218
+ SELECT p.*
219
+ FROM prompts_fts f
220
+ JOIN prompts p ON p.id = f.rowid
221
+ WHERE prompts_fts MATCH ?${filterClause}
222
+ ORDER BY p.timestamp DESC
223
+ LIMIT ?
224
+ `;
225
+ return this.db.prepare(sql).all(sanitized, ...ftsArgs, opts.limit);
226
+ } else {
227
+ const filterClause = scanFilters.length > 0 ? " AND " + scanFilters.join(" AND ") : "";
228
+ const sql = `
229
+ SELECT * FROM prompts
230
+ WHERE 1=1${filterClause}
231
+ ORDER BY timestamp DESC
232
+ LIMIT ?
233
+ `;
234
+ return this.db.prepare(sql).all(...scanArgs, opts.limit);
235
+ }
236
+ }
237
+ saveEmbedding(id, vector) {
238
+ const buf = Buffer.alloc(vector.length * 4);
239
+ for (let i = 0; i < vector.length; i++) {
240
+ buf.writeFloatLE(vector[i], i * 4);
241
+ }
242
+ this.db.prepare("INSERT OR REPLACE INTO embeddings (prompt_id, vector) VALUES (?, ?)").run(id, buf);
243
+ }
244
+ getAllEmbeddings() {
245
+ const rows = this.db.prepare("SELECT prompt_id, vector FROM embeddings").all();
246
+ const map = /* @__PURE__ */ new Map();
247
+ for (const row of rows) {
248
+ const vec = new Float32Array(row.vector.length / 4);
249
+ for (let i = 0; i < vec.length; i++) {
250
+ vec[i] = row.vector.readFloatLE(i * 4);
251
+ }
252
+ map.set(row.prompt_id, vec);
253
+ }
254
+ return map;
255
+ }
256
+ getPromptsWithoutEmbeddings() {
257
+ return this.db.prepare(
258
+ `SELECT p.* FROM prompts p
259
+ LEFT JOIN embeddings e ON e.prompt_id = p.id
260
+ WHERE e.prompt_id IS NULL`
261
+ ).all();
262
+ }
263
+ getAllPrompts(limit = 1e6) {
264
+ return this.db.prepare("SELECT * FROM prompts ORDER BY timestamp DESC LIMIT ?").all(limit);
265
+ }
266
+ getPromptsByRole(role) {
267
+ return this.db.prepare("SELECT * FROM prompts WHERE json_extract(metadata, '$.role') = ? ORDER BY timestamp DESC").all(role);
268
+ }
269
+ delete(id) {
270
+ this.db.prepare("DELETE FROM prompts WHERE id = ?").run(id);
271
+ }
272
+ deleteById(id) {
273
+ this.delete(id);
274
+ }
275
+ deleteByIds(ids) {
276
+ if (ids.length === 0) return 0;
277
+ const placeholders = ids.map(() => "?").join(",");
278
+ const info = this.db.prepare(`DELETE FROM prompts WHERE id IN (${placeholders})`).run(...ids);
279
+ return info.changes;
280
+ }
281
+ deleteOlderThan(days) {
282
+ const info = this.db.prepare("DELETE FROM prompts WHERE timestamp < datetime('now', ?)").run(`-${days} days`);
283
+ return info.changes;
284
+ }
285
+ vacuum() {
286
+ this.db.exec("VACUUM");
287
+ }
288
+ getAllPromptHashes() {
289
+ const rows = this.db.prepare("SELECT id, prompt FROM prompts").all();
290
+ const map = /* @__PURE__ */ new Map();
291
+ for (const row of rows) {
292
+ const hash = createHash("sha256").update(row.prompt.trim().toLowerCase()).digest("hex");
293
+ map.set(hash, row.id);
294
+ }
295
+ return map;
296
+ }
297
+ close() {
298
+ this.db.close();
299
+ }
300
+ };
301
+ function cosineSimilarity(a, b) {
302
+ if (a.length !== b.length) return 0;
303
+ let dot = 0, normA = 0, normB = 0;
304
+ for (let i = 0; i < a.length; i++) {
305
+ dot += a[i] * b[i];
306
+ normA += a[i] * a[i];
307
+ normB += b[i] * b[i];
308
+ }
309
+ if (normA === 0 || normB === 0) return 0;
310
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
311
+ }
312
+
313
+ // src/runner/inline.ts
314
+ import { spawn, execFileSync as execFileSync2 } from "child_process";
315
+ import fs3 from "fs";
316
+ import os3 from "os";
317
+
318
+ // src/runner/project.ts
319
+ import fs2 from "fs";
320
+ import path3 from "path";
321
+ var PROJECT_MARKERS = [
322
+ ".git",
323
+ "go.mod",
324
+ "package.json",
325
+ "Cargo.toml",
326
+ "Makefile",
327
+ "pyproject.toml",
328
+ "requirements.txt"
329
+ ];
330
+ var LANGUAGE_MAP = {
331
+ "go.mod": "go",
332
+ "package.json": "javascript",
333
+ "Cargo.toml": "rust",
334
+ "pyproject.toml": "python",
335
+ "requirements.txt": "python",
336
+ "Makefile": "make",
337
+ "composer.json": "php",
338
+ "Gemfile": "ruby"
339
+ };
340
+ function detectProject(startDir) {
341
+ let curr = path3.resolve(startDir);
342
+ while (true) {
343
+ for (const marker of PROJECT_MARKERS) {
344
+ if (fs2.existsSync(path3.join(curr, marker))) {
345
+ return { rootDir: curr, projectName: path3.basename(curr) };
346
+ }
347
+ }
348
+ const parent = path3.dirname(curr);
349
+ if (parent === curr) break;
350
+ curr = parent;
351
+ }
352
+ return { rootDir: "", projectName: "" };
353
+ }
354
+ function detectLanguage(rootDir) {
355
+ if (!rootDir) return "";
356
+ for (const [marker, lang] of Object.entries(LANGUAGE_MAP)) {
357
+ if (fs2.existsSync(path3.join(rootDir, marker))) return lang;
358
+ }
359
+ return "";
360
+ }
361
+
362
+ // src/runner/git-context.ts
363
+ import { execFileSync } from "child_process";
364
+ function captureGitContext(workdir) {
365
+ try {
366
+ const branch = execFileSync("git", ["-C", workdir, "rev-parse", "--abbrev-ref", "HEAD"], {
367
+ encoding: "utf-8",
368
+ stdio: ["ignore", "pipe", "pipe"]
369
+ }).trim();
370
+ const filesRaw = execFileSync("git", ["-C", workdir, "diff", "--name-only", "HEAD"], {
371
+ encoding: "utf-8",
372
+ stdio: ["ignore", "pipe", "pipe"]
373
+ }).trim();
374
+ const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
375
+ let diff = execFileSync("git", ["-C", workdir, "diff", "HEAD"], {
376
+ encoding: "utf-8",
377
+ stdio: ["ignore", "pipe", "pipe"]
378
+ });
379
+ if (diff.length > 4e3) {
380
+ diff = diff.slice(0, 4e3) + "\n... (truncated)";
381
+ }
382
+ return { branch, files, diff };
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+
388
+ // src/runner/inline.ts
389
+ function extractPrompt(args) {
390
+ for (let i = args.length - 1; i >= 0; i--) {
391
+ if (!args[i].startsWith("-")) return args[i];
392
+ }
393
+ return args.join(" ");
394
+ }
395
+ function resolveRealBinary(tool) {
396
+ let found;
397
+ try {
398
+ found = execFileSync2("which", [tool], { encoding: "utf-8" }).trim();
399
+ } catch {
400
+ throw new Error(`command not found: ${tool}`);
401
+ }
402
+ const realFound = fs3.realpathSync(found);
403
+ const selfPath = fs3.realpathSync(process.execPath);
404
+ const scriptPath = process.argv[1] ? fs3.realpathSync(process.argv[1]) : "";
405
+ if (realFound === selfPath || realFound === scriptPath) {
406
+ throw new Error(
407
+ `resolved to ph itself \u2014 would loop. Place ph AFTER the real ${tool} in $PATH`
408
+ );
409
+ }
410
+ return realFound;
411
+ }
412
+ async function runInline(realBin, args, db, tool, tags, role, onInserted) {
413
+ const workdir = process.cwd();
414
+ const { rootDir, projectName } = detectProject(workdir);
415
+ const language = detectLanguage(rootDir);
416
+ const gitContext = captureGitContext(workdir);
417
+ const metaObj = {};
418
+ if (projectName) metaObj.project = projectName;
419
+ if (language) metaObj.language = language;
420
+ if (role) metaObj.role = role;
421
+ if (tags.length > 0) metaObj.tags = tags;
422
+ if (gitContext) metaObj.git_context = gitContext;
423
+ const metadata = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : "{}";
424
+ const id = db.insert({
425
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
426
+ tool,
427
+ prompt: extractPrompt(args),
428
+ args: args.join(" "),
429
+ workdir,
430
+ hostname: os3.hostname(),
431
+ exit_code: 0,
432
+ metadata
433
+ });
434
+ if (onInserted) onInserted(id);
435
+ const child = spawn(realBin, args, { stdio: "inherit" });
436
+ child.on("error", (err) => {
437
+ process.stderr.write(`ph: spawn error: ${err.message}
438
+ `);
439
+ process.exit(1);
440
+ });
441
+ child.on("exit", (code, signal) => {
442
+ const exitCode = code ?? (signal ? 1 : 0);
443
+ if (id > 0) db.updateExitCode(id, exitCode);
444
+ process.exit(exitCode);
445
+ });
446
+ }
447
+
448
+ // src/pty/wrapper.ts
449
+ import pty from "@lydell/node-pty";
450
+ import fs4 from "fs";
451
+ function cleanLine(bytes) {
452
+ const s = Buffer.from(bytes).toString("utf-8");
453
+ return s.trim();
454
+ }
455
+ function stripAnsi(text) {
456
+ return text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
457
+ }
458
+ async function runPTY(binary, args, onPrompt, onResponse, debugLog) {
459
+ let dbgStream = null;
460
+ if (debugLog) {
461
+ dbgStream = fs4.createWriteStream(debugLog, { flags: "a" });
462
+ dbgStream.write(`
463
+ === session start: ${binary} ${args.join(" ")} @ ${(/* @__PURE__ */ new Date()).toISOString()} ===
464
+ `);
465
+ }
466
+ const log = (msg) => {
467
+ if (dbgStream) dbgStream.write(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
468
+ `);
469
+ };
470
+ const cols = process.stdout.columns || 80;
471
+ const rows = process.stdout.rows || 24;
472
+ const ptyProcess = pty.spawn(binary, args, {
473
+ name: "xterm-256color",
474
+ cols,
475
+ rows,
476
+ cwd: process.cwd(),
477
+ env: process.env
478
+ });
479
+ let currentPromptId = null;
480
+ let responseBuffer = "";
481
+ ptyProcess.onData((data) => {
482
+ process.stdout.write(data);
483
+ if (currentPromptId !== null) {
484
+ responseBuffer += data;
485
+ }
486
+ });
487
+ const onResize = () => {
488
+ ptyProcess.resize(process.stdout.columns || 80, process.stdout.rows || 24);
489
+ };
490
+ process.stdout.on("resize", onResize);
491
+ if (process.stdin.isTTY) {
492
+ process.stdin.setRawMode(true);
493
+ }
494
+ process.stdin.resume();
495
+ let lineBuffer = [];
496
+ let escState = "NORMAL";
497
+ const onData = (chunk) => {
498
+ for (let i = 0; i < chunk.length; i++) {
499
+ const b = chunk[i];
500
+ ptyProcess.write(String.fromCharCode(b));
501
+ switch (escState) {
502
+ case "NORMAL":
503
+ if (b === 13 || b === 10) {
504
+ const prompt = cleanLine(lineBuffer);
505
+ log(`ENTER \u2192 prompt: ${JSON.stringify(prompt)}`);
506
+ if (prompt) {
507
+ if (currentPromptId !== null) {
508
+ onResponse(currentPromptId, stripAnsi(responseBuffer));
509
+ }
510
+ currentPromptId = onPrompt(prompt, /* @__PURE__ */ new Date());
511
+ responseBuffer = "";
512
+ }
513
+ lineBuffer = [];
514
+ } else if (b === 127 || b === 8) {
515
+ if (lineBuffer.length > 0) lineBuffer.pop();
516
+ } else if (b === 3 || b === 4) {
517
+ lineBuffer = [];
518
+ } else if (b === 27) {
519
+ escState = "ESC";
520
+ } else if (b >= 32) {
521
+ lineBuffer.push(b);
522
+ }
523
+ break;
524
+ case "ESC":
525
+ if (b === 91) {
526
+ escState = "CSI";
527
+ } else if (b === 93 || b === 80 || b === 95 || b === 94 || b === 88) {
528
+ escState = "OSC";
529
+ } else {
530
+ escState = "NORMAL";
531
+ }
532
+ break;
533
+ case "CSI":
534
+ if (b >= 65 && b <= 90 || b >= 97 && b <= 122 || b === 126) {
535
+ escState = "NORMAL";
536
+ }
537
+ break;
538
+ case "OSC":
539
+ if (b === 7) {
540
+ escState = "NORMAL";
541
+ } else if (b === 27) {
542
+ escState = "ESC";
543
+ }
544
+ break;
545
+ }
546
+ }
547
+ };
548
+ process.stdin.on("data", onData);
549
+ return new Promise((resolve) => {
550
+ ptyProcess.onExit(({ exitCode }) => {
551
+ if (currentPromptId !== null) {
552
+ onResponse(currentPromptId, stripAnsi(responseBuffer));
553
+ }
554
+ process.stdin.removeListener("data", onData);
555
+ process.stdout.removeListener("resize", onResize);
556
+ if (process.stdin.isTTY) {
557
+ process.stdin.setRawMode(false);
558
+ }
559
+ if (dbgStream) {
560
+ dbgStream.write(`=== session end exitCode=${exitCode} ===
561
+ `);
562
+ dbgStream.end();
563
+ }
564
+ resolve(exitCode);
565
+ });
566
+ });
567
+ }
568
+ function isTerminal() {
569
+ return Boolean(process.stdin.isTTY);
570
+ }
571
+
572
+ // src/display/print.ts
573
+ var C = {
574
+ reset: "\x1B[0m",
575
+ cyan: "\x1B[36m",
576
+ yellow: "\x1B[33m",
577
+ green: "\x1B[32m",
578
+ gray: "\x1B[90m",
579
+ red: "\x1B[31m",
580
+ blue: "\x1B[34m",
581
+ magenta: "\x1B[35m"
582
+ };
583
+ function truncatePrompt(s, max = 120) {
584
+ const flat = s.replace(/\n/g, " ");
585
+ if (flat.length > max) return flat.slice(0, max) + "\u2026";
586
+ return flat;
587
+ }
588
+ function formatTimestamp(ts) {
589
+ try {
590
+ const d = new Date(ts);
591
+ const pad = (n) => String(n).padStart(2, "0");
592
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
593
+ } catch {
594
+ return ts;
595
+ }
596
+ }
597
+ function printResults(entries, showFull) {
598
+ if (entries.length === 0) {
599
+ console.log("No results found.");
600
+ return;
601
+ }
602
+ console.log(`${C.yellow}${entries.length} result(s)${C.reset}
603
+ `);
604
+ for (const e of entries) {
605
+ printEntry(e, showFull);
606
+ }
607
+ }
608
+ function printEntry(e, showFull) {
609
+ let meta = {};
610
+ try {
611
+ meta = JSON.parse(e.metadata);
612
+ } catch (_) {
613
+ }
614
+ const exitColor = e.exit_code === 0 ? C.green : C.red;
615
+ const starStr = meta.starred ? `${C.yellow}\u2605${C.reset} ` : "";
616
+ let projectStr = "";
617
+ if (meta.project && meta.language) {
618
+ projectStr = `${C.blue}[${meta.project}:${meta.language}]${C.reset} `;
619
+ } else if (meta.project) {
620
+ projectStr = `${C.blue}[${meta.project}]${C.reset} `;
621
+ } else if (meta.language) {
622
+ projectStr = `${C.blue}[${meta.language}]${C.reset} `;
623
+ }
624
+ const roleStr = meta.role ? `${C.magenta}{${meta.role}}${C.reset} ` : "";
625
+ let tagsStr = "";
626
+ if (meta.tags && meta.tags.length > 0) {
627
+ tagsStr = `${C.cyan}(${meta.tags.join(",")})${C.reset} `;
628
+ }
629
+ process.stdout.write(
630
+ `${C.cyan}#${String(e.id).padEnd(4)}${C.reset} ${starStr}${C.yellow}${e.tool.padEnd(8)}${C.reset} ${C.gray}${formatTimestamp(e.timestamp)}${C.reset} ${projectStr}${roleStr}${tagsStr}${exitColor}[exit:${e.exit_code}]${C.reset}
631
+ `
632
+ );
633
+ if (e.workdir) {
634
+ process.stdout.write(` ${C.gray}${e.workdir}${C.reset}
635
+ `);
636
+ }
637
+ let prompt = e.prompt;
638
+ if (!showFull) {
639
+ prompt = truncatePrompt(prompt);
640
+ }
641
+ prompt = prompt.replace(/\n/g, "\n ");
642
+ process.stdout.write(` ${prompt}
643
+
644
+ `);
645
+ }
646
+
647
+ // src/importer/gemini.ts
648
+ import fs5 from "fs";
649
+ import path4 from "path";
650
+ import os4 from "os";
651
+
652
+ // src/analyzer/index.ts
653
+ var ANALYSIS_PROMPT = (text) => `Analyze this AI tool prompt and return ONLY a JSON object with these exact fields:
654
+ {
655
+ "project": "<project name if clearly identifiable, else empty string>",
656
+ "language": "<programming language if identifiable, else empty string>",
657
+ "role": "<ONE of: debug, refactor, explain, review, architect, test, docs, generate, research \u2014 or empty string>",
658
+ "tags": ["<tag1>", "<tag2>"],
659
+ "relevance": <integer 0-10>,
660
+ "quality": <integer 0-10>
661
+ }
662
+
663
+ Rules:
664
+ - tags: 1-3 short lowercase words describing what the prompt is about
665
+ - role: pick the single best match, or "" if none fits
666
+ - project/language: only fill if clearly inferrable from the prompt text
667
+ - relevance: how informative/useful is this prompt for future reference?
668
+ 0=completely useless (single word, greeting, yes/no), 5=average technical question, 10=highly detailed technical question
669
+ - quality: how well-structured is the prompt?
670
+ 0=messy/vague, 5=clear but simple, 10=professional prompt engineering (context, instructions, examples, constraints)
671
+ - Return ONLY the JSON object \u2014 no markdown fences, no explanation, no other text
672
+
673
+ Prompt to analyze:
674
+ """
675
+ ${text.slice(0, 1500)}
676
+ """`;
677
+ function parseAnalysisResponse(raw) {
678
+ let cleaned = raw.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "").trim();
679
+ const start = cleaned.indexOf("{");
680
+ const end = cleaned.lastIndexOf("}");
681
+ if (start === -1 || end === -1) return {};
682
+ cleaned = cleaned.slice(start, end + 1);
683
+ let parsed;
684
+ try {
685
+ parsed = JSON.parse(cleaned);
686
+ } catch {
687
+ return {};
688
+ }
689
+ const result = {};
690
+ if (typeof parsed["project"] === "string" && parsed["project"].trim()) {
691
+ result.project = parsed["project"].trim();
692
+ }
693
+ if (typeof parsed["language"] === "string" && parsed["language"].trim()) {
694
+ result.language = parsed["language"].trim().toLowerCase();
695
+ }
696
+ if (typeof parsed["role"] === "string" && parsed["role"].trim()) {
697
+ const validRoles = ["debug", "refactor", "explain", "review", "architect", "test", "docs", "generate", "research"];
698
+ const role = parsed["role"].trim().toLowerCase();
699
+ if (validRoles.includes(role)) result.role = role;
700
+ }
701
+ if (Array.isArray(parsed["tags"])) {
702
+ const tags = parsed["tags"].filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.trim().toLowerCase()).slice(0, 5);
703
+ if (tags.length > 0) result.tags = tags;
704
+ }
705
+ if (typeof parsed["relevance"] === "number") {
706
+ result.relevance = Math.max(0, Math.min(10, Math.round(parsed["relevance"])));
707
+ }
708
+ if (typeof parsed["quality"] === "number") {
709
+ result.quality = Math.max(0, Math.min(10, Math.round(parsed["quality"])));
710
+ }
711
+ return result;
712
+ }
713
+ async function analyzePrompt(text, provider) {
714
+ try {
715
+ const raw = await provider.generate(ANALYSIS_PROMPT(text));
716
+ return parseAnalysisResponse(raw);
717
+ } catch {
718
+ return {};
719
+ }
720
+ }
721
+ function mergeMetadata(existing, result, force) {
722
+ const merged = { ...existing };
723
+ if (force || !merged.project) merged.project = result.project || merged.project;
724
+ if (force || !merged.language) merged.language = result.language || merged.language;
725
+ if (force || !merged.role) merged.role = result.role || merged.role;
726
+ if (force || !merged.tags?.length) merged.tags = result.tags ?? merged.tags;
727
+ if (result.relevance !== void 0 && (force || merged.relevance === void 0)) {
728
+ merged.relevance = result.relevance;
729
+ }
730
+ if (result.quality !== void 0 && (force || merged.quality === void 0)) {
731
+ merged.quality = result.quality;
732
+ }
733
+ if (!merged.project) delete merged.project;
734
+ if (!merged.language) delete merged.language;
735
+ if (!merged.role) delete merged.role;
736
+ if (!merged.tags?.length) delete merged.tags;
737
+ return merged;
738
+ }
739
+ function parseMeta(raw) {
740
+ try {
741
+ return JSON.parse(raw);
742
+ } catch {
743
+ return {};
744
+ }
745
+ }
746
+ function hasMetadata(entry) {
747
+ const meta = parseMeta(entry.metadata);
748
+ return Boolean(meta.role || meta.tags?.length || meta.project || meta.language);
749
+ }
750
+ async function analyzeAll(entries, provider, db, opts = {}) {
751
+ const { force = false, pruneBelow = 0, dryRun = false, onProgress, onPrune } = opts;
752
+ const toProcess = force ? entries : entries.filter((e) => !hasMetadata(e));
753
+ const stats = { updated: 0, skipped: entries.length - toProcess.length, failed: 0, pruned: 0 };
754
+ for (let i = 0; i < toProcess.length; i++) {
755
+ const entry = toProcess[i];
756
+ let result = null;
757
+ let errMsg;
758
+ try {
759
+ result = await analyzePrompt(entry.prompt, provider);
760
+ const existing = parseMeta(entry.metadata);
761
+ const merged = mergeMetadata(existing, result, force);
762
+ if (pruneBelow > 0 && result.relevance !== void 0 && result.relevance < pruneBelow) {
763
+ onPrune?.(entry, result.relevance);
764
+ if (!dryRun) {
765
+ db.deleteById(entry.id);
766
+ }
767
+ stats.pruned++;
768
+ } else {
769
+ db.updateMetadata(entry.id, JSON.stringify(merged));
770
+ stats.updated++;
771
+ }
772
+ } catch (e) {
773
+ errMsg = e.message;
774
+ stats.failed++;
775
+ }
776
+ onProgress?.(i + 1, toProcess.length, entry, result, errMsg);
777
+ }
778
+ return stats;
779
+ }
780
+
781
+ // src/importer/gemini.ts
782
+ function getContent(msg) {
783
+ if (typeof msg.content === "string") return msg.content;
784
+ if (Array.isArray(msg.content)) {
785
+ return msg.content.map((c) => c.text || "").join("");
786
+ }
787
+ return "";
788
+ }
789
+ function findSessionFiles(tmpDir) {
790
+ const files = [];
791
+ if (!fs5.existsSync(tmpDir)) return files;
792
+ try {
793
+ const hashes = fs5.readdirSync(tmpDir);
794
+ for (const hash of hashes) {
795
+ const chatsDir = path4.join(tmpDir, hash, "chats");
796
+ if (!fs5.existsSync(chatsDir)) continue;
797
+ const sessions = fs5.readdirSync(chatsDir).filter(
798
+ (f) => f.startsWith("session-") && f.endsWith(".json")
799
+ );
800
+ for (const s of sessions) {
801
+ files.push(path4.join(chatsDir, s));
802
+ }
803
+ }
804
+ } catch (_) {
805
+ }
806
+ return files;
807
+ }
808
+ function inferWorkDir(sessionPath, geminiDir) {
809
+ const tmpDir = path4.join(geminiDir, "tmp");
810
+ const rel = path4.relative(tmpDir, sessionPath);
811
+ const parts = rel.split(path4.sep);
812
+ if (parts.length > 0) {
813
+ const hash = parts[0];
814
+ const historyDir = path4.join(geminiDir, "history", hash);
815
+ if (fs5.existsSync(path4.join(historyDir, ".git"))) {
816
+ return historyDir;
817
+ }
818
+ }
819
+ return "";
820
+ }
821
+ async function importGeminiHistory(db, geminiDir, dryRun, analyzer, onProgress, filter) {
822
+ const result = {
823
+ filesScanned: 0,
824
+ promptsFound: 0,
825
+ promptsImported: 0,
826
+ skipped: 0,
827
+ filtered: 0,
828
+ errors: []
829
+ };
830
+ const files = findSessionFiles(path4.join(geminiDir, "tmp"));
831
+ result.filesScanned = files.length;
832
+ const hostname = os4.hostname();
833
+ let totalPromptsToEvaluate = 0;
834
+ for (const filePath of files) {
835
+ try {
836
+ const raw = fs5.readFileSync(filePath, "utf-8");
837
+ const parsed = JSON.parse(raw);
838
+ const messages = Array.isArray(parsed) ? parsed : parsed.messages || [];
839
+ totalPromptsToEvaluate += messages.filter((m) => (m.type === "human" || m.type === "user") && getContent(m) !== "").length;
840
+ } catch {
841
+ }
842
+ }
843
+ let promptsEvaluated = 0;
844
+ for (const filePath of files) {
845
+ try {
846
+ const raw = fs5.readFileSync(filePath, "utf-8");
847
+ let messages;
848
+ try {
849
+ const parsed = JSON.parse(raw);
850
+ if (Array.isArray(parsed)) {
851
+ messages = parsed;
852
+ } else {
853
+ messages = parsed.messages || [];
854
+ }
855
+ } catch {
856
+ result.errors.push(`${filePath}: JSON parse error`);
857
+ continue;
858
+ }
859
+ for (let i = 0; i < messages.length; i++) {
860
+ const msg = messages[i];
861
+ if (!((msg.type === "human" || msg.type === "user") && getContent(msg) !== "")) continue;
862
+ result.promptsFound++;
863
+ promptsEvaluated++;
864
+ const content = getContent(msg);
865
+ onProgress?.(promptsEvaluated, result.promptsImported, totalPromptsToEvaluate, content.slice(0, 60));
866
+ if (dryRun) {
867
+ result.promptsImported++;
868
+ continue;
869
+ }
870
+ if (filter) {
871
+ const preCheck = filter.checkRules(content);
872
+ if (!preCheck.keep) {
873
+ result.filtered++;
874
+ continue;
875
+ }
876
+ const dupCheck = filter.checkDuplicate(content);
877
+ if (!dupCheck.keep) {
878
+ result.filtered++;
879
+ continue;
880
+ }
881
+ }
882
+ let metadata = "{}";
883
+ let analysis;
884
+ if (analyzer) {
885
+ try {
886
+ analysis = await analyzePrompt(content, analyzer);
887
+ if (filter && analysis) {
888
+ const relCheck = filter.checkRelevance(analysis);
889
+ if (!relCheck.keep) {
890
+ result.filtered++;
891
+ continue;
892
+ }
893
+ }
894
+ const merged = mergeMetadata({}, analysis, false);
895
+ if (Object.keys(merged).length > 0) metadata = JSON.stringify(merged);
896
+ } catch {
897
+ }
898
+ }
899
+ let response = "";
900
+ const nextMsg = messages[i + 1];
901
+ if (nextMsg && (nextMsg.type === "model" || nextMsg.type === "assistant")) {
902
+ response = getContent(nextMsg);
903
+ if (response.length > 8e3) response = response.slice(0, 8e3) + "\n... (truncated)";
904
+ }
905
+ try {
906
+ const id = db.insert({
907
+ timestamp: new Date(msg.timestamp).toISOString(),
908
+ tool: "gemini",
909
+ prompt: content,
910
+ response,
911
+ args: content,
912
+ workdir: inferWorkDir(filePath, geminiDir),
913
+ hostname,
914
+ exit_code: 0,
915
+ metadata
916
+ });
917
+ filter?.registerHash(content, id);
918
+ result.promptsImported++;
919
+ } catch (e) {
920
+ result.errors.push(`insert ${msg.id || "?"}: ${e.message}`);
921
+ result.skipped++;
922
+ }
923
+ }
924
+ } catch (e) {
925
+ result.errors.push(`${filePath}: ${e.message}`);
926
+ }
927
+ }
928
+ return result;
929
+ }
930
+
931
+ // src/importer/claude.ts
932
+ import fs6 from "fs";
933
+ import path5 from "path";
934
+ import os5 from "os";
935
+ var MAX_RESPONSE_LENGTH = 8e3;
936
+ var TRUNCATION_SUFFIX = "\n... (truncated)";
937
+ function slugToPath(slug) {
938
+ return slug.replace(/-/g, "/");
939
+ }
940
+ function extractAssistantText(parts) {
941
+ return parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text).join("");
942
+ }
943
+ function truncateResponse(text) {
944
+ if (text.length <= MAX_RESPONSE_LENGTH) return text;
945
+ return text.slice(0, MAX_RESPONSE_LENGTH - TRUNCATION_SUFFIX.length) + TRUNCATION_SUFFIX;
946
+ }
947
+ function parseTranscriptFile(filePath, errors) {
948
+ let raw;
949
+ try {
950
+ raw = fs6.readFileSync(filePath, "utf8");
951
+ } catch (e) {
952
+ errors.push(`read ${filePath}: ${e.message}`);
953
+ return [];
954
+ }
955
+ const messages = [];
956
+ for (const line of raw.split("\n")) {
957
+ const trimmed = line.trim();
958
+ if (!trimmed) continue;
959
+ try {
960
+ messages.push(JSON.parse(trimmed));
961
+ } catch {
962
+ }
963
+ }
964
+ return messages;
965
+ }
966
+ function findTranscriptFiles(claudeDir) {
967
+ const projectsDir = path5.join(claudeDir, "projects");
968
+ const result = [];
969
+ if (!fs6.existsSync(projectsDir)) return result;
970
+ let projectEntries;
971
+ try {
972
+ projectEntries = fs6.readdirSync(projectsDir, { withFileTypes: true });
973
+ } catch {
974
+ return result;
975
+ }
976
+ for (const entry of projectEntries) {
977
+ if (!entry.isDirectory()) continue;
978
+ const projectSlug = entry.name;
979
+ const projectDir = path5.join(projectsDir, projectSlug);
980
+ let sessionEntries;
981
+ try {
982
+ sessionEntries = fs6.readdirSync(projectDir, { withFileTypes: true });
983
+ } catch {
984
+ continue;
985
+ }
986
+ for (const sessionEntry of sessionEntries) {
987
+ if (!sessionEntry.isFile() || !sessionEntry.name.endsWith(".jsonl")) continue;
988
+ result.push({
989
+ filePath: path5.join(projectDir, sessionEntry.name),
990
+ projectSlug
991
+ });
992
+ }
993
+ }
994
+ return result;
995
+ }
996
+ async function importClaudeHistory(db, claudeDir, dryRun, analyzer, onProgress, filter) {
997
+ const result = {
998
+ filesScanned: 0,
999
+ promptsFound: 0,
1000
+ promptsImported: 0,
1001
+ skipped: 0,
1002
+ filtered: 0,
1003
+ errors: []
1004
+ };
1005
+ const transcriptFiles = findTranscriptFiles(claudeDir);
1006
+ result.filesScanned = transcriptFiles.length;
1007
+ if (transcriptFiles.length === 0) return result;
1008
+ const hostname = os5.hostname();
1009
+ const pendingEntries = [];
1010
+ for (const { filePath, projectSlug } of transcriptFiles) {
1011
+ const messages = parseTranscriptFile(filePath, result.errors);
1012
+ const workdir = slugToPath(projectSlug);
1013
+ const sessionId = path5.basename(filePath, ".jsonl");
1014
+ for (let i = 0; i < messages.length; i++) {
1015
+ const msg = messages[i];
1016
+ if (msg.type !== "user") continue;
1017
+ const userMsg = msg;
1018
+ const prompt = typeof userMsg.content === "string" ? userMsg.content.trim() : "";
1019
+ if (!prompt) continue;
1020
+ let response = "";
1021
+ if (i + 1 < messages.length && messages[i + 1].type === "assistant") {
1022
+ const assistantMsg = messages[i + 1];
1023
+ const rawText = extractAssistantText(assistantMsg.content ?? []);
1024
+ response = truncateResponse(rawText);
1025
+ }
1026
+ pendingEntries.push({
1027
+ prompt,
1028
+ response,
1029
+ timestamp: userMsg.timestamp,
1030
+ workdir,
1031
+ sessionId
1032
+ });
1033
+ }
1034
+ }
1035
+ const totalPromptsToEvaluate = pendingEntries.length;
1036
+ let promptsEvaluated = 0;
1037
+ for (const entry of pendingEntries) {
1038
+ promptsEvaluated++;
1039
+ result.promptsFound++;
1040
+ onProgress?.(promptsEvaluated, result.promptsImported, totalPromptsToEvaluate, entry.prompt.slice(0, 60));
1041
+ if (dryRun) {
1042
+ result.promptsImported++;
1043
+ continue;
1044
+ }
1045
+ if (filter) {
1046
+ const preCheck = filter.checkRules(entry.prompt);
1047
+ if (!preCheck.keep) {
1048
+ result.filtered++;
1049
+ continue;
1050
+ }
1051
+ const dupCheck = filter.checkDuplicate(entry.prompt);
1052
+ if (!dupCheck.keep) {
1053
+ result.filtered++;
1054
+ continue;
1055
+ }
1056
+ }
1057
+ try {
1058
+ let metadata = "{}";
1059
+ if (analyzer) {
1060
+ try {
1061
+ const analysis = await analyzePrompt(entry.prompt, analyzer);
1062
+ if (filter && analysis) {
1063
+ const relCheck = filter.checkRelevance(analysis);
1064
+ if (!relCheck.keep) {
1065
+ result.filtered++;
1066
+ continue;
1067
+ }
1068
+ }
1069
+ const merged = mergeMetadata({}, analysis, false);
1070
+ if (Object.keys(merged).length > 0) metadata = JSON.stringify(merged);
1071
+ } catch {
1072
+ }
1073
+ }
1074
+ const id = db.insert({
1075
+ timestamp: entry.timestamp,
1076
+ tool: "claude",
1077
+ prompt: entry.prompt,
1078
+ response: entry.response,
1079
+ args: entry.prompt,
1080
+ workdir: entry.workdir,
1081
+ hostname,
1082
+ exit_code: 0,
1083
+ metadata
1084
+ });
1085
+ filter?.registerHash(entry.prompt, id);
1086
+ result.promptsImported++;
1087
+ } catch (e) {
1088
+ result.errors.push(
1089
+ `insert session ${entry.sessionId}: ${e.message}`
1090
+ );
1091
+ result.skipped++;
1092
+ }
1093
+ }
1094
+ return result;
1095
+ }
1096
+
1097
+ // src/embedding/index.ts
1098
+ async function getEmbeddings(texts, ollamaUrl, model, batchSize = 20) {
1099
+ const results = [];
1100
+ for (let i = 0; i < texts.length; i += batchSize) {
1101
+ const batch = texts.slice(i, i + batchSize);
1102
+ const res = await fetch(`${ollamaUrl}/api/embed`, {
1103
+ method: "POST",
1104
+ headers: { "Content-Type": "application/json" },
1105
+ body: JSON.stringify({ model, input: batch })
1106
+ });
1107
+ if (!res.ok) {
1108
+ const err = await res.text();
1109
+ throw new Error(`Ollama embed error ${res.status}: ${err}`);
1110
+ }
1111
+ const data = await res.json();
1112
+ for (const vec of data.embeddings) {
1113
+ results.push(new Float32Array(vec));
1114
+ }
1115
+ }
1116
+ return results;
1117
+ }
1118
+
1119
+ // src/ai/ollama.ts
1120
+ var OllamaProvider = class {
1121
+ constructor(url, model) {
1122
+ this.url = url;
1123
+ this.model = model;
1124
+ }
1125
+ name = "ollama";
1126
+ async generate(prompt) {
1127
+ const controller = new AbortController();
1128
+ const timer = setTimeout(() => controller.abort(), 6e4);
1129
+ let res;
1130
+ try {
1131
+ res = await fetch(`${this.url}/api/generate`, {
1132
+ method: "POST",
1133
+ headers: { "Content-Type": "application/json" },
1134
+ body: JSON.stringify({ model: this.model, prompt, stream: false }),
1135
+ signal: controller.signal
1136
+ });
1137
+ } catch (err) {
1138
+ clearTimeout(timer);
1139
+ const msg = err.message ?? String(err);
1140
+ if (msg.includes("abort") || msg.includes("timeout")) {
1141
+ throw new Error(`Ollama request timed out (60s). Is Ollama running at ${this.url}?`);
1142
+ }
1143
+ throw new Error(`Ollama unreachable at ${this.url}: ${msg}`);
1144
+ } finally {
1145
+ clearTimeout(timer);
1146
+ }
1147
+ if (!res.ok) {
1148
+ const body = await res.text().catch(() => "");
1149
+ throw new Error(`Ollama error ${res.status}: ${body}`);
1150
+ }
1151
+ const data = await res.json();
1152
+ return data.response ?? "";
1153
+ }
1154
+ };
1155
+
1156
+ // src/ai/gemini.ts
1157
+ var GENERATE_API = "https://generativelanguage.googleapis.com/v1beta/models";
1158
+ async function generateContent(prompt, model, apiKey) {
1159
+ const res = await fetch(`${GENERATE_API}/${model}:generateContent?key=${apiKey}`, {
1160
+ method: "POST",
1161
+ headers: { "Content-Type": "application/json" },
1162
+ body: JSON.stringify({
1163
+ contents: [{ parts: [{ text: prompt }] }]
1164
+ })
1165
+ });
1166
+ if (!res.ok) {
1167
+ const err = await res.text();
1168
+ throw new Error(`Gemini API error ${res.status}: ${err}`);
1169
+ }
1170
+ const data = await res.json();
1171
+ return data.candidates[0]?.content?.parts[0]?.text ?? "";
1172
+ }
1173
+ var GeminiProvider = class {
1174
+ constructor(model, apiKey) {
1175
+ this.model = model;
1176
+ this.apiKey = apiKey;
1177
+ }
1178
+ name = "gemini";
1179
+ generate(prompt) {
1180
+ return generateContent(prompt, this.model, this.apiKey);
1181
+ }
1182
+ };
1183
+
1184
+ // src/ai/provider.ts
1185
+ function getProvider(cfg) {
1186
+ const provider = cfg.analyzeProvider ?? "ollama";
1187
+ if (provider === "gemini") {
1188
+ const apiKey = process.env.GEMINI_API_KEY ?? cfg.geminiApiKey;
1189
+ if (!apiKey) return null;
1190
+ return new GeminiProvider("gemini-2.5-flash", apiKey);
1191
+ }
1192
+ return new OllamaProvider(
1193
+ cfg.ollamaUrl ?? "http://localhost:11434",
1194
+ cfg.ollamaModel ?? "llama3.1:latest"
1195
+ );
1196
+ }
1197
+
1198
+ // src/analyzer/reusability.ts
1199
+ var ReusabilityAnalyzer = class _ReusabilityAnalyzer {
1200
+ threshold;
1201
+ constructor(threshold = 0.7) {
1202
+ this.threshold = threshold;
1203
+ }
1204
+ analyze(entries, embeddings) {
1205
+ const scores = entries.map((entry) => {
1206
+ const meta = this.parseMeta(entry.metadata);
1207
+ const embedding = embeddings.get(entry.id);
1208
+ let maxSim = 0;
1209
+ if (embedding) {
1210
+ for (const [otherId, otherVec] of embeddings.entries()) {
1211
+ if (otherId === entry.id) continue;
1212
+ const sim = cosineSimilarity(embedding, otherVec);
1213
+ if (sim > maxSim) maxSim = sim;
1214
+ }
1215
+ }
1216
+ const roleScore = this.calculateRoleScore(meta.role);
1217
+ const lengthScore = this.calculateLengthScore(entry.prompt.length);
1218
+ const uniquenessScore = 1 - maxSim;
1219
+ const recencyScore = this.calculateRecencyScore(entry.timestamp);
1220
+ const finalScore = (roleScore * 0.4 + lengthScore * 0.2 + uniquenessScore * 0.25 + recencyScore * 0.15) * 10;
1221
+ return {
1222
+ id: entry.id,
1223
+ prompt: entry.prompt,
1224
+ role: meta.role || "other",
1225
+ score: finalScore,
1226
+ details: {
1227
+ role: roleScore,
1228
+ length: lengthScore,
1229
+ uniqueness: uniquenessScore,
1230
+ recency: recencyScore
1231
+ }
1232
+ };
1233
+ });
1234
+ const report = {
1235
+ total: entries.length,
1236
+ keep: [],
1237
+ review: [],
1238
+ remove: [],
1239
+ byRole: {},
1240
+ duplicates: { exact: 0, variants: 0 }
1241
+ };
1242
+ const keepThreshold = this.threshold * 10;
1243
+ const removeThreshold = Math.max(0, this.threshold - 0.3) * 10;
1244
+ for (const s of scores) {
1245
+ const uniqueness = s.details.uniqueness;
1246
+ const sim = 1 - uniqueness;
1247
+ if (sim > 0.95) report.duplicates.exact++;
1248
+ else if (sim > 0.85) report.duplicates.variants++;
1249
+ if (s.score >= keepThreshold) report.keep.push(s);
1250
+ else if (s.score >= removeThreshold) report.review.push(s);
1251
+ else report.remove.push(s);
1252
+ if (!report.byRole[s.role]) {
1253
+ report.byRole[s.role] = { keep: 0, review: 0, remove: 0 };
1254
+ }
1255
+ if (s.score >= keepThreshold) report.byRole[s.role].keep++;
1256
+ else if (s.score >= removeThreshold) report.byRole[s.role].review++;
1257
+ else report.byRole[s.role].remove++;
1258
+ }
1259
+ report.keep.sort((a, b) => b.score - a.score);
1260
+ report.remove.sort((a, b) => a.score - b.score);
1261
+ return report;
1262
+ }
1263
+ getRemovalCandidates(entries, embeddings, threshold = 0.7) {
1264
+ const removeThreshold = Math.max(0, threshold - 0.3) * 10;
1265
+ const candidates = [];
1266
+ const tempAnalyzer = new _ReusabilityAnalyzer(threshold);
1267
+ const report = tempAnalyzer.analyze(entries, embeddings);
1268
+ for (const s of report.remove) {
1269
+ const entry = entries.find((e) => e.id === s.id);
1270
+ if (!entry) continue;
1271
+ const meta = this.parseMeta(entry.metadata);
1272
+ if (meta.starred) continue;
1273
+ const hasEmbedding = embeddings.has(entry.id);
1274
+ candidates.push({
1275
+ id: s.id,
1276
+ score: s.score,
1277
+ reason: `score ${s.score.toFixed(1)} < ${removeThreshold.toFixed(1)}`,
1278
+ role: s.role,
1279
+ promptSize: entry.prompt.length,
1280
+ responseSize: (entry.response || "").length,
1281
+ hasEmbedding
1282
+ });
1283
+ }
1284
+ return candidates;
1285
+ }
1286
+ parseMeta(raw) {
1287
+ try {
1288
+ return JSON.parse(raw);
1289
+ } catch {
1290
+ return {};
1291
+ }
1292
+ }
1293
+ calculateRoleScore(role) {
1294
+ const scores = {
1295
+ review: 0.9,
1296
+ refactor: 0.9,
1297
+ architect: 0.9,
1298
+ debug: 0.5,
1299
+ explain: 0.5,
1300
+ generate: 0.2
1301
+ };
1302
+ return scores[role || ""] || 0.1;
1303
+ }
1304
+ calculateLengthScore(length) {
1305
+ if (length > 300 && length < 2e3) {
1306
+ return Math.min(1, Math.max(0, length / 1e3));
1307
+ }
1308
+ return 0.3;
1309
+ }
1310
+ calculateRecencyScore(timestamp) {
1311
+ const date = new Date(timestamp);
1312
+ const now = /* @__PURE__ */ new Date();
1313
+ const diffTime = Math.abs(now.getTime() - date.getTime());
1314
+ const diffDays = Math.ceil(diffTime / (1e3 * 60 * 60 * 24));
1315
+ return Math.max(0, 1 - diffDays / 365);
1316
+ }
1317
+ toCSV(report) {
1318
+ const all = [...report.keep, ...report.review, ...report.remove];
1319
+ let csv = "id,score,role,length,uniqueness,recency,prompt\n";
1320
+ for (const s of all) {
1321
+ const cleanPrompt = s.prompt.replace(/"/g, '""').replace(/\n/g, " ");
1322
+ csv += `${s.id},${s.score.toFixed(2)},${s.role},${s.prompt.length},${s.details.uniqueness.toFixed(4)},${s.details.recency.toFixed(4)},"${cleanPrompt.slice(0, 100)}"
1323
+ `;
1324
+ }
1325
+ return csv;
1326
+ }
1327
+ };
1328
+
1329
+ // src/background/analyzer.ts
1330
+ import { spawn as spawn2 } from "child_process";
1331
+ function spawnBackgroundAnalysis(id, dbPath) {
1332
+ try {
1333
+ const scriptPath = process.argv[1];
1334
+ if (!scriptPath) return;
1335
+ const nodePath = process.execPath;
1336
+ const child = spawn2(nodePath, [scriptPath, "_bg-analyze", String(id), "--db", dbPath], {
1337
+ detached: true,
1338
+ stdio: "ignore"
1339
+ });
1340
+ child.unref();
1341
+ } catch (_err) {
1342
+ }
1343
+ }
1344
+
1345
+ // src/filter/index.ts
1346
+ import { createHash as createHash2 } from "crypto";
1347
+ var TRIVIAL_PATTERN = /^(yes|no|ok|okay|sure|thanks|thank you|continue|go on|next|exit|quit|stop|help|y|n|👍|👎|che dici\??|dimmi|vai|go|do it|tell me|vadi|procedi|aspetta|wait|[.!?,;:]+|\s*)$/i;
1348
+ var ORPHAN_CODE_PATTERN = /^([{}()[\]<>|&!=\-+*/%^~#@\\:;.,\s]+|\.\.\.|console\.log.*|print.*)$/i;
1349
+ var NON_PRINTABLE_PATTERN = /^[\x00-\x1f\x7f\x1b[\]()#;\d;A-Za-z]*$/;
1350
+ var FilterPipeline = class _FilterPipeline {
1351
+ minLength;
1352
+ minRelevance;
1353
+ existingHashes;
1354
+ constructor(opts = {}) {
1355
+ this.minLength = opts.minLength ?? 15;
1356
+ this.minRelevance = opts.minRelevance ?? 3;
1357
+ this.existingHashes = opts.existingHashes ?? /* @__PURE__ */ new Map();
1358
+ }
1359
+ static hashPrompt(text) {
1360
+ return createHash2("sha256").update(text.trim().toLowerCase()).digest("hex");
1361
+ }
1362
+ checkRules(text) {
1363
+ const trimmed = text.trim();
1364
+ if (trimmed.length < this.minLength) {
1365
+ return { keep: false, reason: "too_short", details: `length ${trimmed.length} < ${this.minLength}` };
1366
+ }
1367
+ if (NON_PRINTABLE_PATTERN.test(trimmed)) {
1368
+ return { keep: false, reason: "non_printable", details: "only control characters" };
1369
+ }
1370
+ if (TRIVIAL_PATTERN.test(trimmed)) {
1371
+ return { keep: false, reason: "pattern_match", details: `matches trivial pattern: "${trimmed}"` };
1372
+ }
1373
+ if (ORPHAN_CODE_PATTERN.test(trimmed)) {
1374
+ return { keep: false, reason: "pattern_match", details: `matches orphan code pattern: "${trimmed}"` };
1375
+ }
1376
+ return { keep: true };
1377
+ }
1378
+ checkDuplicate(text) {
1379
+ const hash = _FilterPipeline.hashPrompt(text);
1380
+ if (this.existingHashes.has(hash)) {
1381
+ const existingId = this.existingHashes.get(hash);
1382
+ return { keep: false, reason: "exact_duplicate", details: `duplicate of #${existingId}` };
1383
+ }
1384
+ return { keep: true };
1385
+ }
1386
+ /**
1387
+ * Register a prompt hash after a successful insert to catch duplicates within the same batch.
1388
+ */
1389
+ registerHash(text, id) {
1390
+ const hash = _FilterPipeline.hashPrompt(text);
1391
+ this.existingHashes.set(hash, id);
1392
+ }
1393
+ checkRelevance(result) {
1394
+ if (this.minRelevance <= 0) return { keep: true };
1395
+ if (result.relevance === void 0) return { keep: true };
1396
+ if (result.relevance < this.minRelevance) {
1397
+ return {
1398
+ keep: false,
1399
+ reason: "low_relevance",
1400
+ details: `relevance ${result.relevance} < ${this.minRelevance}`
1401
+ };
1402
+ }
1403
+ return { keep: true };
1404
+ }
1405
+ /**
1406
+ * Full pipeline check: rules → dedup → (relevance if provided).
1407
+ * Short-circuits on first failure.
1408
+ */
1409
+ check(text, analysisResult) {
1410
+ const rulesResult = this.checkRules(text);
1411
+ if (!rulesResult.keep) return rulesResult;
1412
+ const dupResult = this.checkDuplicate(text);
1413
+ if (!dupResult.keep) return dupResult;
1414
+ if (analysisResult !== void 0) {
1415
+ const relResult = this.checkRelevance(analysisResult);
1416
+ if (!relResult.keep) return relResult;
1417
+ }
1418
+ return { keep: true };
1419
+ }
1420
+ };
1421
+
1422
+ // src/ui/BrowseApp.tsx
1423
+ import React6, { useState as useState2, useCallback, useEffect as useEffect2, useMemo as useMemo2 } from "react";
1424
+ import { Box as Box4, Text as Text5, useInput as useInput2, useApp } from "ink";
1425
+ import { spawn as spawn3 } from "child_process";
1426
+
1427
+ // src/ui/Header.tsx
1428
+ import React from "react";
1429
+ import { Text } from "ink";
1430
+ import { AppHeader, THEMES } from "@gi4nks/ink";
1431
+
1432
+ // package.json
1433
+ var package_default = {
1434
+ name: "@gi4nks/ph",
1435
+ version: "1.3.2",
1436
+ description: "Transparent CLI wrapper that captures and indexes AI prompts",
1437
+ type: "module",
1438
+ license: "MIT",
1439
+ bin: {
1440
+ ph: "dist/cli.js"
1441
+ },
1442
+ files: [
1443
+ "dist",
1444
+ "README.md",
1445
+ "LICENSE",
1446
+ "docs",
1447
+ "CHANGELOG.md",
1448
+ "hooks"
1449
+ ],
1450
+ repository: {
1451
+ type: "git",
1452
+ url: "git+https://github.com/gi4nks/ph.git"
1453
+ },
1454
+ scripts: {
1455
+ build: "tsup",
1456
+ dev: "tsx src/cli.ts",
1457
+ start: "node dist/cli.js",
1458
+ test: "vitest run",
1459
+ lint: "eslint src",
1460
+ "release:patch": "standard-version --release-as patch",
1461
+ "release:minor": "standard-version --release-as minor",
1462
+ "release:major": "standard-version --release-as major"
1463
+ },
1464
+ dependencies: {
1465
+ "@gi4nks/ink": "^1.5.0",
1466
+ "@lydell/node-pty": "^1.2.0-beta.3",
1467
+ "better-sqlite3": "^12.8.0",
1468
+ ink: "^6.8.0",
1469
+ react: "^19.2.4",
1470
+ zod: "^4.3.6"
1471
+ },
1472
+ devDependencies: {
1473
+ "@types/better-sqlite3": "^7.6.13",
1474
+ "@types/node": "^25.5.0",
1475
+ "@types/react": "^19.2.14",
1476
+ "@typescript-eslint/eslint-plugin": "^8.57.2",
1477
+ "@typescript-eslint/parser": "^8.57.2",
1478
+ eslint: "^9.39.4",
1479
+ "eslint-plugin-react": "^7.37.5",
1480
+ "eslint-plugin-react-hooks": "^7.0.1",
1481
+ "standard-version": "^9.5.0",
1482
+ tsup: "^8.5.1",
1483
+ tsx: "^4.21.0",
1484
+ typescript: "^5.9.3",
1485
+ vitest: "^4.1.0"
1486
+ }
1487
+ };
1488
+
1489
+ // src/ui/Header.tsx
1490
+ var ROLE_COLOR = {
1491
+ debug: "red",
1492
+ refactor: "yellow",
1493
+ explain: "blue",
1494
+ review: "magenta",
1495
+ architect: "green",
1496
+ test: "cyan",
1497
+ docs: "white",
1498
+ generate: "green",
1499
+ research: "blue"
1500
+ };
1501
+ var FilterBadges = ({ active, textFilter }) => /* @__PURE__ */ React.createElement(React.Fragment, null, active.project && /* @__PURE__ */ React.createElement(Text, { color: "blue" }, " proj:", active.project), active.language && /* @__PURE__ */ React.createElement(Text, { color: "green" }, " lang:", active.language), active.role && /* @__PURE__ */ React.createElement(Text, { color: ROLE_COLOR[active.role] ?? "cyan" }, " role:", active.role), active.tool && /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, " tool:", active.tool), active.tag && /* @__PURE__ */ React.createElement(Text, { color: "cyan" }, " tag:", active.tag), active.starred && /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, " \u2605only"), active.minQuality !== void 0 && /* @__PURE__ */ React.createElement(Text, { color: "green" }, " Q\u2265", active.minQuality), active.minRelevance !== void 0 && /* @__PURE__ */ React.createElement(Text, { color: "green" }, " R\u2265", active.minRelevance), textFilter && /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, " /", textFilter));
1502
+ var Header = ({
1503
+ entriesCount,
1504
+ allEntriesCount,
1505
+ activeFilters,
1506
+ textFilter,
1507
+ isTextFiltering,
1508
+ theme: themeName = "dark"
1509
+ }) => {
1510
+ const theme = THEMES[themeName] || THEMES.dark;
1511
+ const version = package_default.version;
1512
+ const rawCwd = process.cwd();
1513
+ const cwd = rawCwd.replace(process.env.HOME || "", "~");
1514
+ const leftExtra = /* @__PURE__ */ React.createElement(Text, { color: "white", dimColor: true }, " ", entriesCount, entriesCount < allEntriesCount ? `/${allEntriesCount}` : "", " prompts");
1515
+ const rightContent = /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(FilterBadges, { active: activeFilters, textFilter }), isTextFiltering && /* @__PURE__ */ React.createElement(Text, { color: theme.success }, " [typing\u2026]"));
1516
+ return /* @__PURE__ */ React.createElement(
1517
+ AppHeader,
1518
+ {
1519
+ appName: "ph",
1520
+ version,
1521
+ theme,
1522
+ cwd,
1523
+ leftExtra,
1524
+ rightContent
1525
+ }
1526
+ );
1527
+ };
1528
+
1529
+ // src/ui/Footer.tsx
1530
+ import React2 from "react";
1531
+ import { AppFooter, THEMES as THEMES2 } from "@gi4nks/ink";
1532
+ var HINTS = [
1533
+ { key: "\u2191\u2193", description: "nav" },
1534
+ { key: "Tab", description: "pane" },
1535
+ { key: "1/2", description: "tab" },
1536
+ { key: "y", description: "copy" },
1537
+ { key: "s", description: "\u2605" },
1538
+ { key: "e", description: "edit" },
1539
+ { key: "r", description: "rerun" },
1540
+ "|",
1541
+ { key: "x", description: "del" },
1542
+ { key: "/", description: "search" },
1543
+ { key: "f", description: "filter" },
1544
+ { key: "q", description: "quit" }
1545
+ ];
1546
+ var Footer = ({
1547
+ cursor,
1548
+ total,
1549
+ copiedId,
1550
+ theme: themeName = "dark"
1551
+ }) => {
1552
+ const theme = THEMES2[themeName] || THEMES2.dark;
1553
+ const position = total > 0 ? `${cursor + 1}/${total}` : "0/0";
1554
+ return /* @__PURE__ */ React2.createElement(
1555
+ AppFooter,
1556
+ {
1557
+ theme,
1558
+ hints: HINTS,
1559
+ position,
1560
+ toast: copiedId ? "Copied to clipboard" : void 0
1561
+ }
1562
+ );
1563
+ };
1564
+
1565
+ // src/ui/themes.ts
1566
+ import { THEMES as THEMES3, THEME_NAMES } from "@gi4nks/ink";
1567
+
1568
+ // src/ui/SearchBar.tsx
1569
+ import React3 from "react";
1570
+ import { Box, Text as Text2 } from "ink";
1571
+ var SearchBar = ({ value, isActive, theme }) => {
1572
+ if (isActive) {
1573
+ return /* @__PURE__ */ React3.createElement(Box, { paddingX: 1 }, /* @__PURE__ */ React3.createElement(Text2, { color: theme.primary }, "\u2315 ", value, "\u2588"));
1574
+ }
1575
+ const content = value === "" ? "type / to search\u2026" : value;
1576
+ return /* @__PURE__ */ React3.createElement(Box, { paddingX: 1 }, /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, "\u2315 ", content));
1577
+ };
1578
+
1579
+ // src/ui/ListEntry.tsx
1580
+ import React4 from "react";
1581
+ import { Box as Box2, Text as Text3 } from "ink";
1582
+
1583
+ // src/utils/extractTopic.ts
1584
+ function extractTopic(prompt) {
1585
+ let text = prompt.trim();
1586
+ if (!text) {
1587
+ return "(empty prompt)";
1588
+ }
1589
+ if (text.startsWith("```") || text.startsWith("`")) {
1590
+ return truncateAtWordBoundary(text, 72);
1591
+ }
1592
+ const originalTrimmed = text;
1593
+ if (text.startsWith("#")) {
1594
+ const headingMatch = text.match(/^#+\s+(.*?)(?:\s+#+)?(?:\n|$)/);
1595
+ if (headingMatch && headingMatch[1]) {
1596
+ text = headingMatch[1].trim();
1597
+ }
1598
+ }
1599
+ const prefixes = [
1600
+ // English
1601
+ "please, ",
1602
+ "please ",
1603
+ "can you please ",
1604
+ "could you please ",
1605
+ "can you ",
1606
+ "could you ",
1607
+ "would you ",
1608
+ "i need you to ",
1609
+ "i need to ",
1610
+ "i want you to ",
1611
+ "i want to ",
1612
+ "i'd like you to ",
1613
+ "i'd like to ",
1614
+ "help me to ",
1615
+ "help me ",
1616
+ "i need ",
1617
+ "claude, ",
1618
+ "gemini, ",
1619
+ "chatgpt, ",
1620
+ // Italiano
1621
+ "puoi ",
1622
+ "potresti ",
1623
+ "potreste ",
1624
+ "per favore ",
1625
+ "per piacere ",
1626
+ "ho bisogno che ",
1627
+ "ho bisogno di ",
1628
+ "voglio che ",
1629
+ "vorrei che ",
1630
+ "voglio ",
1631
+ "vorrei ",
1632
+ "aiutami a ",
1633
+ "aiutami ",
1634
+ "fammi vedere ",
1635
+ "fammi ",
1636
+ "si prega di ",
1637
+ "si "
1638
+ ];
1639
+ let changed = true;
1640
+ while (changed) {
1641
+ changed = false;
1642
+ const lowerText = text.toLowerCase();
1643
+ for (const prefix of prefixes) {
1644
+ if (lowerText.startsWith(prefix.toLowerCase())) {
1645
+ text = text.slice(prefix.length).trim();
1646
+ changed = true;
1647
+ break;
1648
+ }
1649
+ }
1650
+ }
1651
+ const sentenceEndMatch = text.match(/[\n.?!`]/);
1652
+ if (sentenceEndMatch && sentenceEndMatch.index !== void 0) {
1653
+ text = text.slice(0, sentenceEndMatch.index).trim();
1654
+ }
1655
+ if (!text) {
1656
+ return truncateAtWordBoundary(originalTrimmed, 72);
1657
+ }
1658
+ text = truncateAtWordBoundary(text, 72);
1659
+ return text.charAt(0).toUpperCase() + text.slice(1);
1660
+ }
1661
+ function truncateAtWordBoundary(str, limit) {
1662
+ const clean = str.replace(/\s+/g, " ").trim();
1663
+ if (clean.length <= limit) return clean;
1664
+ const sub = clean.slice(0, limit);
1665
+ const lastSpace = sub.lastIndexOf(" ");
1666
+ if (lastSpace > 0) {
1667
+ return sub.slice(0, lastSpace);
1668
+ }
1669
+ return sub;
1670
+ }
1671
+
1672
+ // src/ui/ListEntry.tsx
1673
+ var ROLE_COLOR2 = {
1674
+ debug: "red",
1675
+ refactor: "yellow",
1676
+ explain: "blue",
1677
+ review: "magenta",
1678
+ architect: "green",
1679
+ test: "cyan",
1680
+ docs: "white",
1681
+ generate: "green",
1682
+ research: "blue"
1683
+ };
1684
+ function toolIndicator(tool) {
1685
+ if (tool === "claude") return { char: "\u25CF", color: "#f5a623" };
1686
+ if (tool === "gemini") return { char: "\u25CF", color: "#4fc3f7" };
1687
+ return { char: "\u25CB", color: "gray" };
1688
+ }
1689
+ function truncate(str, width) {
1690
+ if (str.length <= width) return str;
1691
+ return str.slice(0, width - 1) + "\u2026";
1692
+ }
1693
+ var ListEntry = ({ entry, isSelected, paneWidth, theme }) => {
1694
+ let meta = {};
1695
+ try {
1696
+ meta = JSON.parse(entry.metadata || "{}");
1697
+ } catch {
1698
+ }
1699
+ const indicator = toolIndicator(entry.tool);
1700
+ const date = new Date(entry.timestamp);
1701
+ const hhmm = date.toLocaleTimeString([], {
1702
+ hour: "2-digit",
1703
+ minute: "2-digit",
1704
+ hour12: false
1705
+ });
1706
+ const role = meta.role ? truncate(meta.role, 6) : "";
1707
+ const roleColor = meta.role ? ROLE_COLOR2[meta.role] || "white" : "white";
1708
+ const rawTitle = meta.title || extractTopic(entry.prompt);
1709
+ const displayPrompt = truncate(rawTitle, paneWidth - 3);
1710
+ return /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "row", width: paneWidth, overflow: "hidden" }, /* @__PURE__ */ React4.createElement(Text3, { color: indicator.color }, indicator.char, " "), /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, entry.tool.padEnd(7)), meta.starred ? /* @__PURE__ */ React4.createElement(Text3, { color: theme.warning }, "\u2605 ") : /* @__PURE__ */ React4.createElement(Text3, null, " "), /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, hhmm, " "), role && /* @__PURE__ */ React4.createElement(Text3, { color: roleColor, dimColor: true }, role)), /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "row" }, isSelected ? /* @__PURE__ */ React4.createElement(Text3, { inverse: true, bold: true }, " \u276F ", displayPrompt.padEnd(paneWidth - 3)) : /* @__PURE__ */ React4.createElement(Text3, null, " ", displayPrompt)));
1711
+ };
1712
+
1713
+ // src/ui/PreviewPane.tsx
1714
+ import React5, { useState, useEffect, useMemo } from "react";
1715
+ import { Box as Box3, Text as Text4, useInput } from "ink";
1716
+ var ROLE_COLOR3 = {
1717
+ debug: "red",
1718
+ refactor: "yellow",
1719
+ explain: "blue",
1720
+ review: "magenta",
1721
+ architect: "green",
1722
+ test: "cyan",
1723
+ docs: "white",
1724
+ generate: "green",
1725
+ research: "blue"
1726
+ };
1727
+ function wrapTextLines(text, width) {
1728
+ const lines = text.split("\n");
1729
+ const result = [];
1730
+ for (const line of lines) {
1731
+ if (line.length === 0) {
1732
+ result.push("");
1733
+ continue;
1734
+ }
1735
+ let current = line;
1736
+ while (current.length > width) {
1737
+ result.push(current.substring(0, width));
1738
+ current = current.substring(width);
1739
+ }
1740
+ if (current.length > 0) result.push(current);
1741
+ }
1742
+ return result;
1743
+ }
1744
+ function formatTimestamp2(ts) {
1745
+ try {
1746
+ const d = new Date(ts);
1747
+ const Y = d.getFullYear();
1748
+ const M = String(d.getMonth() + 1).padStart(2, "0");
1749
+ const D = String(d.getDate()).padStart(2, "0");
1750
+ const h = String(d.getHours()).padStart(2, "0");
1751
+ const m = String(d.getMinutes()).padStart(2, "0");
1752
+ return `${Y}-${M}-${D} ${h}:${m}`;
1753
+ } catch {
1754
+ return ts.slice(0, 16);
1755
+ }
1756
+ }
1757
+ function parseMeta2(raw) {
1758
+ try {
1759
+ return JSON.parse(raw);
1760
+ } catch {
1761
+ return {};
1762
+ }
1763
+ }
1764
+ var PreviewPane = ({
1765
+ entry,
1766
+ paneWidth,
1767
+ paneHeight,
1768
+ isFocused,
1769
+ theme
1770
+ }) => {
1771
+ const [activeTab, setActiveTab] = useState("prompt");
1772
+ const [scrollOffset, setScrollOffset] = useState(0);
1773
+ useEffect(() => {
1774
+ setActiveTab("prompt");
1775
+ setScrollOffset(0);
1776
+ }, [entry?.id]);
1777
+ useEffect(() => {
1778
+ setScrollOffset(0);
1779
+ }, [activeTab]);
1780
+ const meta = useMemo(() => entry ? parseMeta2(entry.metadata) : {}, [entry]);
1781
+ const contentWidth = Math.max(10, paneWidth - 4);
1782
+ const contentHeight = Math.max(3, paneHeight - 6);
1783
+ const allLines = useMemo(() => {
1784
+ if (!entry) return [];
1785
+ const text = activeTab === "prompt" ? entry.prompt : entry.response || "(no response captured)";
1786
+ return wrapTextLines(text, contentWidth);
1787
+ }, [entry, activeTab, contentWidth]);
1788
+ const maxScroll = Math.max(0, allLines.length - contentHeight);
1789
+ useInput((_char, key) => {
1790
+ if (!isFocused) return;
1791
+ if (_char === "1") {
1792
+ setActiveTab("prompt");
1793
+ return;
1794
+ }
1795
+ if (_char === "2") {
1796
+ setActiveTab("response");
1797
+ return;
1798
+ }
1799
+ if (key.upArrow) setScrollOffset((s) => Math.max(0, s - 1));
1800
+ if (key.downArrow) setScrollOffset((s) => Math.min(maxScroll, s + 1));
1801
+ if (key.pageUp) setScrollOffset((s) => Math.max(0, s - contentHeight));
1802
+ if (key.pageDown) setScrollOffset((s) => Math.min(maxScroll, s + contentHeight));
1803
+ });
1804
+ if (!entry) {
1805
+ return /* @__PURE__ */ React5.createElement(Box3, { width: paneWidth, height: paneHeight, paddingX: 2, flexDirection: "column", justifyContent: "center" }, /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, "(select an entry to preview)"));
1806
+ }
1807
+ const visibleLines = allLines.slice(scrollOffset, scrollOffset + contentHeight);
1808
+ const paddedLines = [...visibleLines];
1809
+ while (paddedLines.length < contentHeight) paddedLines.push("");
1810
+ const topIndicator = scrollOffset > 0 ? ` \u2191 ${scrollOffset} above` : " ";
1811
+ const remaining = allLines.length - scrollOffset - contentHeight;
1812
+ const botIndicator = scrollOffset < maxScroll ? ` \u2193 ${remaining} more` : " ";
1813
+ const isPromptActive = activeTab === "prompt";
1814
+ const isResponseActive = activeTab === "response";
1815
+ const tabPromptColor = isPromptActive && isFocused ? theme.primary : theme.dim;
1816
+ const tabResponseColor = isResponseActive && isFocused ? theme.primary : theme.dim;
1817
+ return /* @__PURE__ */ React5.createElement(Box3, { flexDirection: "column", paddingX: 2 }, /* @__PURE__ */ React5.createElement(Box3, null, /* @__PURE__ */ React5.createElement(Text4, { color: theme.primary, bold: true }, "#", entry.id), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, " \xB7 ", entry.tool, " \xB7 ", formatTimestamp2(entry.timestamp)), meta.starred && /* @__PURE__ */ React5.createElement(Text4, { color: theme.warning }, " \u2605")), /* @__PURE__ */ React5.createElement(Box3, { marginBottom: 1 }, meta.project && /* @__PURE__ */ React5.createElement(Text4, { color: theme.accent }, "proj:", meta.project, " "), meta.language && /* @__PURE__ */ React5.createElement(Text4, { color: theme.dim }, "lang:", meta.language, " "), meta.role && /* @__PURE__ */ React5.createElement(Text4, { color: ROLE_COLOR3[meta.role] || theme.primary }, "role:", meta.role, " "), meta.quality !== void 0 && /* @__PURE__ */ React5.createElement(Text4, { color: theme.success }, "Q:", meta.quality, " "), meta.relevance !== void 0 && /* @__PURE__ */ React5.createElement(Text4, { color: theme.warning }, "R:", meta.relevance, " "), /* @__PURE__ */ React5.createElement(Text4, { color: entry.exit_code === 0 ? theme.dim : theme.error }, "exit:", entry.exit_code)), /* @__PURE__ */ React5.createElement(Box3, null, /* @__PURE__ */ React5.createElement(
1818
+ Text4,
1819
+ {
1820
+ color: tabPromptColor,
1821
+ bold: isPromptActive && isFocused,
1822
+ underline: isPromptActive && isFocused
1823
+ },
1824
+ isPromptActive ? "\u25CF " : "\u25CB ",
1825
+ "PROMPT"
1826
+ ), /* @__PURE__ */ React5.createElement(Text4, null, " "), /* @__PURE__ */ React5.createElement(
1827
+ Text4,
1828
+ {
1829
+ color: tabResponseColor,
1830
+ bold: isResponseActive && isFocused,
1831
+ underline: isResponseActive && isFocused
1832
+ },
1833
+ isResponseActive ? "\u25CF " : "\u25CB ",
1834
+ "RESPONSE"
1835
+ )), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, topIndicator), paddedLines.map((line, i) => /* @__PURE__ */ React5.createElement(Text4, { key: i }, line || " ")), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, botIndicator));
1836
+ };
1837
+
1838
+ // src/ui/BrowseApp.tsx
1839
+ var FILTER_CATEGORIES = ["project", "language", "role", "tool", "tag", "starred", "quality", "relevance"];
1840
+ var CATEGORY_LABEL = {
1841
+ project: "Project",
1842
+ language: "Language",
1843
+ role: "Role",
1844
+ tool: "Tool",
1845
+ tag: "Tag",
1846
+ starred: "Starred",
1847
+ quality: "Quality",
1848
+ relevance: "Relevance"
1849
+ };
1850
+ var ROLE_COLOR4 = {
1851
+ debug: "red",
1852
+ refactor: "yellow",
1853
+ explain: "blue",
1854
+ review: "magenta",
1855
+ architect: "green",
1856
+ test: "cyan",
1857
+ docs: "white",
1858
+ generate: "green",
1859
+ research: "blue"
1860
+ };
1861
+ function copyToClipboard(text) {
1862
+ try {
1863
+ const platform = process.platform;
1864
+ let cmd = "";
1865
+ let args = [];
1866
+ if (platform === "darwin") {
1867
+ cmd = "pbcopy";
1868
+ } else if (platform === "win32") {
1869
+ cmd = "clip";
1870
+ } else {
1871
+ cmd = "xclip";
1872
+ args = ["-selection", "clipboard"];
1873
+ }
1874
+ const proc = spawn3(cmd, args);
1875
+ proc.stdin.write(text);
1876
+ proc.stdin.end();
1877
+ } catch {
1878
+ }
1879
+ }
1880
+ function wrapTextLines2(text, width) {
1881
+ const result = [];
1882
+ for (const line of text.split("\n")) {
1883
+ let current = line;
1884
+ if (current.length === 0) {
1885
+ result.push("");
1886
+ continue;
1887
+ }
1888
+ while (current.length > width) {
1889
+ result.push(current.slice(0, width));
1890
+ current = current.slice(width);
1891
+ }
1892
+ if (current.length > 0) result.push(current);
1893
+ }
1894
+ return result;
1895
+ }
1896
+ function useStdoutDimensions() {
1897
+ const [dimensions, setDimensions] = useState2({
1898
+ columns: process.stdout.columns || 120,
1899
+ rows: process.stdout.rows || 24
1900
+ });
1901
+ useEffect2(() => {
1902
+ const handler = () => {
1903
+ setDimensions({
1904
+ columns: process.stdout.columns || 120,
1905
+ rows: process.stdout.rows || 24
1906
+ });
1907
+ };
1908
+ process.stdout.on("resize", handler);
1909
+ return () => {
1910
+ process.stdout.off("resize", handler);
1911
+ };
1912
+ }, []);
1913
+ return dimensions;
1914
+ }
1915
+ function formatTimestamp3(ts) {
1916
+ try {
1917
+ const d = new Date(ts);
1918
+ const p = (n) => String(n).padStart(2, "0");
1919
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
1920
+ } catch {
1921
+ return ts.slice(0, 16);
1922
+ }
1923
+ }
1924
+ function parseMeta3(raw) {
1925
+ try {
1926
+ return JSON.parse(raw);
1927
+ } catch {
1928
+ return {};
1929
+ }
1930
+ }
1931
+ function getDistinctValues(entries, category) {
1932
+ const set = /* @__PURE__ */ new Set();
1933
+ for (const e of entries) {
1934
+ if (category === "tool") {
1935
+ set.add(e.tool);
1936
+ continue;
1937
+ }
1938
+ const meta = parseMeta3(e.metadata);
1939
+ if (category === "project" && meta.project) set.add(meta.project);
1940
+ if (category === "language" && meta.language) set.add(meta.language);
1941
+ if (category === "role" && meta.role) set.add(meta.role);
1942
+ if (category === "tag") meta.tags?.forEach((t) => set.add(t));
1943
+ }
1944
+ return [...set].sort();
1945
+ }
1946
+ function applyFilters(entries, active, textFilter) {
1947
+ let result = entries;
1948
+ const hasActiveFilter = Object.values(active).some((v) => v !== void 0 && v !== false);
1949
+ if (hasActiveFilter) {
1950
+ result = result.filter((e) => {
1951
+ const meta = parseMeta3(e.metadata);
1952
+ if (active.tool && e.tool !== active.tool) return false;
1953
+ if (active.project && meta.project !== active.project) return false;
1954
+ if (active.language && meta.language !== active.language) return false;
1955
+ if (active.role && meta.role !== active.role) return false;
1956
+ if (active.tag && !meta.tags?.includes(active.tag)) return false;
1957
+ if (active.starred && !meta.starred) return false;
1958
+ if (active.minQuality !== void 0 && (meta.quality ?? 0) < active.minQuality) return false;
1959
+ if (active.minRelevance !== void 0 && (meta.relevance ?? 0) < active.minRelevance) return false;
1960
+ return true;
1961
+ });
1962
+ }
1963
+ if (textFilter) {
1964
+ const lq = textFilter.toLowerCase();
1965
+ result = result.filter((e) => {
1966
+ const meta = parseMeta3(e.metadata);
1967
+ return e.prompt.toLowerCase().includes(lq) || e.tool.toLowerCase().includes(lq) || (meta.project?.toLowerCase().includes(lq) ?? false) || (meta.role?.toLowerCase().includes(lq) ?? false) || (meta.tags?.some((t) => t.toLowerCase().includes(lq)) ?? false);
1968
+ });
1969
+ }
1970
+ return result;
1971
+ }
1972
+ var DetailView = ({ entry, onClose, onEdit, termWidth, termHeight, theme }) => {
1973
+ const meta = parseMeta3(entry.metadata);
1974
+ const [activeTab, setActiveTab] = useState2(entry.response ? "response" : "prompt");
1975
+ const [scrollOffset, setScrollOffset] = useState2(0);
1976
+ const [copied, setCopied] = useState2(false);
1977
+ useEffect2(() => {
1978
+ setScrollOffset(0);
1979
+ }, [activeTab]);
1980
+ const contentWidth = Math.max(10, termWidth - 4);
1981
+ const text = activeTab === "prompt" ? entry.prompt : entry.response || "(no response captured)";
1982
+ const lines = useMemo2(() => wrapTextLines2(text, contentWidth), [text, contentWidth]);
1983
+ const contentHeight = Math.max(1, termHeight - 10);
1984
+ const maxScroll = Math.max(0, lines.length - contentHeight);
1985
+ useInput2((char, key) => {
1986
+ if (key.escape || key.return) onClose();
1987
+ else if (char === "e") onEdit();
1988
+ else if (key.tab || char === "1" || char === "2") {
1989
+ if (char === "1") setActiveTab("prompt");
1990
+ else if (char === "2") setActiveTab("response");
1991
+ else setActiveTab((t) => t === "prompt" ? "response" : "prompt");
1992
+ } else if (char === "y") {
1993
+ copyToClipboard(text);
1994
+ setCopied(true);
1995
+ setTimeout(() => setCopied(false), 2e3);
1996
+ } else if (key.upArrow) setScrollOffset((s) => Math.max(0, s - 1));
1997
+ else if (key.downArrow) setScrollOffset((s) => Math.min(maxScroll, s + 1));
1998
+ else if (key.pageUp) setScrollOffset((s) => Math.max(0, s - contentHeight));
1999
+ else if (key.pageDown) setScrollOffset((s) => Math.min(maxScroll, s + contentHeight));
2000
+ });
2001
+ const visibleLines = lines.slice(scrollOffset, scrollOffset + contentHeight);
2002
+ const remaining = lines.length - (scrollOffset + contentHeight);
2003
+ return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", paddingX: 2, paddingTop: 1, flexGrow: 1 }, /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true }, "#", entry.id), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, " \xB7 ", entry.tool, " \xB7 ", formatTimestamp3(entry.timestamp), " "), meta.starred && /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, "\u2605")), /* @__PURE__ */ React6.createElement(Box4, null, meta.project && /* @__PURE__ */ React6.createElement(Text5, { color: theme.accent }, "proj:", meta.project, " "), meta.language && /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "lang:", meta.language, " "), meta.role && /* @__PURE__ */ React6.createElement(Text5, { color: ROLE_COLOR4[meta.role] ?? theme.primary }, "role:", meta.role, " "), meta.quality !== void 0 && /* @__PURE__ */ React6.createElement(Text5, { color: theme.success }, "Q:", meta.quality, " "), meta.relevance !== void 0 && /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, "R:", meta.relevance, " "), /* @__PURE__ */ React6.createElement(Text5, { color: entry.exit_code === 0 ? theme.dim : theme.error }, "exit:", entry.exit_code)), /* @__PURE__ */ React6.createElement(Box4, { height: 1 }), /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 0, justifyContent: "space-between" }, /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true, underline: true }, activeTab === "prompt" ? "\u25CF" : "\u25CB", " PROMPT"), /* @__PURE__ */ React6.createElement(Text5, null, " "), /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true, underline: true }, activeTab === "response" ? "\u25CF" : "\u25CB", " RESPONSE")), /* @__PURE__ */ React6.createElement(Box4, null, copied && /* @__PURE__ */ React6.createElement(Text5, { color: theme.success }, "Copied! "), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "[y:copy e:edit ESC:back]"))), /* @__PURE__ */ React6.createElement(Box4, { height: 1 }, scrollOffset > 0 && /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, " \u2191 ", scrollOffset, " above")), /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", flexGrow: 1 }, visibleLines.map((line, i) => /* @__PURE__ */ React6.createElement(Text5, { key: i }, line || " ")), Array.from({ length: Math.max(0, contentHeight - visibleLines.length) }).map((_, i) => /* @__PURE__ */ React6.createElement(Text5, { key: `pad-${i}` }, " "))), /* @__PURE__ */ React6.createElement(Box4, { height: 1 }, remaining > 0 && /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, " \u2193 ", remaining, " more")));
2004
+ };
2005
+ var EditView = ({ entry, onSave, onClose, theme }) => {
2006
+ const initMeta = parseMeta3(entry.metadata);
2007
+ const [field, setField] = useState2("title");
2008
+ const [titleValue, setTitleValue] = useState2(initMeta.title ?? extractTopic(entry.prompt));
2009
+ const [roleValue, setRoleValue] = useState2(initMeta.role ?? "");
2010
+ const [tagsValue, setTagsValue] = useState2(initMeta.tags?.join(", ") ?? "");
2011
+ useInput2((char, key) => {
2012
+ if (key.escape) {
2013
+ onClose();
2014
+ return;
2015
+ }
2016
+ if (key.return) {
2017
+ const newMeta = { ...initMeta };
2018
+ const trimmedTitle = titleValue.trim();
2019
+ newMeta.title = trimmedTitle || void 0;
2020
+ const trimmedRole = roleValue.trim();
2021
+ newMeta.role = trimmedRole || void 0;
2022
+ const parsedTags = tagsValue.split(",").map((t) => t.trim()).filter(Boolean);
2023
+ newMeta.tags = parsedTags.length > 0 ? parsedTags : void 0;
2024
+ onSave(newMeta);
2025
+ return;
2026
+ }
2027
+ if (key.tab) {
2028
+ setField((f) => {
2029
+ if (f === "title") return "role";
2030
+ if (f === "role") return "tags";
2031
+ return "title";
2032
+ });
2033
+ return;
2034
+ }
2035
+ const setter = field === "title" ? setTitleValue : field === "role" ? setRoleValue : setTagsValue;
2036
+ if (key.backspace || key.delete) {
2037
+ setter((v) => v.slice(0, -1));
2038
+ } else if (char && !key.ctrl && !key.meta) {
2039
+ setter((v) => v + char);
2040
+ }
2041
+ });
2042
+ const ROLES = "debug \xB7 refactor \xB7 explain \xB7 review \xB7 architect \xB7 test \xB7 docs \xB7 generate \xB7 research";
2043
+ return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true }, "Edit Metadata \u2014 Prompt #", entry.id)), /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", borderStyle: "single", borderColor: theme.dim, padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Title: "), /* @__PURE__ */ React6.createElement(Text5, { color: field === "title" ? theme.primary : "white", bold: field === "title" }, titleValue || "(none)", field === "title" ? "\u2588" : "")), /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Role: "), /* @__PURE__ */ React6.createElement(Text5, { color: field === "role" ? theme.primary : "white", bold: field === "role" }, roleValue || "(none)", field === "role" ? "\u2588" : "")), /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Tags: "), /* @__PURE__ */ React6.createElement(Text5, { color: field === "tags" ? theme.primary : "white", bold: field === "tags" }, tagsValue || "(none)", field === "tags" ? "\u2588" : ""))), /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Roles: ", ROLES)), /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Tab switch field \xB7 Enter save \xB7 ESC cancel")));
2044
+ };
2045
+ var RerunView = ({ entry, onConfirm, onClose, theme }) => {
2046
+ const [field, setField] = useState2("prompt");
2047
+ const [toolValue, setToolValue] = useState2(entry.tool);
2048
+ const [promptValue, setPromptValue] = useState2(entry.prompt);
2049
+ const meta = parseMeta3(entry.metadata);
2050
+ const git = meta.git_context;
2051
+ useInput2((char, key) => {
2052
+ if (key.escape) {
2053
+ onClose();
2054
+ return;
2055
+ }
2056
+ if (key.return) {
2057
+ onConfirm(toolValue.trim(), promptValue.trim());
2058
+ return;
2059
+ }
2060
+ if (key.tab) {
2061
+ setField((f) => f === "tool" ? "prompt" : "tool");
2062
+ return;
2063
+ }
2064
+ const setter = field === "tool" ? setToolValue : setPromptValue;
2065
+ if (key.backspace || key.delete) {
2066
+ setter((v) => v.slice(0, -1));
2067
+ } else if (char && !key.ctrl && !key.meta) {
2068
+ setter((v) => v + char);
2069
+ }
2070
+ });
2071
+ return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true }, "Rerun Prompt #", entry.id)), /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Tool: ", /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, entry.tool)), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Date: ", /* @__PURE__ */ React6.createElement(Text5, null, formatTimestamp3(entry.timestamp))), meta.project && /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Project: ", /* @__PURE__ */ React6.createElement(Text5, { color: theme.accent }, meta.project)), git && /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Git: ", /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, "Captured on branch: ", git.branch, ", ", git.files?.length || 0, " files modified"))), /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", borderStyle: "single", borderColor: theme.dim, padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Tool: "), /* @__PURE__ */ React6.createElement(Text5, { color: field === "tool" ? theme.primary : "white", bold: field === "tool" }, toolValue, field === "tool" ? "\u2588" : "")), /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column" }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Prompt: "), /* @__PURE__ */ React6.createElement(Box4, { borderStyle: "round", borderColor: field === "prompt" ? theme.primary : theme.dim, paddingX: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: field === "prompt" ? theme.primary : "white" }, promptValue, field === "prompt" ? "\u2588" : "")))), /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "Tab switch \xB7 Enter run \xB7 ESC cancel")));
2072
+ };
2073
+ var FilterPanel = ({ allEntries, active, onUpdate, onClose, theme }) => {
2074
+ const [catIdx, setCatIdx] = useState2(0);
2075
+ const [itemIdx, setItemIdx] = useState2(0);
2076
+ const category = FILTER_CATEGORIES[catIdx];
2077
+ const isStarred = category === "starred";
2078
+ const isQuality = category === "quality";
2079
+ const isRelevance = category === "relevance";
2080
+ const values = useMemo2(
2081
+ () => {
2082
+ if (isStarred) return [];
2083
+ if (isQuality || isRelevance) return ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
2084
+ return getDistinctValues(allEntries, category);
2085
+ },
2086
+ [allEntries, category, isStarred, isQuality, isRelevance]
2087
+ );
2088
+ const allValues = isStarred ? [] : ["(all)", ...values];
2089
+ const currentCatValue = useMemo2(() => {
2090
+ if (isStarred) return active.starred ? "yes" : void 0;
2091
+ if (isQuality) return active.minQuality?.toString();
2092
+ if (isRelevance) return active.minRelevance?.toString();
2093
+ return active[category];
2094
+ }, [active, category, isStarred, isQuality, isRelevance]);
2095
+ const ITEMS_VISIBLE = 14;
2096
+ const pageStart = Math.max(0, Math.min(itemIdx - Math.floor(ITEMS_VISIBLE / 2), allValues.length - ITEMS_VISIBLE));
2097
+ const visibleValues = allValues.slice(pageStart, pageStart + ITEMS_VISIBLE);
2098
+ useInput2((char, key) => {
2099
+ if (key.escape) {
2100
+ onClose();
2101
+ return;
2102
+ }
2103
+ if (key.tab) {
2104
+ const next = (catIdx + 1) % FILTER_CATEGORIES.length;
2105
+ setCatIdx(next);
2106
+ setItemIdx(0);
2107
+ return;
2108
+ }
2109
+ if (key.leftArrow) {
2110
+ setCatIdx((i) => (i - 1 + FILTER_CATEGORIES.length) % FILTER_CATEGORIES.length);
2111
+ setItemIdx(0);
2112
+ } else if (key.rightArrow) {
2113
+ setCatIdx((i) => (i + 1) % FILTER_CATEGORIES.length);
2114
+ setItemIdx(0);
2115
+ } else if (key.upArrow) {
2116
+ setItemIdx((i) => Math.max(0, i - 1));
2117
+ } else if (key.downArrow) {
2118
+ if (isStarred) {
2119
+ onUpdate({ ...active, starred: !active.starred || void 0 });
2120
+ } else {
2121
+ setItemIdx((i) => Math.min(allValues.length - 1, i + 1));
2122
+ }
2123
+ } else if (key.return || char === " ") {
2124
+ if (isStarred) {
2125
+ onUpdate({ ...active, starred: !active.starred || void 0 });
2126
+ } else {
2127
+ const selected = allValues[itemIdx];
2128
+ if (!selected) return;
2129
+ if (selected === "(all)") {
2130
+ const updated = { ...active };
2131
+ if (isQuality) delete updated.minQuality;
2132
+ else if (isRelevance) delete updated.minRelevance;
2133
+ else delete updated[category];
2134
+ onUpdate(updated);
2135
+ } else {
2136
+ if (isQuality) onUpdate({ ...active, minQuality: Number(selected) });
2137
+ else if (isRelevance) onUpdate({ ...active, minRelevance: Number(selected) });
2138
+ else onUpdate({ ...active, [category]: selected });
2139
+ }
2140
+ }
2141
+ } else if (char === "c") {
2142
+ onUpdate({});
2143
+ }
2144
+ });
2145
+ const activeCount = Object.values(active).filter((v) => v !== void 0 && v !== false).length;
2146
+ return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true }, "Filter Panel "), activeCount > 0 ? /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, activeCount, " active filter", activeCount > 1 ? "s" : "", " ") : /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "no filters active "), activeCount > 0 && /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "(c to clear all)")), /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, FILTER_CATEGORIES.map((cat, i) => {
2147
+ let catVal;
2148
+ if (cat === "starred") catVal = active.starred ? "\u2605" : void 0;
2149
+ else if (cat === "quality") catVal = active.minQuality ? `\u2265${active.minQuality}` : void 0;
2150
+ else if (cat === "relevance") catVal = active.minRelevance ? `\u2265${active.minRelevance}` : void 0;
2151
+ else catVal = active[cat];
2152
+ const isActive = i === catIdx;
2153
+ return /* @__PURE__ */ React6.createElement(Box4, { key: cat, marginRight: 2 }, /* @__PURE__ */ React6.createElement(Text5, { color: isActive ? theme.primary : catVal ? theme.warning : theme.dim, bold: isActive }, CATEGORY_LABEL[cat], catVal ? `[${catVal}]` : ""));
2154
+ })), /* @__PURE__ */ React6.createElement(Box4, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1, minHeight: 16 }, isStarred ? /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: active.starred ? theme.warning : theme.dim, bold: active.starred }, "\u276F ", active.starred ? "\u2605" : "\u2606", " Only starred prompts", active.starred ? " \u2713" : "")) : allValues.length === 0 ? /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "(no values found)") : visibleValues.map((val, i) => {
2155
+ const absIdx = pageStart + i;
2156
+ const isCurrent = val === currentCatValue || val === "(all)" && !currentCatValue;
2157
+ return /* @__PURE__ */ React6.createElement(Box4, { key: val }, /* @__PURE__ */ React6.createElement(
2158
+ Text5,
2159
+ {
2160
+ color: absIdx === itemIdx ? theme.primary : isCurrent ? theme.warning : theme.dim,
2161
+ bold: absIdx === itemIdx
2162
+ },
2163
+ absIdx === itemIdx ? "\u276F " : " ",
2164
+ val,
2165
+ isCurrent && val !== "(all)" ? " \u2713" : ""
2166
+ ));
2167
+ })), /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "\u2190\u2192/Tab switch category \xB7 \u2191\u2193 navigate \xB7 Enter/Space select \xB7 c clear all \xB7 ESC close")));
2168
+ };
2169
+ var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2170
+ const { exit } = useApp();
2171
+ const [allEntries, setAllEntries] = useState2(() => db.search({ limit: 1e3 }));
2172
+ const [refreshKey, setRefreshKey] = useState2(0);
2173
+ const [textFilter, setTextFilter] = useState2(initialTextFilter ?? "");
2174
+ const [isTextFiltering, setTextFiltering] = useState2(false);
2175
+ const [activeFilters, setActiveFilters] = useState2(initialFilters ?? {});
2176
+ const [showFilterPanel, setFilterPanel] = useState2(false);
2177
+ const [cursor, setCursor] = useState2(0);
2178
+ const [detail, setDetail] = useState2(null);
2179
+ const [editing, setEditing] = useState2(null);
2180
+ const [rerunning, setRerunning] = useState2(null);
2181
+ const [copiedId, setCopiedId] = useState2(null);
2182
+ const [focusPane, setFocusPane] = useState2("list");
2183
+ const [scrollOffset, setScrollOffset] = useState2(0);
2184
+ const currentThemeName = "dark";
2185
+ const theme = THEMES3[currentThemeName] || THEMES3.dark;
2186
+ const { columns: termWidth, rows: termHeight } = useStdoutDimensions();
2187
+ const SPLIT_THRESHOLD = 100;
2188
+ const isWide = termWidth >= SPLIT_THRESHOLD;
2189
+ const leftPaneWidth = isWide ? Math.max(32, Math.min(50, Math.floor(termWidth * 0.33))) : termWidth;
2190
+ const PAGE_SIZE = Math.max(1, Math.floor((termHeight - 3) / 3));
2191
+ const entries = useMemo2(
2192
+ () => applyFilters(allEntries, activeFilters, textFilter),
2193
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2194
+ [allEntries, activeFilters, textFilter, refreshKey]
2195
+ );
2196
+ useEffect2(() => {
2197
+ setCursor((c) => Math.min(c, Math.max(0, entries.length - 1)));
2198
+ }, [entries.length]);
2199
+ useEffect2(() => {
2200
+ setScrollOffset((prev) => {
2201
+ const clamped = Math.max(0, Math.min(prev, Math.max(0, entries.length - PAGE_SIZE)));
2202
+ if (cursor < clamped) return cursor;
2203
+ if (cursor >= clamped + PAGE_SIZE) return cursor - PAGE_SIZE + 1;
2204
+ return clamped;
2205
+ });
2206
+ }, [cursor, PAGE_SIZE, entries.length]);
2207
+ const pageStart = scrollOffset;
2208
+ const visible = entries.slice(pageStart, pageStart + PAGE_SIZE);
2209
+ const triggerRefresh = useCallback(() => setRefreshKey((k) => k + 1), []);
2210
+ const toggleStar = useCallback((entry) => {
2211
+ const meta = parseMeta3(entry.metadata);
2212
+ meta.starred = !meta.starred;
2213
+ const newMeta = JSON.stringify(meta);
2214
+ db.updateMetadata(entry.id, newMeta);
2215
+ entry.metadata = newMeta;
2216
+ triggerRefresh();
2217
+ }, [db, triggerRefresh]);
2218
+ const handleDelete = useCallback((entry) => {
2219
+ db.deleteById(entry.id);
2220
+ setAllEntries((prev) => prev.filter((e) => e.id !== entry.id));
2221
+ }, [db]);
2222
+ const handleSaveEdit = useCallback((entry, meta) => {
2223
+ const newMeta = JSON.stringify(meta);
2224
+ db.updateMetadata(entry.id, newMeta);
2225
+ entry.metadata = newMeta;
2226
+ setEditing(null);
2227
+ triggerRefresh();
2228
+ }, [db, triggerRefresh]);
2229
+ const handleFilterUpdate = useCallback((filters) => {
2230
+ setActiveFilters(filters);
2231
+ setCursor(0);
2232
+ setScrollOffset(0);
2233
+ }, []);
2234
+ useInput2((char, key) => {
2235
+ if (detail || editing || showFilterPanel || rerunning) return;
2236
+ if (isTextFiltering) {
2237
+ if (key.escape) {
2238
+ setTextFiltering(false);
2239
+ setTextFilter("");
2240
+ } else if (key.return) {
2241
+ setTextFiltering(false);
2242
+ } else if (key.backspace || key.delete) {
2243
+ setTextFilter((v) => v.slice(0, -1));
2244
+ } else if (char && !key.ctrl) {
2245
+ setTextFilter((v) => v + char);
2246
+ }
2247
+ return;
2248
+ }
2249
+ if (key.escape) {
2250
+ if (isWide && focusPane === "preview") {
2251
+ setFocusPane("list");
2252
+ return;
2253
+ }
2254
+ exit();
2255
+ return;
2256
+ } else if (char === "q") {
2257
+ exit();
2258
+ } else if (char === "/") {
2259
+ setTextFiltering(true);
2260
+ } else if (char === "s") {
2261
+ if (entries[cursor]) toggleStar(entries[cursor]);
2262
+ } else if (char === "e") {
2263
+ if (entries[cursor]) setEditing(entries[cursor]);
2264
+ } else if (char === "r") {
2265
+ if (entries[cursor]) setRerunning(entries[cursor]);
2266
+ } else if (char === "x") {
2267
+ if (entries[cursor]) handleDelete(entries[cursor]);
2268
+ } else if (char === "f") {
2269
+ setFilterPanel(true);
2270
+ } else if (char === "c") {
2271
+ setActiveFilters({});
2272
+ setTextFilter("");
2273
+ setCursor(0);
2274
+ setScrollOffset(0);
2275
+ } else if (char === "y") {
2276
+ if (entries[cursor]) {
2277
+ copyToClipboard(entries[cursor].prompt);
2278
+ setCopiedId(entries[cursor].id);
2279
+ setTimeout(() => setCopiedId(null), 2e3);
2280
+ }
2281
+ } else if (key.tab && isWide) {
2282
+ setFocusPane((p) => p === "list" ? "preview" : "list");
2283
+ } else if (focusPane === "list" || !isWide) {
2284
+ if (key.upArrow) {
2285
+ setCursor((c) => Math.max(0, c - 1));
2286
+ } else if (key.downArrow) {
2287
+ setCursor((c) => Math.min(entries.length - 1, c + 1));
2288
+ } else if (key.pageUp) {
2289
+ setCursor((c) => Math.max(0, c - PAGE_SIZE));
2290
+ } else if (key.pageDown) {
2291
+ setCursor((c) => Math.min(entries.length - 1, c + PAGE_SIZE));
2292
+ } else if (key.return) {
2293
+ if (isWide) {
2294
+ if (entries[cursor]) {
2295
+ copyToClipboard(entries[cursor].prompt);
2296
+ setCopiedId(entries[cursor].id);
2297
+ setTimeout(() => setCopiedId(null), 2e3);
2298
+ }
2299
+ } else {
2300
+ if (entries[cursor]) setDetail(entries[cursor]);
2301
+ }
2302
+ }
2303
+ }
2304
+ });
2305
+ let mainContent;
2306
+ if (showFilterPanel) {
2307
+ mainContent = /* @__PURE__ */ React6.createElement(
2308
+ FilterPanel,
2309
+ {
2310
+ allEntries,
2311
+ active: activeFilters,
2312
+ onUpdate: handleFilterUpdate,
2313
+ onClose: () => setFilterPanel(false),
2314
+ theme
2315
+ }
2316
+ );
2317
+ } else if (rerunning) {
2318
+ mainContent = /* @__PURE__ */ React6.createElement(
2319
+ RerunView,
2320
+ {
2321
+ entry: rerunning,
2322
+ onConfirm: (tool, prompt) => {
2323
+ onRerun?.(tool, prompt);
2324
+ setRerunning(null);
2325
+ exit();
2326
+ },
2327
+ onClose: () => setRerunning(null),
2328
+ theme
2329
+ }
2330
+ );
2331
+ } else if (editing) {
2332
+ mainContent = /* @__PURE__ */ React6.createElement(
2333
+ EditView,
2334
+ {
2335
+ entry: editing,
2336
+ onSave: (meta) => handleSaveEdit(editing, meta),
2337
+ onClose: () => setEditing(null),
2338
+ theme
2339
+ }
2340
+ );
2341
+ } else if (detail && !isWide) {
2342
+ mainContent = /* @__PURE__ */ React6.createElement(
2343
+ DetailView,
2344
+ {
2345
+ entry: detail,
2346
+ onClose: () => setDetail(null),
2347
+ onEdit: () => {
2348
+ setEditing(detail);
2349
+ setDetail(null);
2350
+ },
2351
+ termWidth,
2352
+ termHeight,
2353
+ theme
2354
+ }
2355
+ );
2356
+ } else if (isWide) {
2357
+ const listFocused = focusPane === "list";
2358
+ const previewFocused = focusPane === "preview";
2359
+ mainContent = /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "row", flexGrow: 1 }, /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", width: leftPaneWidth }, /* @__PURE__ */ React6.createElement(Box4, { paddingX: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: listFocused ? theme.primary : theme.dim, bold: listFocused }, listFocused ? "\u276F " : " ", "HISTORY"), entries.length > 0 && /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, " ", cursor + 1, "/", entries.length)), entries.length === 0 ? /* @__PURE__ */ React6.createElement(Box4, { paddingX: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "(no results)")) : visible.map((entry, i) => /* @__PURE__ */ React6.createElement(React6.Fragment, { key: entry.id }, /* @__PURE__ */ React6.createElement(
2360
+ ListEntry,
2361
+ {
2362
+ entry,
2363
+ isSelected: pageStart + i === cursor,
2364
+ paneWidth: leftPaneWidth,
2365
+ theme
2366
+ }
2367
+ ), i < visible.length - 1 && /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "\u2500".repeat(leftPaneWidth)))))), /* @__PURE__ */ React6.createElement(
2368
+ Box4,
2369
+ {
2370
+ borderLeft: true,
2371
+ borderStyle: "single",
2372
+ borderColor: previewFocused ? theme.primary : theme.dim,
2373
+ flexGrow: 1,
2374
+ flexDirection: "column"
2375
+ },
2376
+ /* @__PURE__ */ React6.createElement(
2377
+ PreviewPane,
2378
+ {
2379
+ entry: entries[cursor] ?? null,
2380
+ paneWidth: termWidth - leftPaneWidth - 1,
2381
+ paneHeight: termHeight - 3,
2382
+ isFocused: previewFocused,
2383
+ theme
2384
+ }
2385
+ )
2386
+ ));
2387
+ } else {
2388
+ mainContent = /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", paddingX: 1, flexGrow: 1 }, entries.length === 0 ? /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "(no results)")) : visible.map((entry, i) => /* @__PURE__ */ React6.createElement(React6.Fragment, { key: entry.id }, /* @__PURE__ */ React6.createElement(
2389
+ ListEntry,
2390
+ {
2391
+ entry,
2392
+ isSelected: pageStart + i === cursor,
2393
+ paneWidth: termWidth,
2394
+ theme
2395
+ }
2396
+ ), i < visible.length - 1 && /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "\u2500".repeat(termWidth))))));
2397
+ }
2398
+ return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", height: termHeight }, /* @__PURE__ */ React6.createElement(
2399
+ Header,
2400
+ {
2401
+ entriesCount: entries.length,
2402
+ allEntriesCount: allEntries.length,
2403
+ activeFilters,
2404
+ textFilter,
2405
+ isTextFiltering: false,
2406
+ theme: currentThemeName
2407
+ }
2408
+ ), /* @__PURE__ */ React6.createElement(SearchBar, { value: textFilter, isActive: isTextFiltering, theme }), /* @__PURE__ */ React6.createElement(Box4, { flexGrow: 1 }, mainContent), /* @__PURE__ */ React6.createElement(
2409
+ Footer,
2410
+ {
2411
+ cursor,
2412
+ total: entries.length,
2413
+ copiedId,
2414
+ theme: currentThemeName
2415
+ }
2416
+ ));
2417
+ };
2418
+
2419
+ // src/sessions/index.ts
2420
+ function groupIntoSessions(entries, gapHours) {
2421
+ if (entries.length === 0) return [];
2422
+ const sessions = [];
2423
+ const gapMs = gapHours * 60 * 60 * 1e3;
2424
+ let current = [entries[0]];
2425
+ let lastTime = new Date(entries[0].timestamp).getTime();
2426
+ for (let i = 1; i < entries.length; i++) {
2427
+ const entry = entries[i];
2428
+ const entryTime = new Date(entry.timestamp).getTime();
2429
+ const gap = entryTime - lastTime;
2430
+ if (gap > gapMs) {
2431
+ sessions.push(makeSession(sessions.length + 1, current));
2432
+ current = [entry];
2433
+ } else {
2434
+ current.push(entry);
2435
+ }
2436
+ lastTime = entryTime;
2437
+ }
2438
+ if (current.length > 0) {
2439
+ sessions.push(makeSession(sessions.length + 1, current));
2440
+ }
2441
+ return sessions;
2442
+ }
2443
+ function makeSession(index, entries) {
2444
+ return {
2445
+ index,
2446
+ entries,
2447
+ startTime: new Date(entries[0].timestamp),
2448
+ endTime: new Date(entries[entries.length - 1].timestamp),
2449
+ cohesion: null
2450
+ };
2451
+ }
2452
+ function computeSessionCohesion(session, embeddings) {
2453
+ const vecs = [];
2454
+ for (const entry of session.entries) {
2455
+ const vec = embeddings.get(entry.id);
2456
+ if (vec) vecs.push(vec);
2457
+ }
2458
+ if (vecs.length < 2) return null;
2459
+ let sum = 0;
2460
+ let count = 0;
2461
+ for (let i = 0; i < vecs.length; i++) {
2462
+ for (let j = i + 1; j < vecs.length; j++) {
2463
+ sum += cosineSimilarity(vecs[i], vecs[j]);
2464
+ count++;
2465
+ }
2466
+ }
2467
+ return count > 0 ? sum / count : null;
2468
+ }
2469
+
2470
+ // src/stats/index.ts
2471
+ function getStats(db) {
2472
+ const all = db.search({ limit: 1e6 });
2473
+ const stats = {
2474
+ total: all.length,
2475
+ byTool: {},
2476
+ byProject: {},
2477
+ byLanguage: {},
2478
+ byRole: {},
2479
+ analyzed: 0,
2480
+ starred: 0,
2481
+ avgRelevance: 0,
2482
+ avgQuality: 0
2483
+ };
2484
+ let totalRel = 0;
2485
+ let totalQual = 0;
2486
+ let analyzedWithScores = 0;
2487
+ for (const entry of all) {
2488
+ stats.byTool[entry.tool] = (stats.byTool[entry.tool] ?? 0) + 1;
2489
+ let meta = {};
2490
+ try {
2491
+ meta = JSON.parse(entry.metadata);
2492
+ } catch {
2493
+ }
2494
+ if (meta.project) {
2495
+ stats.byProject[meta.project] = (stats.byProject[meta.project] ?? 0) + 1;
2496
+ }
2497
+ if (meta.language) {
2498
+ stats.byLanguage[meta.language] = (stats.byLanguage[meta.language] ?? 0) + 1;
2499
+ }
2500
+ if (meta.role) {
2501
+ stats.byRole[meta.role] = (stats.byRole[meta.role] ?? 0) + 1;
2502
+ stats.analyzed++;
2503
+ }
2504
+ if (meta.starred) {
2505
+ stats.starred++;
2506
+ }
2507
+ if (meta.relevance !== void 0 || meta.quality !== void 0) {
2508
+ totalRel += meta.relevance ?? 0;
2509
+ totalQual += meta.quality ?? 0;
2510
+ analyzedWithScores++;
2511
+ }
2512
+ }
2513
+ if (analyzedWithScores > 0) {
2514
+ stats.avgRelevance = totalRel / analyzedWithScores;
2515
+ stats.avgQuality = totalQual / analyzedWithScores;
2516
+ }
2517
+ return stats;
2518
+ }
2519
+
2520
+ // src/cluster/index.ts
2521
+ function kmeans(embeddings, k, maxIterations = 20) {
2522
+ const ids = Array.from(embeddings.keys());
2523
+ const vecs = Array.from(embeddings.values());
2524
+ if (ids.length === 0 || k <= 0) return [];
2525
+ if (ids.length <= k) {
2526
+ return ids.map((id, i) => ({
2527
+ centroid: vecs[i],
2528
+ entryIds: [id]
2529
+ }));
2530
+ }
2531
+ let centroids = vecs.slice(0, k).map((v) => new Float32Array(v));
2532
+ let clusters = [];
2533
+ for (let iter = 0; iter < maxIterations; iter++) {
2534
+ clusters = centroids.map((c) => ({ centroid: c, entryIds: [] }));
2535
+ for (let i = 0; i < ids.length; i++) {
2536
+ const vec = vecs[i];
2537
+ let bestSim = -1;
2538
+ let bestIdx = 0;
2539
+ for (let j = 0; j < k; j++) {
2540
+ const sim = cosineSimilarity(vec, centroids[j]);
2541
+ if (sim > bestSim) {
2542
+ bestSim = sim;
2543
+ bestIdx = j;
2544
+ }
2545
+ }
2546
+ clusters[bestIdx].entryIds.push(ids[i]);
2547
+ }
2548
+ const nextCentroids = clusters.map((cluster) => {
2549
+ if (cluster.entryIds.length === 0) return cluster.centroid;
2550
+ const first = embeddings.get(cluster.entryIds[0]);
2551
+ const sum = new Float32Array(first.length);
2552
+ for (const id of cluster.entryIds) {
2553
+ const v = embeddings.get(id);
2554
+ for (let i = 0; i < v.length; i++) {
2555
+ sum[i] += v[i];
2556
+ }
2557
+ }
2558
+ for (let i = 0; i < sum.length; i++) {
2559
+ sum[i] /= cluster.entryIds.length;
2560
+ }
2561
+ return sum;
2562
+ });
2563
+ let changed = false;
2564
+ for (let i = 0; i < k; i++) {
2565
+ if (cosineSimilarity(centroids[i], nextCentroids[i]) < 0.9999) {
2566
+ changed = true;
2567
+ break;
2568
+ }
2569
+ }
2570
+ centroids = nextCentroids;
2571
+ if (!changed) break;
2572
+ }
2573
+ return clusters;
2574
+ }
2575
+
2576
+ // src/cli.ts
2577
+ var USAGE = `ph \u2014 prompt history tracker
2578
+
2579
+ USAGE:
2580
+ ph Open interactive TUI browser (default)
2581
+ ph <tool> [tool-args...] Wrap a CLI tool, save prompt to history
2582
+ ph search [options] [query] Search saved prompts
2583
+ ph last [n] Show last N prompts (default 10)
2584
+ ph sessions [options] Group prompts into work sessions
2585
+ ph stats Show history statistics
2586
+ ph cluster [options] Cluster prompts by similarity
2587
+ ph analyze-reusability [options] Analyze prompt reusability
2588
+ ph star <id> Toggle star on a prompt
2589
+ ph export <id> [--format txt|json|md] Export a single prompt
2590
+ ph import gemini [--dry-run] [--analyze] [--filter] Import from Gemini CLI sessions
2591
+ ph import claude [--dry-run] [--analyze] [--filter] Import from Claude CLI sessions
2592
+ ph analyze [--limit n] [--force] [--prune] [--dry-run] Analyze prompts with LLM
2593
+ ph cleanup [--dry-run] [--min-length N] [--min-score N] Remove useless prompts
2594
+ ph cleanup-reusability [--dry-run] [--threshold 0.7] [--force] Cleanup based on reusability
2595
+ ph embed-all Generate embeddings for all prompts
2596
+ ph log --tool <name> --prompt <text> [--response <text>] Log a prompt+response directly
2597
+ ph config set <key> <value> Save config value
2598
+ ph browse Interactive TUI browser
2599
+
2600
+ WRAP FLAGS (placed before the tool name):
2601
+ --ph-tag <tag> Add a tag to the captured prompt
2602
+ --ph-role <role> Set the prompt role (debug, refactor, explain, review, architect, test, docs, generate, research)
2603
+ --ph-debug Write a debug log to ~/.ph_debug.log
2604
+
2605
+ SEARCH OPTIONS:
2606
+ -i, --interactive Open results in interactive TUI browser
2607
+ --tool <name> Filter by tool name (claude, gemini, \u2026)
2608
+ --project <name> Filter by project name
2609
+ --language <lang> Filter by language (go, typescript, python, \u2026)
2610
+ --role <role> Filter by role (debug, refactor, explain, \u2026)
2611
+ --tag <tag> Filter by tag
2612
+ --starred Show only starred prompts
2613
+ --min-quality <n> Filter by min quality (0-10)
2614
+ --min-relevance <n> Filter by min relevance (0-10)
2615
+ --top Show only top quality prompts (quality >= 8)
2616
+ --semantic Use semantic search (requires embeddings)
2617
+ --since YYYY-MM-DD
2618
+ --until YYYY-MM-DD
2619
+ --limit <n> Max results (default 50)
2620
+ --full Show full prompt without truncation
2621
+
2622
+ SESSIONS OPTIONS:
2623
+ --gap-hours <n> Hours gap to split sessions (default 2)
2624
+ --limit <n> Max sessions to show (default 20)
2625
+ --min-size <n> Minimum prompts per session (default 1)
2626
+ --no-cohesion Skip semantic cohesion computation
2627
+
2628
+ CLUSTER OPTIONS:
2629
+ -k <number> Number of clusters (default 5)
2630
+ --limit <n> Max prompts per cluster to show (default 3)
2631
+
2632
+ ANALYZE-REUSABILITY OPTIONS:
2633
+ --export-csv <file> Export report to CSV
2634
+ --threshold <n> Scoring threshold (default 0.7)
2635
+
2636
+ EXAMPLES:
2637
+ ph claude "explain goroutines"
2638
+ ph --ph-role debug --ph-tag auth claude "fix JWT expiration bug"
2639
+ ph search "goroutines"
2640
+ ph search --tool claude --role debug --since 2026-01-01 "error handling"
2641
+ ph search --semantic "how to handle errors gracefully"
2642
+ ph search -i "database"
2643
+ ph last 20
2644
+ ph sessions
2645
+ ph sessions --gap-hours 4 --min-size 3
2646
+ ph stats
2647
+ ph cluster -k 10
2648
+ ph export 5 --format md
2649
+ ph import claude --analyze --filter
2650
+ ph analyze --force --prune --min-score 3
2651
+ ph cleanup --dry-run
2652
+ ph embed-all
2653
+ ph analyze-reusability --export-csv report.csv
2654
+ ph cleanup-reusability --dry-run
2655
+ ph config set analyze-provider gemini
2656
+ ph config set background-analysis true
2657
+ ph config set ollama-model llama3.2:latest
2658
+ ph ollama-models List models available on the local Ollama instance
2659
+ `;
2660
+ function parseFlags(args) {
2661
+ const flags = {};
2662
+ const positional = [];
2663
+ let i = 0;
2664
+ while (i < args.length) {
2665
+ const a = args[i];
2666
+ if (a.startsWith("--")) {
2667
+ const key = a.slice(2);
2668
+ if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
2669
+ flags[key] = args[i + 1];
2670
+ i += 2;
2671
+ } else {
2672
+ flags[key] = true;
2673
+ i++;
2674
+ }
2675
+ } else {
2676
+ positional.push(a);
2677
+ i++;
2678
+ }
2679
+ }
2680
+ return { flags, positional };
2681
+ }
2682
+ function parseDate(s, label) {
2683
+ const d = new Date(s);
2684
+ if (isNaN(d.getTime())) {
2685
+ process.stderr.write(`ph: invalid ${label} date: ${s}
2686
+ `);
2687
+ process.exit(1);
2688
+ }
2689
+ return d;
2690
+ }
2691
+ async function cmdSearch(db, cfg, args) {
2692
+ const { flags, positional } = parseFlags(args);
2693
+ const query = positional.join(" ");
2694
+ if (flags["semantic"] && query) {
2695
+ await cmdSearchSemantic(db, cfg, query, Number(flags["limit"] ?? 50), Boolean(flags["full"]));
2696
+ return;
2697
+ }
2698
+ const opts = {
2699
+ query,
2700
+ tool: flags["tool"],
2701
+ project: flags["project"],
2702
+ language: flags["language"],
2703
+ role: flags["role"],
2704
+ tag: flags["tag"],
2705
+ starred: Boolean(flags["starred"]),
2706
+ minQuality: flags["top"] ? 8 : flags["min-quality"] ? Number(flags["min-quality"]) : void 0,
2707
+ minRelevance: flags["min-relevance"] ? Number(flags["min-relevance"]) : void 0,
2708
+ limit: Number(flags["limit"] ?? 50)
2709
+ };
2710
+ if (flags["since"]) opts.since = parseDate(flags["since"], "--since");
2711
+ if (flags["until"]) {
2712
+ const d = parseDate(flags["until"], "--until");
2713
+ d.setHours(23, 59, 59, 999);
2714
+ opts.until = d;
2715
+ }
2716
+ const results = db.search(opts);
2717
+ printResults(results, Boolean(flags["full"]));
2718
+ }
2719
+ async function cmdSearchSemantic(db, cfg, query, limit, showFull) {
2720
+ const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
2721
+ const model = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
2722
+ process.stdout.write(`Searching semantically for: "${query}" (model: ${model})...
2723
+ `);
2724
+ const [queryVec] = await getEmbeddings([query], ollamaUrl, model, 1);
2725
+ if (!queryVec) {
2726
+ process.stderr.write("ph: failed to get embedding for query\n");
2727
+ process.exit(1);
2728
+ }
2729
+ const embeddings = db.getAllEmbeddings();
2730
+ const scores = [];
2731
+ for (const [id, vec] of embeddings) {
2732
+ const sim = cosineSimilarity(queryVec, vec);
2733
+ if (sim > 0.3) scores.push({ id, sim });
2734
+ }
2735
+ scores.sort((a, b) => b.sim - a.sim);
2736
+ const topScores = scores.slice(0, limit);
2737
+ const results = topScores.map(({ id }) => db.getById(id)).filter((e) => e !== void 0);
2738
+ printResults(results, showFull);
2739
+ }
2740
+ async function cmdLast(db, args) {
2741
+ const n = parseInt(args[0] ?? "10", 10);
2742
+ const results = db.search({ limit: isNaN(n) ? 10 : n });
2743
+ printResults(results, false);
2744
+ }
2745
+ async function cmdStar(db, args) {
2746
+ const id = parseInt(args[0] ?? "", 10);
2747
+ if (isNaN(id)) {
2748
+ process.stderr.write("Usage: ph star <id>\n");
2749
+ process.exit(1);
2750
+ }
2751
+ const entry = db.getById(id);
2752
+ if (!entry) {
2753
+ process.stderr.write(`ph: prompt #${id} not found
2754
+ `);
2755
+ process.exit(1);
2756
+ }
2757
+ let meta = {};
2758
+ try {
2759
+ meta = JSON.parse(entry.metadata);
2760
+ } catch {
2761
+ }
2762
+ const wasStarred = Boolean(meta.starred);
2763
+ meta.starred = !wasStarred;
2764
+ db.updateMetadata(id, JSON.stringify(meta));
2765
+ if (meta.starred) {
2766
+ console.log(`Prompt #${id} starred \u2605`);
2767
+ } else {
2768
+ console.log(`Prompt #${id} unstarred`);
2769
+ }
2770
+ }
2771
+ async function cmdExport(db, args) {
2772
+ const { flags, positional } = parseFlags(args);
2773
+ const id = parseInt(positional[0] ?? "", 10);
2774
+ if (isNaN(id)) {
2775
+ process.stderr.write("Usage: ph export <id> [--format txt|json|md]\n");
2776
+ process.exit(1);
2777
+ }
2778
+ const entry = db.getById(id);
2779
+ if (!entry) {
2780
+ process.stderr.write(`ph: prompt #${id} not found
2781
+ `);
2782
+ process.exit(1);
2783
+ }
2784
+ const format = flags["format"] ?? "txt";
2785
+ switch (format) {
2786
+ case "txt":
2787
+ case "text":
2788
+ process.stdout.write(entry.prompt);
2789
+ break;
2790
+ case "json": {
2791
+ const obj = {
2792
+ id: entry.id,
2793
+ timestamp: entry.timestamp,
2794
+ tool: entry.tool,
2795
+ prompt: entry.prompt,
2796
+ metadata: JSON.parse(entry.metadata)
2797
+ };
2798
+ console.log(JSON.stringify(obj));
2799
+ break;
2800
+ }
2801
+ case "md":
2802
+ case "markdown": {
2803
+ let meta = {};
2804
+ try {
2805
+ meta = JSON.parse(entry.metadata);
2806
+ } catch {
2807
+ }
2808
+ let md = `# Prompt #${entry.id}
2809
+
2810
+ `;
2811
+ if (meta.starred) md += "\u2605 **Starred**\n\n";
2812
+ md += `- **Tool**: ${entry.tool}
2813
+ `;
2814
+ md += `- **Date**: ${entry.timestamp}
2815
+ `;
2816
+ if (meta.project) md += `- **Project**: ${meta.project}
2817
+ `;
2818
+ if (meta.language) md += `- **Language**: ${meta.language}
2819
+ `;
2820
+ if (meta.role) md += `- **Role**: ${meta.role}
2821
+ `;
2822
+ if (meta.tags && meta.tags.length > 0) md += `- **Tags**: ${meta.tags.join(", ")}
2823
+ `;
2824
+ md += `- **Dir**: ${entry.workdir}
2825
+
2826
+ ---
2827
+
2828
+ ## Prompt
2829
+
2830
+ ${entry.prompt}
2831
+ `;
2832
+ if (entry.response) {
2833
+ md += `
2834
+ ## Response
2835
+
2836
+ ${entry.response}
2837
+ `;
2838
+ }
2839
+ if (meta.git_context) {
2840
+ md += `
2841
+ ## Git Context
2842
+
2843
+ `;
2844
+ md += `**Branch**: ${meta.git_context.branch}
2845
+ `;
2846
+ if (meta.git_context.files.length > 0) {
2847
+ md += `**Modified files**: ${meta.git_context.files.join(", ")}
2848
+ `;
2849
+ }
2850
+ md += `
2851
+ \`\`\`diff
2852
+ ${meta.git_context.diff}
2853
+ \`\`\`
2854
+ `;
2855
+ }
2856
+ process.stdout.write(md);
2857
+ break;
2858
+ }
2859
+ default:
2860
+ process.stderr.write(`ph: unknown format "${format}" \u2014 use txt, json, or md
2861
+ `);
2862
+ process.exit(1);
2863
+ }
2864
+ }
2865
+ async function cmdImport(db, cfg, args) {
2866
+ if (args.length === 0) {
2867
+ process.stderr.write("Usage: ph import <source> [--dry-run] [--analyze]\n");
2868
+ process.exit(1);
2869
+ }
2870
+ const source = args[0];
2871
+ const { flags } = parseFlags(args.slice(1));
2872
+ const dryRun = Boolean(flags["dry-run"]);
2873
+ const analyze = Boolean(flags["analyze"]);
2874
+ const useFilter = Boolean(flags["filter"]);
2875
+ const homeDir = os6.homedir();
2876
+ if (dryRun) console.log("Dry run \u2014 nothing will be written to the database.");
2877
+ let analyzer;
2878
+ if (analyze) {
2879
+ analyzer = getProvider(cfg);
2880
+ if (!analyzer) {
2881
+ process.stderr.write("ph: no LLM provider configured. Run: ph config set analyze-provider ollama\n");
2882
+ process.exit(1);
2883
+ }
2884
+ console.log(`Analyzing with ${analyzer.name} during import...`);
2885
+ }
2886
+ let filter;
2887
+ const existingHashes = dryRun ? /* @__PURE__ */ new Map() : db.getAllPromptHashes();
2888
+ if (useFilter) {
2889
+ filter = new FilterPipeline({
2890
+ minLength: cfg.filterMinLength,
2891
+ minRelevance: analyze ? cfg.filterMinRelevance ?? 3 : 0,
2892
+ existingHashes
2893
+ });
2894
+ console.log(`Filtering enabled (min-length: ${cfg.filterMinLength ?? 15}${analyze ? `, min-relevance: ${cfg.filterMinRelevance ?? 3}` : ""}).`);
2895
+ } else {
2896
+ filter = new FilterPipeline({
2897
+ minLength: 0,
2898
+ minRelevance: 0,
2899
+ existingHashes
2900
+ });
2901
+ filter.checkRules = () => ({ keep: true });
2902
+ }
2903
+ const onProgress = (evaluated, imported, total, current) => {
2904
+ const text = current.length > 50 ? current.slice(0, 47) + "..." : current;
2905
+ process.stdout.write(`\r [${evaluated}/${total}] (imported: ${imported}) ${text.replace(/\n/g, " ")}`.padEnd(80));
2906
+ };
2907
+ let result;
2908
+ switch (source) {
2909
+ case "gemini":
2910
+ result = await importGeminiHistory(db, path6.join(homeDir, ".gemini"), dryRun, analyzer, onProgress, filter);
2911
+ break;
2912
+ case "claude":
2913
+ result = await importClaudeHistory(db, path6.join(homeDir, ".claude"), dryRun, analyzer, onProgress, filter);
2914
+ break;
2915
+ default:
2916
+ process.stderr.write(`ph: unknown import source "${source}". Supported: gemini, claude
2917
+ `);
2918
+ process.exit(1);
2919
+ }
2920
+ if (analyze) process.stdout.write("\n");
2921
+ console.log(`Files scanned: ${result.filesScanned}`);
2922
+ console.log(`Prompts found: ${result.promptsFound}`);
2923
+ console.log(`Prompts imported: ${result.promptsImported}`);
2924
+ if (result.filtered > 0) console.log(`Filtered out: ${result.filtered}`);
2925
+ if (analyze) console.log(`Analyzed: ${result.promptsImported} prompts tagged`);
2926
+ if (result.skipped > 0) console.log(`Skipped: ${result.skipped}`);
2927
+ if (result.errors.length > 0) {
2928
+ console.log(`
2929
+ Warnings (${result.errors.length}):`);
2930
+ for (const e of result.errors) console.log(` - ${e}`);
2931
+ }
2932
+ if (!dryRun && result.promptsImported > 0) {
2933
+ console.log(`
2934
+ Done! Run \`ph last ${Math.min(result.promptsImported, 20)}\` to review.`);
2935
+ }
2936
+ }
2937
+ async function cmdAnalyze(db, cfg, args) {
2938
+ const { flags } = parseFlags(args);
2939
+ const force = Boolean(flags["force"]);
2940
+ const dryRun = Boolean(flags["dry-run"]);
2941
+ const prune = Boolean(flags["prune"]);
2942
+ const limitFlag = flags["limit"] ? Number(flags["limit"]) : void 0;
2943
+ const pruneBelow = prune ? flags["min-score"] ? Number(flags["min-score"]) : cfg.filterMinRelevance ?? 3 : 0;
2944
+ const provider = getProvider(cfg);
2945
+ if (!provider) {
2946
+ process.stderr.write("ph: no LLM provider configured. Set analyze-provider in config.\n");
2947
+ process.exit(1);
2948
+ }
2949
+ const allRows = db.search({ limit: 1e4 });
2950
+ const limit = limitFlag ?? (force ? void 0 : 50);
2951
+ const entries = limit !== void 0 ? allRows.slice(0, limit) : allRows;
2952
+ let modeStr = force ? " (--force: reanalyze all)" : " (untagged only)";
2953
+ if (prune) modeStr += dryRun ? ` + prune<${pruneBelow} [dry-run]` : ` + prune<${pruneBelow}`;
2954
+ console.log(`Analyzing with ${provider.name}${modeStr}...`);
2955
+ const stats = await analyzeAll(entries, provider, db, {
2956
+ force,
2957
+ pruneBelow,
2958
+ dryRun,
2959
+ onProgress: (done, total, entry, res, err) => {
2960
+ const truncated = entry.prompt.replace(/\n/g, " ");
2961
+ const short = truncated.length > 40 ? truncated.slice(0, 37) + "..." : truncated;
2962
+ if (err) {
2963
+ console.log(` [${done}/${total}] #${entry.id} \u2192 error: ${err}`);
2964
+ } else if (res) {
2965
+ const role = res.role || "-";
2966
+ const tags = (res.tags ?? []).join(",") || "-";
2967
+ const relStr = res.relevance !== void 0 ? ` rel:${res.relevance}` : "";
2968
+ console.log(` [${done}/${total}] #${entry.id} \u2192 role:${role} tags:${tags}${relStr} | ${short}`);
2969
+ }
2970
+ },
2971
+ onPrune: (entry, relevance) => {
2972
+ const short = entry.prompt.replace(/\n/g, " ").slice(0, 50);
2973
+ console.log(` [prune${dryRun ? " DRY" : ""}] #${entry.id} rel:${relevance} | ${short}`);
2974
+ }
2975
+ });
2976
+ let summary = `
2977
+ Done. Updated: ${stats.updated}, Skipped: ${stats.skipped}, Failed: ${stats.failed}`;
2978
+ if (prune) summary += `, Pruned: ${stats.pruned}${dryRun ? " (dry-run, not deleted)" : ""}`;
2979
+ console.log(summary);
2980
+ }
2981
+ async function cmdEmbedAll(db, cfg) {
2982
+ const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
2983
+ const model = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
2984
+ const toEmbed = db.getPromptsWithoutEmbeddings();
2985
+ if (toEmbed.length === 0) {
2986
+ console.log("No new prompts to embed.");
2987
+ return;
2988
+ }
2989
+ const batchSize = 20;
2990
+ console.log(
2991
+ `Generating embeddings for ${toEmbed.length} prompts via Ollama (${model}), batches of ${batchSize}...`
2992
+ );
2993
+ for (let i = 0; i < toEmbed.length; i += batchSize) {
2994
+ const batch = toEmbed.slice(i, i + batchSize);
2995
+ const texts = batch.map((e) => e.response ? `${e.prompt}
2996
+
2997
+ ${e.response}` : e.prompt);
2998
+ process.stdout.write(`\r\u2192 ${i + batch.length}/${toEmbed.length}...`);
2999
+ try {
3000
+ const vecs = await getEmbeddings(texts, ollamaUrl, model, batchSize);
3001
+ for (let j = 0; j < vecs.length; j++) {
3002
+ try {
3003
+ db.saveEmbedding(batch[j].id, vecs[j]);
3004
+ } catch (e) {
3005
+ console.log(`
3006
+ ! DB error for #${batch[j].id}: ${e.message}`);
3007
+ }
3008
+ }
3009
+ } catch (e) {
3010
+ console.log(`
3011
+ ! Batch error: ${e.message}`);
3012
+ }
3013
+ }
3014
+ console.log("\nDone!");
3015
+ }
3016
+ async function cmdLog(dbPath, cfg, args) {
3017
+ let tool;
3018
+ let prompt;
3019
+ let response;
3020
+ let workdir;
3021
+ const { flags } = parseFlags(args);
3022
+ if (flags["prompt"]) {
3023
+ tool = flags["tool"] || "unknown";
3024
+ prompt = flags["prompt"];
3025
+ response = flags["response"] || "";
3026
+ workdir = flags["workdir"] || process.cwd();
3027
+ } else {
3028
+ const chunks = [];
3029
+ for await (const chunk of process.stdin) chunks.push(chunk);
3030
+ const raw = Buffer.concat(chunks).toString("utf-8").trim();
3031
+ if (!raw) {
3032
+ process.stderr.write("ph log: no input (use --prompt or pipe JSON)\n");
3033
+ process.exit(1);
3034
+ }
3035
+ let parsed;
3036
+ try {
3037
+ parsed = JSON.parse(raw);
3038
+ } catch {
3039
+ process.stderr.write("ph log: invalid JSON on stdin\n");
3040
+ process.exit(1);
3041
+ }
3042
+ tool = parsed["tool"] || flags["tool"] || "unknown";
3043
+ prompt = parsed["prompt"] || "";
3044
+ response = parsed["response"] || "";
3045
+ workdir = parsed["workdir"] || flags["workdir"] || process.cwd();
3046
+ if (!prompt) {
3047
+ process.stderr.write('ph log: missing "prompt" field\n');
3048
+ process.exit(1);
3049
+ }
3050
+ }
3051
+ const { rootDir, projectName } = detectProject(workdir);
3052
+ const language = detectLanguage(rootDir);
3053
+ const metaObj = {};
3054
+ if (projectName) metaObj.project = projectName;
3055
+ if (language) metaObj.language = language;
3056
+ const title = extractTopic(prompt);
3057
+ if (title) metaObj.title = title;
3058
+ const db = new PhDB(dbPath);
3059
+ const id = db.insert({
3060
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
3061
+ tool,
3062
+ prompt,
3063
+ response,
3064
+ args: "",
3065
+ workdir,
3066
+ hostname: os6.hostname(),
3067
+ exit_code: 0,
3068
+ metadata: JSON.stringify(metaObj)
3069
+ });
3070
+ db.close();
3071
+ if (cfg.backgroundAnalysis) {
3072
+ spawnBackgroundAnalysis(id, dbPath);
3073
+ }
3074
+ }
3075
+ function cmdConfig(args) {
3076
+ if (args.length < 3 || args[0] !== "set") {
3077
+ console.log("Usage: ph config set <key> <value>");
3078
+ console.log("Keys: gemini-api-key, db-path, analyze-provider, ollama-url, ollama-model,");
3079
+ console.log(" ollama-embed-model, filter-min-length, filter-min-relevance, background-analysis");
3080
+ process.exit(1);
3081
+ }
3082
+ const cfg = load();
3083
+ const key = args[1];
3084
+ const val = args[2];
3085
+ switch (key) {
3086
+ case "gemini-api-key":
3087
+ cfg.geminiApiKey = val;
3088
+ break;
3089
+ case "db-path":
3090
+ cfg.dbPath = val;
3091
+ break;
3092
+ case "analyze-provider":
3093
+ if (val !== "ollama" && val !== "gemini") {
3094
+ process.stderr.write('ph: analyze-provider must be "ollama" or "gemini"\n');
3095
+ process.exit(1);
3096
+ }
3097
+ cfg.analyzeProvider = val;
3098
+ break;
3099
+ case "ollama-url":
3100
+ cfg.ollamaUrl = val;
3101
+ break;
3102
+ case "ollama-model":
3103
+ cfg.ollamaModel = val;
3104
+ break;
3105
+ case "ollama-embed-model":
3106
+ cfg.ollamaEmbedModel = val;
3107
+ break;
3108
+ case "filter-min-length": {
3109
+ const n = Number(val);
3110
+ if (isNaN(n) || n < 0) {
3111
+ process.stderr.write("ph: filter-min-length must be a non-negative integer\n");
3112
+ process.exit(1);
3113
+ }
3114
+ cfg.filterMinLength = n;
3115
+ break;
3116
+ }
3117
+ case "filter-min-relevance": {
3118
+ const n = Number(val);
3119
+ if (isNaN(n) || n < 0 || n > 10) {
3120
+ process.stderr.write("ph: filter-min-relevance must be 0-10\n");
3121
+ process.exit(1);
3122
+ }
3123
+ cfg.filterMinRelevance = n;
3124
+ break;
3125
+ }
3126
+ case "background-analysis":
3127
+ cfg.backgroundAnalysis = val === "true" || val === "1";
3128
+ break;
3129
+ default:
3130
+ process.stderr.write(`ph: unknown config key "${key}"
3131
+ `);
3132
+ process.exit(1);
3133
+ }
3134
+ save(cfg);
3135
+ console.log(`Config "${key}" updated successfully.`);
3136
+ }
3137
+ async function cmdBackgroundAnalyze(dbPath, args) {
3138
+ const id = parseInt(args[0], 10);
3139
+ if (isNaN(id)) return;
3140
+ let finalDbPath = dbPath;
3141
+ const dbIdx = args.indexOf("--db");
3142
+ if (dbIdx !== -1 && dbIdx + 1 < args.length) {
3143
+ finalDbPath = args[dbIdx + 1];
3144
+ }
3145
+ const cfg = load();
3146
+ const provider = getProvider(cfg);
3147
+ if (!provider) return;
3148
+ const db = new PhDB(finalDbPath);
3149
+ try {
3150
+ const entry = db.getById(id);
3151
+ if (!entry) return;
3152
+ const result = await analyzePrompt(entry.prompt, provider);
3153
+ if (Object.keys(result).length === 0) return;
3154
+ let existing = {};
3155
+ try {
3156
+ existing = JSON.parse(entry.metadata);
3157
+ } catch {
3158
+ }
3159
+ const merged = mergeMetadata(existing, result, false);
3160
+ db.updateMetadata(id, JSON.stringify(merged));
3161
+ } catch (_err) {
3162
+ } finally {
3163
+ db.close();
3164
+ }
3165
+ }
3166
+ async function cmdCleanup(db, cfg, args) {
3167
+ const { flags } = parseFlags(args);
3168
+ const dryRun = Boolean(flags["dry-run"]);
3169
+ const minLength = flags["min-length"] ? Number(flags["min-length"]) : cfg.filterMinLength ?? 15;
3170
+ const minScore = flags["min-score"] ? Number(flags["min-score"]) : cfg.filterMinRelevance ?? 3;
3171
+ const days = flags["days"] ? Number(flags["days"]) : void 0;
3172
+ if (days !== void 0) {
3173
+ if (dryRun) {
3174
+ console.log(`(dry-run) Would delete prompts older than ${days} days.`);
3175
+ } else {
3176
+ const deleted2 = db.deleteOlderThan(days);
3177
+ console.log(`Deleted ${deleted2} prompts older than ${days} days.`);
3178
+ }
3179
+ }
3180
+ const allEntries = db.search({ limit: 1e5 });
3181
+ console.log(`Scanning ${allEntries.length} prompts for rule-based cleanup (min-length: ${minLength}, min-score: ${minScore})...`);
3182
+ const toDelete = [];
3183
+ const existingHashes = /* @__PURE__ */ new Map();
3184
+ const filter = new FilterPipeline({ minLength, minRelevance: 0, existingHashes });
3185
+ for (const entry of allEntries) {
3186
+ const ruleResult = filter.checkRules(entry.prompt);
3187
+ if (!ruleResult.keep) {
3188
+ toDelete.push({ id: entry.id, prompt: entry.prompt, reason: `${ruleResult.reason}: ${ruleResult.details ?? ""}` });
3189
+ continue;
3190
+ }
3191
+ const hash = FilterPipeline.hashPrompt(entry.prompt);
3192
+ if (existingHashes.has(hash)) {
3193
+ const dupId = existingHashes.get(hash);
3194
+ toDelete.push({ id: entry.id, prompt: entry.prompt, reason: `exact_duplicate of #${dupId}` });
3195
+ continue;
3196
+ }
3197
+ existingHashes.set(hash, entry.id);
3198
+ if (minScore > 0) {
3199
+ let meta = {};
3200
+ try {
3201
+ meta = JSON.parse(entry.metadata);
3202
+ } catch {
3203
+ }
3204
+ if (meta.relevance !== void 0 && meta.relevance < minScore) {
3205
+ toDelete.push({ id: entry.id, prompt: entry.prompt, reason: `low_relevance: ${meta.relevance} < ${minScore}` });
3206
+ }
3207
+ }
3208
+ }
3209
+ if (toDelete.length === 0) {
3210
+ if (days === void 0) console.log("Nothing else to clean up.");
3211
+ return;
3212
+ }
3213
+ console.log(`
3214
+ Candidates for rule-based deletion: ${toDelete.length}`);
3215
+ for (const item of toDelete.slice(0, 30)) {
3216
+ const short = item.prompt.replace(/\n/g, " ").slice(0, 60);
3217
+ console.log(` #${String(item.id).padEnd(5)} [${item.reason}] "${short}"`);
3218
+ }
3219
+ if (toDelete.length > 30) {
3220
+ console.log(` ... and ${toDelete.length - 30} more`);
3221
+ }
3222
+ if (dryRun) {
3223
+ console.log(`
3224
+ (dry-run) Would delete ${toDelete.length} more prompts. Run without --dry-run to apply.`);
3225
+ return;
3226
+ }
3227
+ const ids = toDelete.map((e) => e.id);
3228
+ const deleted = db.deleteByIds(ids);
3229
+ console.log(`
3230
+ Deleted ${deleted} prompts.`);
3231
+ }
3232
+ async function cmdSessions(db, args) {
3233
+ const { flags } = parseFlags(args);
3234
+ const gapHours = Number(flags["gap-hours"] ?? 2);
3235
+ const limit = Number(flags["limit"] ?? 20);
3236
+ const minSize = Number(flags["min-size"] ?? 1);
3237
+ const noCohesion = Boolean(flags["no-cohesion"]);
3238
+ const C2 = {
3239
+ reset: "\x1B[0m",
3240
+ cyan: "\x1B[36m",
3241
+ yellow: "\x1B[33m",
3242
+ green: "\x1B[32m",
3243
+ gray: "\x1B[90m",
3244
+ red: "\x1B[31m",
3245
+ blue: "\x1B[34m",
3246
+ magenta: "\x1B[35m"
3247
+ };
3248
+ function fmtDate(ts) {
3249
+ const d = new Date(ts);
3250
+ const yr = d.getFullYear();
3251
+ const mo = String(d.getMonth() + 1).padStart(2, "0");
3252
+ const dy = String(d.getDate()).padStart(2, "0");
3253
+ const hh = String(d.getHours()).padStart(2, "0");
3254
+ const mm = String(d.getMinutes()).padStart(2, "0");
3255
+ return `${yr}-${mo}-${dy} ${hh}:${mm}`;
3256
+ }
3257
+ function fmtTime(ts) {
3258
+ const d = new Date(ts);
3259
+ const hh = String(d.getHours()).padStart(2, "0");
3260
+ const mm = String(d.getMinutes()).padStart(2, "0");
3261
+ return `${hh}:${mm}`;
3262
+ }
3263
+ const allEntries = db.search({ limit: 1e5 });
3264
+ const entriesAsc = [...allEntries].reverse();
3265
+ const sessions = groupIntoSessions(entriesAsc, gapHours);
3266
+ let embeddings = null;
3267
+ if (!noCohesion) {
3268
+ embeddings = db.getAllEmbeddings();
3269
+ }
3270
+ for (const session of sessions) {
3271
+ if (embeddings) {
3272
+ session.cohesion = computeSessionCohesion(session, embeddings);
3273
+ }
3274
+ }
3275
+ const filtered = sessions.filter((s) => s.entries.length >= minSize);
3276
+ const visible = filtered.slice(-limit);
3277
+ if (visible.length === 0) {
3278
+ process.stdout.write("No sessions found.\n");
3279
+ return;
3280
+ }
3281
+ for (const session of visible) {
3282
+ const projectCounts = /* @__PURE__ */ new Map();
3283
+ const languageCounts = /* @__PURE__ */ new Map();
3284
+ for (const entry of session.entries) {
3285
+ let meta = {};
3286
+ try {
3287
+ meta = JSON.parse(entry.metadata);
3288
+ } catch {
3289
+ }
3290
+ if (meta.project) projectCounts.set(meta.project, (projectCounts.get(meta.project) ?? 0) + 1);
3291
+ if (meta.language) languageCounts.set(meta.language, (languageCounts.get(meta.language) ?? 0) + 1);
3292
+ }
3293
+ const total = session.entries.length;
3294
+ let dominantProject = null;
3295
+ let dominantLanguage = null;
3296
+ for (const [proj, count] of projectCounts) {
3297
+ if (count / total > 0.5) {
3298
+ dominantProject = proj;
3299
+ break;
3300
+ }
3301
+ }
3302
+ for (const [lang, count] of languageCounts) {
3303
+ if (count / total > 0.5) {
3304
+ dominantLanguage = lang;
3305
+ break;
3306
+ }
3307
+ }
3308
+ const startStr = fmtDate(session.startTime.toISOString());
3309
+ const endStr = fmtTime(session.endTime.toISOString());
3310
+ const promptWord = session.entries.length === 1 ? "prompt" : "prompts";
3311
+ let header = `${C2.cyan}Session ${session.index}${C2.reset}`;
3312
+ header += ` ${C2.gray}\xB7${C2.reset} ${startStr} \u2192 ${endStr}`;
3313
+ header += ` ${C2.gray}\xB7${C2.reset} ${C2.yellow}${session.entries.length} ${promptWord}${C2.reset}`;
3314
+ if (dominantProject && dominantLanguage) {
3315
+ header += ` ${C2.gray}\xB7 [${dominantProject}:${dominantLanguage}]${C2.reset}`;
3316
+ } else if (dominantProject) {
3317
+ header += ` ${C2.gray}\xB7 [${dominantProject}]${C2.reset}`;
3318
+ } else if (dominantLanguage) {
3319
+ header += ` ${C2.gray}\xB7 [${dominantLanguage}]${C2.reset}`;
3320
+ }
3321
+ if (session.cohesion !== null) {
3322
+ header += ` ${C2.gray}\xB7 cohesion: ${session.cohesion.toFixed(2)}${C2.reset}`;
3323
+ }
3324
+ process.stdout.write(header + "\n");
3325
+ for (const entry of session.entries) {
3326
+ let meta = {};
3327
+ try {
3328
+ meta = JSON.parse(entry.metadata);
3329
+ } catch {
3330
+ }
3331
+ const idStr = `${C2.cyan}#${entry.id}${C2.reset}`;
3332
+ const toolStr = `${C2.yellow}${entry.tool.padEnd(8)}${C2.reset}`;
3333
+ const timeStr = `${C2.gray}${fmtTime(entry.timestamp)}${C2.reset}`;
3334
+ let rolePart = "";
3335
+ if (meta.role) rolePart = ` ${C2.magenta}{${meta.role}}${C2.reset}`;
3336
+ let tagsPart = "";
3337
+ if (meta.tags && meta.tags.length > 0) tagsPart = ` ${C2.cyan}(${meta.tags.join(", ")})${C2.reset}`;
3338
+ const preview = entry.prompt.replace(/\n/g, " ").slice(0, 80);
3339
+ process.stdout.write(` ${idStr} ${toolStr} ${timeStr}${rolePart}${tagsPart} ${preview}
3340
+ `);
3341
+ }
3342
+ process.stdout.write("\n");
3343
+ }
3344
+ }
3345
+ async function cmdStats(db) {
3346
+ const stats = getStats(db);
3347
+ const C2 = {
3348
+ reset: "\x1B[0m",
3349
+ cyan: "\x1B[36m",
3350
+ yellow: "\x1B[33m",
3351
+ green: "\x1B[32m",
3352
+ gray: "\x1B[90m",
3353
+ magenta: "\x1B[35m"
3354
+ };
3355
+ console.log(`${C2.cyan}ph \u2014 Prompt History Statistics${C2.reset}
3356
+ `);
3357
+ console.log(` Total prompts: ${C2.yellow}${stats.total}${C2.reset}`);
3358
+ console.log(` Analyzed: ${C2.yellow}${stats.analyzed}${C2.reset} (${Math.round(stats.analyzed / (stats.total || 1) * 100)}%)`);
3359
+ console.log(` Starred: ${C2.yellow}${stats.starred}${C2.reset}`);
3360
+ if (stats.avgRelevance > 0 || stats.avgQuality > 0) {
3361
+ console.log(`
3362
+ ${C2.cyan}Global Scores:${C2.reset}`);
3363
+ console.log(` - Avg Relevance: ${C2.yellow}${stats.avgRelevance.toFixed(1)}/10${C2.reset}`);
3364
+ console.log(` - Avg Quality: ${C2.yellow}${stats.avgQuality.toFixed(1)}/10${C2.reset}`);
3365
+ }
3366
+ console.log(`
3367
+ ${C2.cyan}By Tool:${C2.reset}`);
3368
+ Object.entries(stats.byTool).sort((a, b) => b[1] - a[1]).forEach(([tool, count]) => {
3369
+ console.log(` - ${tool.padEnd(12)}: ${C2.yellow}${count}${C2.reset}`);
3370
+ });
3371
+ if (Object.keys(stats.byProject).length > 0) {
3372
+ console.log(`
3373
+ ${C2.cyan}Top Projects:${C2.reset}`);
3374
+ Object.entries(stats.byProject).sort((a, b) => b[1] - a[1]).slice(0, 10).forEach(([proj, count]) => {
3375
+ console.log(` - ${proj.padEnd(20)}: ${C2.yellow}${count}${C2.reset}`);
3376
+ });
3377
+ }
3378
+ if (Object.keys(stats.byLanguage).length > 0) {
3379
+ console.log(`
3380
+ ${C2.cyan}Top Languages:${C2.reset}`);
3381
+ Object.entries(stats.byLanguage).sort((a, b) => b[1] - a[1]).slice(0, 10).forEach(([lang, count]) => {
3382
+ console.log(` - ${lang.padEnd(12)}: ${C2.yellow}${count}${C2.reset}`);
3383
+ });
3384
+ }
3385
+ if (Object.keys(stats.byRole).length > 0) {
3386
+ console.log(`
3387
+ ${C2.cyan}By Role:${C2.reset}`);
3388
+ Object.entries(stats.byRole).sort((a, b) => b[1] - a[1]).forEach(([role, count]) => {
3389
+ console.log(` - ${role.padEnd(12)}: ${C2.yellow}${count}${C2.reset}`);
3390
+ });
3391
+ }
3392
+ console.log();
3393
+ }
3394
+ async function cmdCluster(db, args) {
3395
+ const { flags } = parseFlags(args);
3396
+ const k = Number(flags["k"] ?? 5);
3397
+ const limitPerCluster = Number(flags["limit"] ?? 3);
3398
+ const embeddings = db.getAllEmbeddings();
3399
+ if (embeddings.size === 0) {
3400
+ console.error("ph: no embeddings found. Run `ph embed-all` first.");
3401
+ return;
3402
+ }
3403
+ console.log(`Clustering ${embeddings.size} prompts into ${k} groups...`);
3404
+ const clusters = kmeans(embeddings, k);
3405
+ const C2 = {
3406
+ reset: "\x1B[0m",
3407
+ cyan: "\x1B[36m",
3408
+ yellow: "\x1B[33m",
3409
+ gray: "\x1B[90m"
3410
+ };
3411
+ clusters.forEach((cluster, i) => {
3412
+ if (cluster.entryIds.length === 0) return;
3413
+ console.log(`
3414
+ ${C2.cyan}Cluster #${i + 1}${C2.reset} (${C2.yellow}${cluster.entryIds.length} prompts${C2.reset})`);
3415
+ const examples = cluster.entryIds.slice(0, limitPerCluster);
3416
+ examples.forEach((id) => {
3417
+ const entry = db.getById(id);
3418
+ if (entry) {
3419
+ const preview = entry.prompt.replace(/\n/g, " ").slice(0, 80);
3420
+ console.log(` ${C2.gray}#${entry.id}${C2.reset} ${preview}`);
3421
+ }
3422
+ });
3423
+ if (cluster.entryIds.length > limitPerCluster) {
3424
+ console.log(` ${C2.gray}... and ${cluster.entryIds.length - limitPerCluster} more${C2.reset}`);
3425
+ }
3426
+ });
3427
+ console.log();
3428
+ }
3429
+ async function cmdAnalyzeReusability(db, args) {
3430
+ const { flags } = parseFlags(args);
3431
+ const threshold = flags["threshold"] ? Number(flags["threshold"]) : 0.7;
3432
+ const exportCsv = flags["export-csv"];
3433
+ const entries = db.getAllPrompts();
3434
+ const embeddings = db.getAllEmbeddings();
3435
+ if (embeddings.size === 0) {
3436
+ console.warn("ph: no embeddings found. Uniqueness score will be 1.0 for all. Run `ph embed-all` first.");
3437
+ }
3438
+ console.log(`Scanning ${entries.length} prompts...`);
3439
+ const analyzer = new ReusabilityAnalyzer(threshold);
3440
+ const report = analyzer.analyze(entries, embeddings);
3441
+ const C2 = {
3442
+ reset: "\x1B[0m",
3443
+ cyan: "\x1B[36m",
3444
+ yellow: "\x1B[33m",
3445
+ green: "\x1B[32m",
3446
+ red: "\x1B[31m",
3447
+ gray: "\x1B[90m",
3448
+ bold: "\x1B[1m"
3449
+ };
3450
+ console.log(`
3451
+ ${C2.bold}SUMMARY:${C2.reset}`);
3452
+ console.log(` - ${C2.green}KEEP${C2.reset} (score 7+): ${C2.yellow}${report.keep.length}${C2.reset} prompts (${(report.keep.length / report.total * 100).toFixed(1)}%)`);
3453
+ console.log(` - ${C2.cyan}REVIEW${C2.reset} (score 4-7): ${C2.yellow}${report.review.length}${C2.reset} prompts (${(report.review.length / report.total * 100).toFixed(1)}%)`);
3454
+ console.log(` - ${C2.red}REMOVE${C2.reset} (score <4): ${C2.yellow}${report.remove.length}${C2.reset} prompts (${(report.remove.length / report.total * 100).toFixed(1)}%)`);
3455
+ console.log(`
3456
+ ${C2.bold}BY ROLE:${C2.reset}`);
3457
+ const roles = Object.keys(report.byRole).sort();
3458
+ for (const role of roles) {
3459
+ const r = report.byRole[role];
3460
+ const roleStr = role.padEnd(12);
3461
+ console.log(` ${C2.gray}${roleStr}${C2.reset} | KEEP: ${C2.green}${r.keep.toString().padEnd(4)}${C2.reset} REVIEW: ${C2.cyan}${r.review.toString().padEnd(4)}${C2.reset} REMOVE: ${C2.red}${r.remove.toString().padEnd(4)}${C2.reset}`);
3462
+ }
3463
+ console.log(`
3464
+ ${C2.bold}DUPLICATES FOUND:${C2.reset}`);
3465
+ console.log(` - Exact semantic (sim > 0.95): ${C2.yellow}${report.duplicates.exact}${C2.reset} prompts`);
3466
+ console.log(` - Variant clusters (sim 0.85-0.95): ${C2.yellow}${report.duplicates.variants}${C2.reset} prompts`);
3467
+ console.log(`
3468
+ ${C2.bold}TOP 10 KEEP (unique + high score):${C2.reset}`);
3469
+ report.keep.slice(0, 10).forEach((s) => {
3470
+ const scoreStr = s.score.toFixed(1).padStart(4);
3471
+ console.log(` [${C2.green}${scoreStr}${C2.reset}] ${s.prompt.replace(/\n/g, " ").slice(0, 80)}...`);
3472
+ });
3473
+ console.log(`
3474
+ ${C2.bold}TOP 10 REMOVE (low score + duplicate):${C2.reset}`);
3475
+ report.remove.slice(0, 10).forEach((s) => {
3476
+ const scoreStr = s.score.toFixed(1).padStart(4);
3477
+ console.log(` [${C2.red}${scoreStr}${C2.reset}] ${s.prompt.replace(/\n/g, " ").slice(0, 80)}...`);
3478
+ });
3479
+ if (exportCsv) {
3480
+ fs7.writeFileSync(exportCsv, analyzer.toCSV(report));
3481
+ console.log(`
3482
+ ${C2.green}\u2705 Report exported to ${exportCsv}${C2.reset}`);
3483
+ }
3484
+ console.log();
3485
+ }
3486
+ async function cmdCleanupReusability(db, cfg, args) {
3487
+ const { flags } = parseFlags(args);
3488
+ const threshold = flags["threshold"] ? parseFloat(flags["threshold"]) : 0.7;
3489
+ const dryRun = Boolean(flags["dry-run"]);
3490
+ const force = Boolean(flags["force"]);
3491
+ const entries = db.getAllPrompts();
3492
+ const embeddings = db.getAllEmbeddings();
3493
+ const analyzer = new ReusabilityAnalyzer(threshold);
3494
+ const candidates = analyzer.getRemovalCandidates(entries, embeddings, threshold);
3495
+ if (candidates.length === 0) {
3496
+ console.log("ph: no prompts match removal criteria");
3497
+ return;
3498
+ }
3499
+ const totalSize = candidates.reduce((sum, c) => sum + c.promptSize + c.responseSize, 0);
3500
+ const totalEmbeddingSize = candidates.filter((c) => c.hasEmbedding).length * 768 * 4;
3501
+ const totalToFree = totalSize + totalEmbeddingSize;
3502
+ const C2 = {
3503
+ reset: "\x1B[0m",
3504
+ cyan: "\x1B[36m",
3505
+ yellow: "\x1B[33m",
3506
+ red: "\x1B[31m",
3507
+ gray: "\x1B[90m",
3508
+ bold: "\x1B[1m"
3509
+ };
3510
+ const removeThreshold = Math.max(0, threshold - 0.3) * 10;
3511
+ const dbCount = entries.length;
3512
+ console.log(`
3513
+ Would delete ${C2.red}${candidates.length}${C2.reset} prompts (${(candidates.length / dbCount * 100).toFixed(1)}% of database):`);
3514
+ console.log(` - By score < ${removeThreshold.toFixed(1)}: ${candidates.length} prompts`);
3515
+ const byRole = {};
3516
+ for (const c of candidates) {
3517
+ byRole[c.role] = (byRole[c.role] ?? 0) + 1;
3518
+ }
3519
+ const roleSummary = Object.entries(byRole).sort((a, b) => b[1] - a[1]).map(([role, count]) => `${role} (${count})`).join(", ");
3520
+ console.log(` - By role (mostly): ${roleSummary}`);
3521
+ console.log(`
3522
+ Storage impact:`);
3523
+ console.log(` - Prompt text: ~${(candidates.reduce((sum, c) => sum + c.promptSize, 0) / 1024 / 1024).toFixed(2)} MB`);
3524
+ console.log(` - Response text: ~${(candidates.reduce((sum, c) => sum + c.responseSize, 0) / 1024 / 1024).toFixed(2)} MB`);
3525
+ console.log(` - Embeddings: ~${(totalEmbeddingSize / 1024 / 1024).toFixed(2)} MB`);
3526
+ console.log(` Total to free: ~${(totalToFree / 1024 / 1024).toFixed(2)} MB`);
3527
+ if (candidates.length > 0) {
3528
+ console.log(`
3529
+ ${C2.bold}Example candidates (top 20):${C2.reset}`);
3530
+ candidates.slice(0, 20).forEach((c) => {
3531
+ const entry = entries.find((e) => e.id === c.id);
3532
+ if (entry) {
3533
+ const preview = entry.prompt.replace(/\n/g, " ").slice(0, 80);
3534
+ console.log(` [${C2.red}${c.score.toFixed(1)}${C2.reset}] #${entry.id} ${preview}...`);
3535
+ }
3536
+ });
3537
+ }
3538
+ if (dryRun) {
3539
+ console.log(`
3540
+ Run with ${C2.bold}--force${C2.reset} or without ${C2.bold}--dry-run${C2.reset} to proceed.`);
3541
+ return;
3542
+ }
3543
+ if (!force) {
3544
+ process.stdout.write(`
3545
+ Continue and delete ${candidates.length} prompts? (y/N) `);
3546
+ const buffer = Buffer.alloc(16);
3547
+ const bytesRead = fs7.readSync(0, buffer, 0, 16);
3548
+ const answer = buffer.toString("utf8", 0, bytesRead).trim().toLowerCase();
3549
+ if (answer !== "y" && answer !== "yes") {
3550
+ console.log("Aborted.");
3551
+ return;
3552
+ }
3553
+ }
3554
+ let deleted = 0;
3555
+ for (const candidate of candidates) {
3556
+ db.delete(candidate.id);
3557
+ deleted++;
3558
+ }
3559
+ console.log(`
3560
+ Deleted ${deleted} prompts, freed ~${(totalToFree / 1024 / 1024).toFixed(1)} MB`);
3561
+ db.vacuum();
3562
+ console.log("Database vacuumed.");
3563
+ }
3564
+ async function cmdWrap(dbPath, tool, args, cfg) {
3565
+ let debugLog;
3566
+ let role;
3567
+ const tags = [];
3568
+ const cleanArgs = [];
3569
+ for (let i = 0; i < args.length; i++) {
3570
+ if (args[i] === "--ph-debug") {
3571
+ debugLog = path6.join(os6.homedir(), ".ph_debug.log");
3572
+ process.stderr.write(`ph: debug log \u2192 ${debugLog}
3573
+ `);
3574
+ } else if (args[i] === "--ph-tag" && i + 1 < args.length) {
3575
+ tags.push(args[i + 1]);
3576
+ i++;
3577
+ } else if (args[i].startsWith("--ph-tag=")) {
3578
+ tags.push(args[i].slice("--ph-tag=".length));
3579
+ } else if (args[i] === "--ph-role" && i + 1 < args.length) {
3580
+ role = args[i + 1];
3581
+ i++;
3582
+ } else if (args[i].startsWith("--ph-role=")) {
3583
+ role = args[i].slice("--ph-role=".length);
3584
+ } else {
3585
+ cleanArgs.push(args[i]);
3586
+ }
3587
+ }
3588
+ let realBin;
3589
+ try {
3590
+ realBin = resolveRealBinary(tool);
3591
+ } catch (e) {
3592
+ process.stderr.write(`ph: cannot find "${tool}": ${e.message}
3593
+ `);
3594
+ process.exit(1);
3595
+ }
3596
+ const interactive = cleanArgs.length === 0 && isTerminal();
3597
+ const db = new PhDB(dbPath);
3598
+ if (interactive) {
3599
+ const workdir = process.cwd();
3600
+ const { rootDir, projectName } = detectProject(workdir);
3601
+ const language = detectLanguage(rootDir);
3602
+ const gitContext = captureGitContext(workdir);
3603
+ const onPrompt = (prompt, ts) => {
3604
+ const metaObj = {};
3605
+ if (projectName) metaObj.project = projectName;
3606
+ if (language) metaObj.language = language;
3607
+ if (role) metaObj.role = role;
3608
+ if (tags.length > 0) metaObj.tags = tags;
3609
+ if (gitContext) metaObj.git_context = gitContext;
3610
+ const metadata = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : "{}";
3611
+ return db.insert({
3612
+ timestamp: ts.toISOString(),
3613
+ tool,
3614
+ prompt,
3615
+ response: "",
3616
+ args: prompt,
3617
+ workdir,
3618
+ hostname: os6.hostname(),
3619
+ exit_code: 0,
3620
+ metadata
3621
+ });
3622
+ };
3623
+ const onResponse = (id, response) => {
3624
+ if (!response.trim()) return;
3625
+ db.updateResponse(id, response);
3626
+ if (cfg.backgroundAnalysis) {
3627
+ spawnBackgroundAnalysis(id, dbPath);
3628
+ }
3629
+ };
3630
+ const exitCode = await runPTY(realBin, cleanArgs, onPrompt, onResponse, debugLog);
3631
+ db.close();
3632
+ process.exit(exitCode);
3633
+ } else {
3634
+ await runInline(realBin, cleanArgs, db, tool, tags, role, (id) => {
3635
+ if (cfg.backgroundAnalysis) {
3636
+ spawnBackgroundAnalysis(id, dbPath);
3637
+ }
3638
+ });
3639
+ }
3640
+ }
3641
+ async function main() {
3642
+ const argv = process.argv.slice(2);
3643
+ const cfg = load();
3644
+ if (cfg.geminiApiKey) {
3645
+ process.env.GEMINI_API_KEY = cfg.geminiApiKey;
3646
+ }
3647
+ const dbPath = process.env.PH_DB ?? cfg.dbPath ?? defaultPath();
3648
+ if (argv.length === 0) {
3649
+ if (isTerminal()) {
3650
+ const db = new PhDB(dbPath);
3651
+ process.stdout.write("\x1B[?1049h");
3652
+ let pendingRerun = null;
3653
+ try {
3654
+ const { waitUntilExit } = render(
3655
+ React7.createElement(BrowseApp, {
3656
+ db,
3657
+ onRerun: (tool, prompt) => {
3658
+ pendingRerun = { tool, prompt };
3659
+ }
3660
+ })
3661
+ );
3662
+ await waitUntilExit();
3663
+ } finally {
3664
+ process.stdout.write("\x1B[?1049l");
3665
+ db.close();
3666
+ }
3667
+ if (pendingRerun) {
3668
+ const { tool, prompt } = pendingRerun;
3669
+ const realBin = resolveRealBinary(tool);
3670
+ const child = spawnSync(realBin, [prompt], { stdio: "inherit" });
3671
+ process.exit(child.status ?? 0);
3672
+ }
3673
+ process.exit(0);
3674
+ } else {
3675
+ process.stdout.write(USAGE);
3676
+ process.exit(0);
3677
+ }
3678
+ }
3679
+ let cmdIdx = -1;
3680
+ for (let i = 0; i < argv.length; i++) {
3681
+ if (argv[i].startsWith("-")) {
3682
+ if (argv[i] === "--ph-tag" || argv[i] === "--ph-role") i++;
3683
+ continue;
3684
+ }
3685
+ cmdIdx = i;
3686
+ break;
3687
+ }
3688
+ if (cmdIdx === -1) {
3689
+ process.stdout.write(USAGE);
3690
+ process.exit(0);
3691
+ }
3692
+ const command = argv[cmdIdx];
3693
+ const cmdArgs = argv.slice(cmdIdx + 1);
3694
+ const preArgs = argv.slice(0, cmdIdx);
3695
+ switch (command) {
3696
+ case "search": {
3697
+ const { flags, positional } = parseFlags(cmdArgs);
3698
+ const query = positional.join(" ");
3699
+ if (flags["i"] || flags["interactive"]) {
3700
+ const db2 = new PhDB(dbPath);
3701
+ process.stdout.write("\x1B[?1049h");
3702
+ let pendingRerun = null;
3703
+ try {
3704
+ const { waitUntilExit } = render(
3705
+ React7.createElement(BrowseApp, {
3706
+ db: db2,
3707
+ initialTextFilter: query,
3708
+ initialFilters: {
3709
+ tool: flags["tool"],
3710
+ project: flags["project"],
3711
+ language: flags["language"],
3712
+ role: flags["role"],
3713
+ tag: flags["tag"],
3714
+ starred: Boolean(flags["starred"]),
3715
+ minQuality: flags["top"] ? 8 : flags["min-quality"] ? Number(flags["min-quality"]) : void 0,
3716
+ minRelevance: flags["min-relevance"] ? Number(flags["min-relevance"]) : void 0
3717
+ },
3718
+ onRerun: (tool, prompt) => {
3719
+ pendingRerun = { tool, prompt };
3720
+ }
3721
+ })
3722
+ );
3723
+ await waitUntilExit();
3724
+ } finally {
3725
+ process.stdout.write("\x1B[?1049l");
3726
+ db2.close();
3727
+ }
3728
+ if (pendingRerun) {
3729
+ const { tool, prompt } = pendingRerun;
3730
+ const realBin = resolveRealBinary(tool);
3731
+ const child = spawnSync(realBin, [prompt], { stdio: "inherit" });
3732
+ process.exit(child.status ?? 0);
3733
+ }
3734
+ break;
3735
+ }
3736
+ const db = new PhDB(dbPath);
3737
+ await cmdSearch(db, cfg, cmdArgs);
3738
+ db.close();
3739
+ break;
3740
+ }
3741
+ case "last": {
3742
+ const db = new PhDB(dbPath);
3743
+ await cmdLast(db, cmdArgs);
3744
+ db.close();
3745
+ break;
3746
+ }
3747
+ case "sessions": {
3748
+ const db = new PhDB(dbPath);
3749
+ await cmdSessions(db, cmdArgs);
3750
+ db.close();
3751
+ break;
3752
+ }
3753
+ case "stats": {
3754
+ const db = new PhDB(dbPath);
3755
+ await cmdStats(db);
3756
+ db.close();
3757
+ break;
3758
+ }
3759
+ case "cluster": {
3760
+ const db = new PhDB(dbPath);
3761
+ await cmdCluster(db, cmdArgs);
3762
+ db.close();
3763
+ break;
3764
+ }
3765
+ case "analyze-reusability": {
3766
+ const db = new PhDB(dbPath);
3767
+ await cmdAnalyzeReusability(db, cmdArgs);
3768
+ db.close();
3769
+ break;
3770
+ }
3771
+ case "cleanup-reusability": {
3772
+ const db = new PhDB(dbPath);
3773
+ await cmdCleanupReusability(db, cfg, cmdArgs);
3774
+ db.close();
3775
+ break;
3776
+ }
3777
+ case "star": {
3778
+ const db = new PhDB(dbPath);
3779
+ await cmdStar(db, cmdArgs);
3780
+ db.close();
3781
+ break;
3782
+ }
3783
+ case "export": {
3784
+ const db = new PhDB(dbPath);
3785
+ await cmdExport(db, cmdArgs);
3786
+ db.close();
3787
+ break;
3788
+ }
3789
+ case "import": {
3790
+ const db = new PhDB(dbPath);
3791
+ await cmdImport(db, cfg, cmdArgs);
3792
+ db.close();
3793
+ break;
3794
+ }
3795
+ case "analyze": {
3796
+ const db = new PhDB(dbPath);
3797
+ await cmdAnalyze(db, cfg, cmdArgs);
3798
+ db.close();
3799
+ break;
3800
+ }
3801
+ case "log": {
3802
+ await cmdLog(dbPath, cfg, cmdArgs);
3803
+ break;
3804
+ }
3805
+ case "embed-all": {
3806
+ const db = new PhDB(dbPath);
3807
+ await cmdEmbedAll(db, cfg);
3808
+ db.close();
3809
+ break;
3810
+ }
3811
+ case "cleanup": {
3812
+ const db = new PhDB(dbPath);
3813
+ await cmdCleanup(db, cfg, cmdArgs);
3814
+ db.close();
3815
+ break;
3816
+ }
3817
+ case "config":
3818
+ cmdConfig(cmdArgs);
3819
+ break;
3820
+ case "_bg-analyze": {
3821
+ await cmdBackgroundAnalyze(dbPath, cmdArgs);
3822
+ break;
3823
+ }
3824
+ case "browse": {
3825
+ const db = new PhDB(dbPath);
3826
+ process.stdout.write("\x1B[?1049h");
3827
+ let pendingRerun = null;
3828
+ try {
3829
+ const { waitUntilExit } = render(
3830
+ React7.createElement(BrowseApp, {
3831
+ db,
3832
+ onRerun: (tool, prompt) => {
3833
+ pendingRerun = { tool, prompt };
3834
+ }
3835
+ })
3836
+ );
3837
+ await waitUntilExit();
3838
+ } finally {
3839
+ process.stdout.write("\x1B[?1049l");
3840
+ db.close();
3841
+ }
3842
+ if (pendingRerun) {
3843
+ const { tool, prompt } = pendingRerun;
3844
+ const realBin = resolveRealBinary(tool);
3845
+ const child = spawnSync(realBin, [prompt], { stdio: "inherit" });
3846
+ process.exit(child.status ?? 0);
3847
+ }
3848
+ break;
3849
+ }
3850
+ case "ollama-models": {
3851
+ const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
3852
+ try {
3853
+ const res = await fetch(`${ollamaUrl}/api/tags`);
3854
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3855
+ const data = await res.json();
3856
+ const models = (data.models ?? []).map((m) => m.name).filter(Boolean);
3857
+ if (models.length === 0) {
3858
+ console.log("No models found.");
3859
+ } else {
3860
+ console.log(`Available Ollama models at ${ollamaUrl}:`);
3861
+ for (const m of models) console.log(` ${m}`);
3862
+ }
3863
+ } catch (err) {
3864
+ process.stderr.write(`ph: cannot reach Ollama at ${ollamaUrl}: ${err.message}
3865
+ `);
3866
+ process.exit(1);
3867
+ }
3868
+ break;
3869
+ }
3870
+ case "help":
3871
+ case "--help":
3872
+ case "-h":
3873
+ process.stdout.write(USAGE);
3874
+ break;
3875
+ default:
3876
+ await cmdWrap(dbPath, command, [...preArgs, ...cmdArgs], cfg);
3877
+ break;
3878
+ }
3879
+ }
3880
+ main().catch((e) => {
3881
+ process.stderr.write(`ph: ${e.message}
3882
+ `);
3883
+ process.exit(1);
3884
+ });
3885
+ //# sourceMappingURL=cli.js.map