@gi4nks/ph 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CHANGELOG.md +9 -2
  2. package/LICENSE +1 -1
  3. package/README.md +155 -145
  4. package/dist/cli.js +2279 -1052
  5. package/dist/cli.js.map +1 -1
  6. package/docs/evals/context-retrieval-golden.json +26 -0
  7. package/docs/evals/context-retrieval.md +27 -0
  8. package/docs/hooks.md +13 -7
  9. package/docs/ph-analysis.html +145 -0
  10. package/docs/ph-manual.md +375 -373
  11. package/docs/specs/SPEC-001-architecture-cli.md +82 -0
  12. package/docs/specs/SPEC-002-database-layer.md +105 -0
  13. package/docs/specs/SPEC-003-capture-modes.md +110 -0
  14. package/docs/specs/SPEC-004-filter-metadata.md +82 -0
  15. package/docs/specs/SPEC-005-search.md +102 -0
  16. package/docs/specs/SPEC-006-analysis-pipeline.md +98 -0
  17. package/docs/specs/SPEC-007-memory-summaries.md +96 -0
  18. package/docs/specs/SPEC-008-embeddings.md +90 -0
  19. package/docs/specs/SPEC-009-retention-archive.md +91 -0
  20. package/docs/specs/SPEC-010-remote-sync.md +106 -0
  21. package/docs/specs/SPEC-011-mcp-server.md +94 -0
  22. package/docs/specs/SPEC-012-tui-browser.md +98 -0
  23. package/docs/specs/SPEC-013-importers.md +109 -0
  24. package/docs/specs/SPEC-014-analytics-commands.md +109 -0
  25. package/docs/specs/SPEC-015-config-env.md +85 -0
  26. package/docs/specs/SPEC-INDEX.md +35 -0
  27. package/docs/specs/SPEC-ISSUES.md +122 -0
  28. package/docs/specs/_TEMPLATE.md +65 -0
  29. package/docs/superpowers/plans/2026-06-12-ph-evolution-phase1.md +466 -0
  30. package/docs/superpowers/plans/2026-06-12-ph-evolution-phase2.md +351 -0
  31. package/docs/superpowers/specs/2026-06-12-ph-evolution-design.md +173 -0
  32. package/hooks/claude/README.md +1 -1
  33. package/hooks/codex/README.md +54 -0
  34. package/hooks/codex/ph-hook.sh +14 -0
  35. package/hooks/gemini/README.md +1 -1
  36. package/hooks/opencode/ph-plugin.ts +60 -57
  37. package/package.json +18 -12
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { spawnSync as spawnSync2 } from "child_process";
5
- import React7 from "react";
5
+ import React8 from "react";
6
6
  import { render } from "ink";
7
7
 
8
8
  // src/config/index.ts
@@ -21,6 +21,7 @@ function load() {
21
21
  }
22
22
  function save(cfg) {
23
23
  fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 384 });
24
+ fs.chmodSync(CONFIG_PATH, 384);
24
25
  }
25
26
 
26
27
  // src/db/index.ts
@@ -29,15 +30,103 @@ import path2 from "path";
29
30
  import os2 from "os";
30
31
  import { createHash } from "crypto";
31
32
  import * as sqliteVec from "sqlite-vec";
33
+
34
+ // src/db/semantic-index.ts
35
+ var SemanticIndex = class {
36
+ constructor(db) {
37
+ this.db = db;
38
+ }
39
+ save(promptId, vector) {
40
+ const encoded = this.encode(vector);
41
+ this.db.prepare("INSERT OR REPLACE INTO embeddings (prompt_id, vector) VALUES (?, ?)").run(promptId, encoded);
42
+ this.db.prepare("INSERT OR REPLACE INTO vec_embeddings(rowid, embedding) VALUES (?, vec_f32(?))").run(BigInt(promptId), encoded);
43
+ }
44
+ search(query, limit, project) {
45
+ const encodedQuery = this.encode(query);
46
+ const projectFilter = project ? "AND json_extract(p.metadata, '$.project') = ?" : "";
47
+ const candidateLimit = project ? Math.max(1, this.db.prepare("SELECT count(*) AS count FROM vec_embeddings").get().count) : limit;
48
+ const sql = `
49
+ SELECT p.*, v.distance
50
+ FROM vec_embeddings v
51
+ JOIN prompts p ON p.id = v.rowid
52
+ WHERE v.embedding MATCH vec_f32(?) AND k = ?
53
+ ${projectFilter}
54
+ ORDER BY v.distance ASC
55
+ `;
56
+ return this.db.prepare(project ? `${sql} LIMIT ?` : sql).all(
57
+ encodedQuery,
58
+ candidateLimit,
59
+ ...project ? [project, limit] : []
60
+ );
61
+ }
62
+ loadAll() {
63
+ const rows = this.db.prepare("SELECT prompt_id, vector FROM embeddings").all();
64
+ return new Map(rows.map((row) => [row.prompt_id, this.decode(row.vector)]));
65
+ }
66
+ findMissingPrompts() {
67
+ return this.db.prepare(
68
+ `SELECT p.* FROM prompts p
69
+ LEFT JOIN vec_embeddings e ON e.rowid = p.id
70
+ WHERE e.rowid IS NULL`
71
+ ).all();
72
+ }
73
+ encode(vector) {
74
+ const buffer = Buffer.alloc(vector.length * 4);
75
+ for (let i = 0; i < vector.length; i++) buffer.writeFloatLE(vector[i], i * 4);
76
+ return buffer;
77
+ }
78
+ decode(buffer) {
79
+ const vector = new Float32Array(buffer.length / 4);
80
+ for (let i = 0; i < vector.length; i++) vector[i] = buffer.readFloatLE(i * 4);
81
+ return vector;
82
+ }
83
+ };
84
+
85
+ // src/db/index.ts
32
86
  function defaultPath() {
33
87
  return path2.join(os2.homedir(), ".prompt_history.db");
34
88
  }
35
- var PhDB = class {
89
+ var PhDB = class _PhDB {
90
+ static PROJECT_SUMMARIES_SCHEMA = `
91
+ CREATE TABLE IF NOT EXISTS project_summaries (
92
+ project TEXT PRIMARY KEY,
93
+ summary TEXT NOT NULL DEFAULT '',
94
+ key_insights TEXT NOT NULL DEFAULT '[]',
95
+ technical_decisions TEXT NOT NULL DEFAULT '[]',
96
+ prompt_count INTEGER NOT NULL DEFAULT 0,
97
+ first_analyzed TEXT NOT NULL,
98
+ last_analyzed TEXT NOT NULL,
99
+ git_context_snapshot TEXT
100
+ );
101
+ `;
102
+ static ARCHIVE_SCHEMA = `
103
+ CREATE TABLE IF NOT EXISTS prompts_archive (
104
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
105
+ timestamp TEXT NOT NULL,
106
+ tool TEXT NOT NULL,
107
+ prompt TEXT NOT NULL,
108
+ response TEXT NOT NULL DEFAULT '',
109
+ args TEXT NOT NULL DEFAULT '',
110
+ workdir TEXT NOT NULL DEFAULT '',
111
+ hostname TEXT NOT NULL DEFAULT '',
112
+ exit_code INTEGER NOT NULL DEFAULT 0,
113
+ metadata TEXT NOT NULL DEFAULT '{}',
114
+ archived_at TEXT NOT NULL,
115
+ original_id INTEGER
116
+ );
117
+ CREATE INDEX IF NOT EXISTS idx_archive_timestamp ON prompts_archive(timestamp);
118
+ CREATE INDEX IF NOT EXISTS idx_archive_original_id ON prompts_archive(original_id);
119
+ `;
36
120
  db;
121
+ semanticIndex;
122
+ /** Absolute path of the SQLite file (used by the HTTP server health endpoint). */
123
+ dbPath;
37
124
  constructor(dbPath) {
125
+ this.dbPath = dbPath;
38
126
  this.db = new Database(dbPath);
39
127
  this.db.pragma("journal_mode = WAL");
40
128
  sqliteVec.load(this.db);
129
+ this.semanticIndex = new SemanticIndex(this.db);
41
130
  this.migrate();
42
131
  }
43
132
  migrate() {
@@ -104,6 +193,8 @@ var PhDB = class {
104
193
  );
105
194
  CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
106
195
  `);
196
+ this.db.exec(_PhDB.PROJECT_SUMMARIES_SCHEMA);
197
+ this.db.exec(_PhDB.ARCHIVE_SCHEMA);
107
198
  const rowsWithoutSchema = this.db.prepare("SELECT id, metadata FROM prompts WHERE json_extract(metadata, '$.\\$schema_version') IS NULL").all();
108
199
  for (const row of rowsWithoutSchema) {
109
200
  try {
@@ -163,6 +254,7 @@ var PhDB = class {
163
254
  `);
164
255
  }
165
256
  }
257
+ /** `response` is optional at the call site (runtime defaults to ''). */
166
258
  insert(entry) {
167
259
  const meta = entry.metadata || "{}";
168
260
  const response = entry.response ?? "";
@@ -281,45 +373,16 @@ var PhDB = class {
281
373
  }
282
374
  }
283
375
  saveEmbedding(id, vector) {
284
- const buf = Buffer.alloc(vector.length * 4);
285
- for (let i = 0; i < vector.length; i++) {
286
- buf.writeFloatLE(vector[i], i * 4);
287
- }
288
- this.db.prepare("INSERT OR REPLACE INTO embeddings (prompt_id, vector) VALUES (?, ?)").run(id, buf);
289
- this.db.prepare("INSERT OR REPLACE INTO vec_embeddings(rowid, embedding) VALUES (?, vec_f32(?))").run(BigInt(id), buf);
376
+ this.semanticIndex.save(id, vector);
290
377
  }
291
- searchSemantic(queryVector, limit) {
292
- const buf = Buffer.alloc(queryVector.length * 4);
293
- for (let i = 0; i < queryVector.length; i++) {
294
- buf.writeFloatLE(queryVector[i], i * 4);
295
- }
296
- const sql = `
297
- SELECT p.*, v.distance
298
- FROM vec_embeddings v
299
- JOIN prompts p ON p.id = v.rowid
300
- WHERE v.embedding MATCH vec_f32(?) AND k = ?
301
- ORDER BY v.distance ASC
302
- `;
303
- return this.db.prepare(sql).all(buf, limit);
378
+ searchSemantic(queryVector, limit, project) {
379
+ return this.semanticIndex.search(queryVector, limit, project);
304
380
  }
305
381
  getAllEmbeddings() {
306
- const rows = this.db.prepare("SELECT prompt_id, vector FROM embeddings").all();
307
- const map = /* @__PURE__ */ new Map();
308
- for (const row of rows) {
309
- const vec = new Float32Array(row.vector.length / 4);
310
- for (let i = 0; i < vec.length; i++) {
311
- vec[i] = row.vector.readFloatLE(i * 4);
312
- }
313
- map.set(row.prompt_id, vec);
314
- }
315
- return map;
382
+ return this.semanticIndex.loadAll();
316
383
  }
317
384
  getPromptsWithoutEmbeddings() {
318
- return this.db.prepare(
319
- `SELECT p.* FROM prompts p
320
- LEFT JOIN vec_embeddings e ON e.rowid = p.id
321
- WHERE e.rowid IS NULL`
322
- ).all();
385
+ return this.semanticIndex.findMissingPrompts();
323
386
  }
324
387
  getAllPrompts(limit = 1e6) {
325
388
  return this.db.prepare("SELECT * FROM prompts ORDER BY timestamp DESC LIMIT ?").all(limit);
@@ -330,6 +393,15 @@ var PhDB = class {
330
393
  getPromptCount() {
331
394
  return this.db.prepare("SELECT count(*) as c FROM prompts").get().c;
332
395
  }
396
+ getPromptCountSince(timestamp) {
397
+ return this.db.prepare("SELECT count(*) as c FROM prompts WHERE timestamp > ?").get(timestamp).c;
398
+ }
399
+ getStats() {
400
+ const total = this.db.prepare("SELECT count(*) as c FROM prompts").get().c;
401
+ const totalMemories = this.db.prepare("SELECT count(*) as c FROM memories").get().c;
402
+ const byTool = this.db.prepare("SELECT tool, count(*) as count FROM prompts GROUP BY tool ORDER BY count DESC").all();
403
+ return { total, totalMemories, byTool };
404
+ }
333
405
  getPromptBySyncHash(hash) {
334
406
  return this.db.prepare("SELECT * FROM prompts WHERE json_extract(metadata, '$.sync_hash') = ?").get(hash);
335
407
  }
@@ -369,6 +441,72 @@ var PhDB = class {
369
441
  }
370
442
  return 0;
371
443
  }
444
+ archivePrompts(ids) {
445
+ if (ids.length === 0) return 0;
446
+ const now = (/* @__PURE__ */ new Date()).toISOString();
447
+ const select = this.db.prepare(`
448
+ SELECT id, timestamp, tool, prompt, response, args, workdir, hostname, exit_code, metadata
449
+ FROM prompts WHERE id = ?
450
+ `);
451
+ const insert = this.db.prepare(`
452
+ INSERT INTO prompts_archive (timestamp, tool, prompt, response, args, workdir, hostname, exit_code, metadata, archived_at, original_id)
453
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
454
+ `);
455
+ const del = this.db.prepare("DELETE FROM prompts WHERE id = ?");
456
+ const transaction = this.db.transaction((toArchive) => {
457
+ let count = 0;
458
+ for (const id of toArchive) {
459
+ const row = select.get(id);
460
+ if (!row) continue;
461
+ insert.run(
462
+ row.timestamp,
463
+ row.tool,
464
+ row.prompt,
465
+ row.response,
466
+ row.args,
467
+ row.workdir,
468
+ row.hostname,
469
+ row.exit_code,
470
+ row.metadata,
471
+ now,
472
+ id
473
+ );
474
+ del.run(id);
475
+ count++;
476
+ }
477
+ return count;
478
+ });
479
+ return transaction(ids);
480
+ }
481
+ searchArchive(opts) {
482
+ let sql = "SELECT * FROM prompts_archive WHERE 1=1";
483
+ const params = [];
484
+ if (opts.since) {
485
+ sql += " AND timestamp >= ?";
486
+ params.push(opts.since);
487
+ }
488
+ if (opts.until) {
489
+ sql += " AND timestamp <= ?";
490
+ params.push(opts.until);
491
+ }
492
+ sql += " ORDER BY timestamp DESC";
493
+ if (opts.limit) {
494
+ sql += " LIMIT ?";
495
+ params.push(opts.limit);
496
+ }
497
+ return this.db.prepare(sql).all(...params);
498
+ }
499
+ purgeArchive(beforeDate) {
500
+ const result = this.db.prepare("DELETE FROM prompts_archive WHERE archived_at < ?").run(beforeDate);
501
+ return result.changes;
502
+ }
503
+ getArchiveStats() {
504
+ const row = this.db.prepare(`
505
+ SELECT COUNT(*) as total, MIN(timestamp) as oldest, MAX(timestamp) as newest
506
+ FROM prompts_archive
507
+ `).get();
508
+ return row;
509
+ }
372
510
  vacuum() {
373
511
  this.db.exec("VACUUM");
374
512
  }
@@ -460,10 +598,62 @@ var PhDB = class {
460
598
  last_accessed: void 0
461
599
  });
462
600
  }
601
+ getLastMemoryForProject(project) {
602
+ const row = this.db.prepare("SELECT * FROM memories WHERE project = ? ORDER BY updated_at DESC LIMIT 1").get(project);
603
+ if (!row) return void 0;
604
+ return this.hydrateMemory(row);
605
+ }
463
606
  getAllProjectsWithMemories() {
464
607
  const rows = this.db.prepare("SELECT DISTINCT project FROM memories ORDER BY project").all();
465
608
  return rows.map((r) => r.project);
466
609
  }
610
+ upsertProjectSummary(params) {
611
+ const existing = this.getProjectSummary(params.project);
612
+ const now = (/* @__PURE__ */ new Date()).toISOString();
613
+ if (existing) {
614
+ const allInsights = /* @__PURE__ */ new Set([...existing.key_insights, ...params.key_insights]);
615
+ const allDecisions = /* @__PURE__ */ new Set([...existing.technical_decisions, ...params.technical_decisions]);
616
+ this.db.prepare(`
617
+ UPDATE project_summaries
618
+ SET summary = ?,
619
+ key_insights = ?,
620
+ technical_decisions = ?,
621
+ prompt_count = prompt_count + 1,
622
+ last_analyzed = ?,
623
+ git_context_snapshot = COALESCE(?, git_context_snapshot)
624
+ WHERE project = ?
625
+ `).run(
626
+ params.summary,
627
+ JSON.stringify([...allInsights]),
628
+ JSON.stringify([...allDecisions]),
629
+ now,
630
+ params.git_context_snapshot || null,
631
+ params.project
632
+ );
633
+ } else {
634
+ this.db.prepare(`
635
+ INSERT INTO project_summaries (project, summary, key_insights, technical_decisions, prompt_count, first_analyzed, last_analyzed, git_context_snapshot)
636
+ VALUES (?, ?, ?, ?, 1, ?, ?, ?)
637
+ `).run(
638
+ params.project,
639
+ params.summary,
640
+ JSON.stringify(params.key_insights),
641
+ JSON.stringify(params.technical_decisions),
642
+ now,
643
+ now,
644
+ params.git_context_snapshot || null
645
+ );
646
+ }
647
+ }
648
+ getProjectSummary(project) {
649
+ const row = this.db.prepare("SELECT * FROM project_summaries WHERE project = ?").get(project);
650
+ if (!row) return null;
651
+ return this.hydrateProjectSummary(row);
652
+ }
653
+ getAllProjectsWithSummaries() {
654
+ const rows = this.db.prepare("SELECT project FROM project_summaries ORDER BY project").all();
655
+ return rows.map((r) => r.project);
656
+ }
467
657
  recordMemoryAccess(id) {
468
658
  this.db.prepare("UPDATE memories SET access_count = access_count + 1, last_accessed = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), id);
469
659
  }
@@ -485,6 +675,18 @@ var PhDB = class {
485
675
  last_accessed: row.last_accessed
486
676
  };
487
677
  }
678
+ hydrateProjectSummary(row) {
679
+ return {
680
+ project: row.project,
681
+ summary: row.summary,
682
+ key_insights: JSON.parse(row.key_insights),
683
+ technical_decisions: JSON.parse(row.technical_decisions),
684
+ prompt_count: row.prompt_count,
685
+ first_analyzed: row.first_analyzed,
686
+ last_analyzed: row.last_analyzed,
687
+ git_context_snapshot: row.git_context_snapshot
688
+ };
689
+ }
488
690
  getAllPromptsByProject(project) {
489
691
  return this.db.prepare(`
490
692
  SELECT * FROM prompts
@@ -492,6 +694,20 @@ var PhDB = class {
492
694
  ORDER BY timestamp ASC
493
695
  `).all(project);
494
696
  }
697
+ getPromptsByProjectPaginated(project, page, pageSize) {
698
+ const total = this.db.prepare("SELECT COUNT(*) as count FROM prompts WHERE json_extract(metadata, '$.project') = ?").get(project);
699
+ const entries = this.db.prepare(`
700
+ SELECT * FROM prompts
701
+ WHERE json_extract(metadata, '$.project') = ?
702
+ ORDER BY timestamp DESC
703
+ LIMIT ? OFFSET ?
704
+ `).all(project, pageSize, (page - 1) * pageSize);
705
+ return { entries, total: total.count };
706
+ }
707
+ getPromptCountByProject(project) {
708
+ const row = this.db.prepare("SELECT COUNT(*) as count FROM prompts WHERE json_extract(metadata, '$.project') = ?").get(project);
709
+ return row.count;
710
+ }
495
711
  getAllMemoriesByProject(project) {
496
712
  const rows = this.db.prepare("SELECT * FROM memories WHERE project = ? ORDER BY created_at ASC").all(project);
497
713
  return rows.map((r) => this.hydrateMemory(r));
@@ -515,6 +731,34 @@ function cosineSimilarity(a, b) {
515
731
  // src/runner/inline.ts
516
732
  import { spawn, execFileSync as execFileSync2 } from "child_process";
517
733
  import fs3 from "fs";
734
+
735
+ // src/runner/git-context.ts
736
+ import { execFileSync } from "child_process";
737
+ function captureGitContext(workdir) {
738
+ try {
739
+ const branch = execFileSync("git", ["-C", workdir, "rev-parse", "--abbrev-ref", "HEAD"], {
740
+ encoding: "utf-8",
741
+ stdio: ["ignore", "pipe", "pipe"]
742
+ }).trim();
743
+ const filesRaw = execFileSync("git", ["-C", workdir, "diff", "--name-only", "HEAD"], {
744
+ encoding: "utf-8",
745
+ stdio: ["ignore", "pipe", "pipe"]
746
+ }).trim();
747
+ const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
748
+ let diff = execFileSync("git", ["-C", workdir, "diff", "HEAD"], {
749
+ encoding: "utf-8",
750
+ stdio: ["ignore", "pipe", "pipe"]
751
+ });
752
+ if (diff.length > 4e3) {
753
+ diff = diff.slice(0, 4e3) + "\n... (truncated)";
754
+ }
755
+ return { branch, files, diff };
756
+ } catch {
757
+ return null;
758
+ }
759
+ }
760
+
761
+ // src/capture/index.ts
518
762
  import os3 from "os";
519
763
 
520
764
  // src/runner/project.ts
@@ -561,30 +805,119 @@ function detectLanguage(rootDir) {
561
805
  return "";
562
806
  }
563
807
 
564
- // src/runner/git-context.ts
565
- import { execFileSync } from "child_process";
566
- function captureGitContext(workdir) {
567
- try {
568
- const branch = execFileSync("git", ["-C", workdir, "rev-parse", "--abbrev-ref", "HEAD"], {
569
- encoding: "utf-8",
570
- stdio: ["ignore", "pipe", "pipe"]
571
- }).trim();
572
- const filesRaw = execFileSync("git", ["-C", workdir, "diff", "--name-only", "HEAD"], {
573
- encoding: "utf-8",
574
- stdio: ["ignore", "pipe", "pipe"]
575
- }).trim();
576
- const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
577
- let diff = execFileSync("git", ["-C", workdir, "diff", "HEAD"], {
578
- encoding: "utf-8",
579
- stdio: ["ignore", "pipe", "pipe"]
580
- });
581
- if (diff.length > 4e3) {
582
- diff = diff.slice(0, 4e3) + "\n... (truncated)";
808
+ // src/utils/extractTopic.ts
809
+ function extractTopic(prompt) {
810
+ let text = prompt.trim();
811
+ if (!text) {
812
+ return "(empty prompt)";
813
+ }
814
+ if (text.startsWith("```") || text.startsWith("`")) {
815
+ return truncateAtWordBoundary(text, 72);
816
+ }
817
+ const originalTrimmed = text;
818
+ if (text.startsWith("#")) {
819
+ const headingMatch = text.match(/^#+\s+(.*?)(?:\s+#+)?(?:\n|$)/);
820
+ if (headingMatch && headingMatch[1]) {
821
+ text = headingMatch[1].trim();
583
822
  }
584
- return { branch, files, diff };
585
- } catch {
586
- return null;
587
823
  }
824
+ const prefixes = [
825
+ // English
826
+ "please, ",
827
+ "please ",
828
+ "can you please ",
829
+ "could you please ",
830
+ "can you ",
831
+ "could you ",
832
+ "would you ",
833
+ "i need you to ",
834
+ "i need to ",
835
+ "i want you to ",
836
+ "i want to ",
837
+ "i'd like you to ",
838
+ "i'd like to ",
839
+ "help me to ",
840
+ "help me ",
841
+ "i need ",
842
+ "claude, ",
843
+ "gemini, ",
844
+ "chatgpt, ",
845
+ // Italiano
846
+ "puoi ",
847
+ "potresti ",
848
+ "potreste ",
849
+ "per favore ",
850
+ "per piacere ",
851
+ "ho bisogno che ",
852
+ "ho bisogno di ",
853
+ "voglio che ",
854
+ "vorrei che ",
855
+ "voglio ",
856
+ "vorrei ",
857
+ "aiutami a ",
858
+ "aiutami ",
859
+ "fammi vedere ",
860
+ "fammi ",
861
+ "si prega di ",
862
+ "si "
863
+ ];
864
+ let changed = true;
865
+ while (changed) {
866
+ changed = false;
867
+ const lowerText = text.toLowerCase();
868
+ for (const prefix of prefixes) {
869
+ if (lowerText.startsWith(prefix.toLowerCase())) {
870
+ text = text.slice(prefix.length).trim();
871
+ changed = true;
872
+ break;
873
+ }
874
+ }
875
+ }
876
+ const sentenceEndMatch = text.match(/[\n.?!`]/);
877
+ if (sentenceEndMatch && sentenceEndMatch.index !== void 0) {
878
+ text = text.slice(0, sentenceEndMatch.index).trim();
879
+ }
880
+ if (!text) {
881
+ return truncateAtWordBoundary(originalTrimmed, 72);
882
+ }
883
+ text = truncateAtWordBoundary(text, 72);
884
+ return text.charAt(0).toUpperCase() + text.slice(1);
885
+ }
886
+ function truncateAtWordBoundary(str, limit) {
887
+ const clean = str.replace(/\s+/g, " ").trim();
888
+ if (clean.length <= limit) return clean;
889
+ const sub = clean.slice(0, limit);
890
+ const lastSpace = sub.lastIndexOf(" ");
891
+ if (lastSpace > 0) {
892
+ return sub.slice(0, lastSpace);
893
+ }
894
+ return sub;
895
+ }
896
+
897
+ // src/capture/index.ts
898
+ function createCaptureRecord(input) {
899
+ const { rootDir, projectName } = detectProject(input.workdir);
900
+ const language = detectLanguage(rootDir);
901
+ const title = extractTopic(input.prompt);
902
+ const metadata = {
903
+ $schema_version: 1,
904
+ ...projectName ? { project: projectName } : {},
905
+ ...language ? { language } : {},
906
+ ...title ? { title } : {},
907
+ ...input.gitContext ? { git_context: input.gitContext } : {},
908
+ ...input.metadata
909
+ };
910
+ return {
911
+ timestamp: input.timestamp ?? (/* @__PURE__ */ new Date()).toISOString(),
912
+ tool: input.tool,
913
+ prompt: input.prompt,
914
+ response: input.response ?? "",
915
+ args: input.args ?? "",
916
+ workdir: input.workdir,
917
+ hostname: input.hostname ?? os3.hostname(),
918
+ exit_code: input.exit_code ?? 0,
919
+ metadata: JSON.stringify(metadata)
920
+ };
588
921
  }
589
922
 
590
923
  // src/runner/inline.ts
@@ -613,26 +946,15 @@ function resolveRealBinary(tool) {
613
946
  }
614
947
  async function runInline(realBin, args, db, tool, tags, role, onInserted) {
615
948
  const workdir = process.cwd();
616
- const { rootDir, projectName } = detectProject(workdir);
617
- const language = detectLanguage(rootDir);
618
949
  const gitContext = captureGitContext(workdir);
619
- const metaObj = {};
620
- if (projectName) metaObj.project = projectName;
621
- if (language) metaObj.language = language;
622
- if (role) metaObj.role = role;
623
- if (tags.length > 0) metaObj.tags = tags;
624
- if (gitContext) metaObj.git_context = gitContext;
625
- const metadata = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : "{}";
626
- const id = db.insert({
627
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
950
+ const id = db.insert(createCaptureRecord({
628
951
  tool,
629
952
  prompt: extractPrompt(args),
630
953
  args: args.join(" "),
631
954
  workdir,
632
- hostname: os3.hostname(),
633
- exit_code: 0,
634
- metadata
635
- });
955
+ gitContext,
956
+ metadata: { ...role ? { role } : {}, ...tags.length > 0 ? { tags } : {} }
957
+ }));
636
958
  if (onInserted) onInserted(id);
637
959
  const child = spawn(realBin, args, { stdio: "inherit" });
638
960
  child.on("error", (err) => {
@@ -654,7 +976,7 @@ function cleanLine(bytes) {
654
976
  const s = Buffer.from(bytes).toString("utf-8");
655
977
  return s.trim();
656
978
  }
657
- function stripAnsi(text) {
979
+ function stripPtyControlSequences(text) {
658
980
  return text.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, "");
659
981
  }
660
982
  async function runPTY(binary, args, onPrompt, onResponse, debugLog) {
@@ -707,7 +1029,7 @@ async function runPTY(binary, args, onPrompt, onResponse, debugLog) {
707
1029
  log(`ENTER \u2192 prompt: ${JSON.stringify(prompt)}`);
708
1030
  if (prompt) {
709
1031
  if (currentPromptId !== null) {
710
- onResponse(currentPromptId, stripAnsi(responseBuffer));
1032
+ onResponse(currentPromptId, stripPtyControlSequences(responseBuffer));
711
1033
  }
712
1034
  currentPromptId = onPrompt(prompt, /* @__PURE__ */ new Date());
713
1035
  responseBuffer = "";
@@ -751,7 +1073,7 @@ async function runPTY(binary, args, onPrompt, onResponse, debugLog) {
751
1073
  return new Promise((resolve) => {
752
1074
  ptyProcess.onExit(({ exitCode }) => {
753
1075
  if (currentPromptId !== null) {
754
- onResponse(currentPromptId, stripAnsi(responseBuffer));
1076
+ onResponse(currentPromptId, stripPtyControlSequences(responseBuffer));
755
1077
  }
756
1078
  process.stdin.removeListener("data", onData);
757
1079
  process.stdout.removeListener("resize", onResize);
@@ -801,26 +1123,169 @@ async function getEmbeddings(texts, ollamaUrl, model, batchSize = 20) {
801
1123
  return results;
802
1124
  }
803
1125
 
804
- // src/mcp/server.ts
805
- import { z } from "zod";
806
- async function runMCPServer() {
807
- const cfg = load();
808
- const dbPath = process.env.PH_DB ?? cfg.dbPath ?? defaultPath();
809
- const db = new PhDB(dbPath);
810
- const server = new Server(
811
- {
812
- name: "ph-memory",
813
- version: "1.0.0"
814
- },
815
- {
816
- capabilities: {
817
- tools: {}
1126
+ // src/context/index.ts
1127
+ async function getProjectContext(db, options, embedQuery) {
1128
+ const limit = Math.max(1, Math.floor(options.limit ?? 5));
1129
+ const memoryLimit = Math.max(1, Math.floor(options.memoryLimit ?? limit));
1130
+ const includeSummary = options.includeSummary ?? true;
1131
+ const includeMemories = options.includeMemories ?? true;
1132
+ const includePrompts = options.includePrompts ?? true;
1133
+ const summary = includeSummary ? db.getProjectSummary(options.project) : null;
1134
+ const memories = includeMemories ? db.searchMemories(options.project, memoryLimit) : [];
1135
+ let prompts = [];
1136
+ if (includePrompts) {
1137
+ const query = options.query?.trim();
1138
+ if (query) {
1139
+ if (!embedQuery) throw new Error("Semantic context requires an embedding provider.");
1140
+ const vector = await embedQuery(query);
1141
+ prompts = vector ? db.searchSemantic(vector, limit, options.project) : [];
1142
+ } else {
1143
+ prompts = db.getProjectMemory(options.project, limit);
1144
+ }
1145
+ }
1146
+ return { project: options.project, summary, memories, prompts };
1147
+ }
1148
+ function formatProjectContext(context, options = {}) {
1149
+ const parts = [];
1150
+ const { summary, memories, prompts, project } = context;
1151
+ if (summary || memories.length > 0) {
1152
+ parts.push(`## Project Knowledge: ${project}
1153
+ `);
1154
+ if (summary?.summary) parts.push(`${summary.summary}
1155
+ `);
1156
+ if (summary?.key_insights.length) {
1157
+ parts.push("Key Insights:");
1158
+ for (const insight of summary.key_insights) parts.push(` - ${insight}`);
1159
+ parts.push("");
1160
+ }
1161
+ if (summary?.technical_decisions.length) {
1162
+ parts.push("Technical Decisions:");
1163
+ for (const decision of summary.technical_decisions) parts.push(` - ${decision}`);
1164
+ parts.push("");
1165
+ }
1166
+ if (!summary && memories.length > 0) {
1167
+ for (const memory of memories) {
1168
+ parts.push(`### Memory #${memory.id}`);
1169
+ if (memory.summary) parts.push(`${memory.summary}
1170
+ `);
1171
+ if (memory.key_insights.length) {
1172
+ parts.push("Key Insights:");
1173
+ for (const insight of memory.key_insights) parts.push(` - ${insight}`);
1174
+ parts.push("");
1175
+ }
1176
+ if (memory.technical_decisions.length) {
1177
+ parts.push("Technical Decisions:");
1178
+ for (const decision of memory.technical_decisions) parts.push(` - ${decision}`);
1179
+ parts.push("");
1180
+ }
818
1181
  }
819
1182
  }
820
- );
821
- server.setRequestHandler(ListToolsRequestSchema, async () => {
822
- return {
823
- tools: [
1183
+ if (summary && memories.length > 0) {
1184
+ parts.push("Recent Memory Entries:");
1185
+ for (const memory of memories) {
1186
+ if (memory.summary) parts.push(` - [memory #${memory.id}] ${memory.summary}`);
1187
+ }
1188
+ parts.push("");
1189
+ }
1190
+ }
1191
+ if (prompts.length > 0) {
1192
+ if (parts.length > 0) parts.push("---\n");
1193
+ parts.push("## Recent Context\n");
1194
+ for (const entry of prompts) {
1195
+ let metadata = {};
1196
+ try {
1197
+ metadata = JSON.parse(entry.metadata);
1198
+ } catch {
1199
+ }
1200
+ parts.push(`### Interaction #${entry.id} \u2014 ${entry.tool} (${new Date(entry.timestamp).toLocaleDateString()})`);
1201
+ if (metadata.role) parts.push(`Role: ${metadata.role}`);
1202
+ const promptSummary = metadata.summary || (options.includePromptText || options.promptExcerptLength ? "" : entry.prompt.slice(0, 100).replace(/\n/g, " "));
1203
+ if (promptSummary) parts.push(promptSummary);
1204
+ if (metadata.key_insights?.length) {
1205
+ for (const insight of metadata.key_insights) parts.push(` - ${insight}`);
1206
+ }
1207
+ if (options.includePromptText) {
1208
+ parts.push("```\n" + entry.prompt.slice(0, 1e3) + (entry.prompt.length > 1e3 ? "\n..." : "") + "\n```");
1209
+ } else if (options.promptExcerptLength) {
1210
+ const excerpt = entry.prompt.slice(0, options.promptExcerptLength);
1211
+ parts.push(`Prompt: ${excerpt}${entry.prompt.length > options.promptExcerptLength ? "..." : ""}`);
1212
+ }
1213
+ if (options.responseExcerptLength && entry.response) {
1214
+ const response = entry.response.slice(0, options.responseExcerptLength);
1215
+ parts.push(`Response: ${response}${entry.response.length > options.responseExcerptLength ? "..." : ""}`);
1216
+ }
1217
+ parts.push("");
1218
+ }
1219
+ }
1220
+ if (parts.length === 0) return options.emptyMessage ?? `No relevant context found for project "${project}".`;
1221
+ return parts.join("\n").trimEnd();
1222
+ }
1223
+
1224
+ // src/mcp/server.ts
1225
+ import { z } from "zod";
1226
+ function createMCPServer(db, cfg = load()) {
1227
+ const server = new Server(
1228
+ {
1229
+ name: "ph-memory",
1230
+ version: "1.0.0"
1231
+ },
1232
+ {
1233
+ capabilities: {
1234
+ tools: {}
1235
+ }
1236
+ }
1237
+ );
1238
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1239
+ return {
1240
+ tools: [
1241
+ {
1242
+ name: "list_prompts",
1243
+ description: "List prompts for a project with pagination. Use page=1, pageSize=10 to browse.",
1244
+ inputSchema: {
1245
+ type: "object",
1246
+ properties: {
1247
+ project: { type: "string", description: "Project name to list prompts for" },
1248
+ page: { type: "number", description: "Page number (1-based)", default: 1 },
1249
+ pageSize: { type: "number", description: "Results per page (max 100)", default: 20 }
1250
+ },
1251
+ required: ["project"]
1252
+ }
1253
+ },
1254
+ {
1255
+ name: "save_decision",
1256
+ description: "Save an architectural decision or key insight to project memory.",
1257
+ inputSchema: {
1258
+ type: "object",
1259
+ properties: {
1260
+ project: { type: "string", description: "Project name" },
1261
+ summary: { type: "string", description: "Brief summary of the decision" },
1262
+ keyInsights: {
1263
+ type: "array",
1264
+ items: { type: "string" },
1265
+ description: "Key insights or takeaways"
1266
+ },
1267
+ technicalDecisions: {
1268
+ type: "array",
1269
+ items: { type: "string" },
1270
+ description: "Technical decisions made"
1271
+ }
1272
+ },
1273
+ required: ["project", "summary"]
1274
+ }
1275
+ },
1276
+ {
1277
+ name: "get_project_diff",
1278
+ description: "Compare project state between two points in time. Shows prompts and memory entries created in the date range.",
1279
+ inputSchema: {
1280
+ type: "object",
1281
+ properties: {
1282
+ project: { type: "string", description: "Project name" },
1283
+ since: { type: "string", description: "Start date (ISO 8601, e.g. 2026-01-01)" },
1284
+ until: { type: "string", description: "End date (ISO 8601, defaults to now)" }
1285
+ },
1286
+ required: ["project", "since"]
1287
+ }
1288
+ },
824
1289
  {
825
1290
  name: "search_project_memory",
826
1291
  description: "Search for past interactions and technical decisions in a specific project using semantic search.",
@@ -904,7 +1369,10 @@ async function runMCPServer() {
904
1369
  inputSchema: {
905
1370
  type: "object",
906
1371
  properties: {
907
- project: { type: "string", description: "The project name" }
1372
+ project: { type: "string", description: "The project name" },
1373
+ since: { type: "string", description: "Start date (ISO 8601, optional)" },
1374
+ until: { type: "string", description: "End date (ISO 8601, optional)" },
1375
+ limit: { type: "number", description: "Max events to return (default 100)", default: 100 }
908
1376
  },
909
1377
  required: ["project"]
910
1378
  }
@@ -928,6 +1396,115 @@ async function runMCPServer() {
928
1396
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
929
1397
  const { name, arguments: args } = request.params;
930
1398
  try {
1399
+ if (name === "list_prompts") {
1400
+ const { project, page = 1, pageSize = 20 } = z.object({
1401
+ project: z.string(),
1402
+ page: z.number().optional(),
1403
+ pageSize: z.number().max(100).optional()
1404
+ }).parse(args);
1405
+ const result = db.getPromptsByProjectPaginated(project, page, pageSize);
1406
+ const totalPages = Math.ceil(result.total / pageSize);
1407
+ const text = [
1408
+ `## Prompts for "${project}"`,
1409
+ `Page ${page}/${totalPages} (${result.total} total)
1410
+ `,
1411
+ ...result.entries.map((e) => {
1412
+ let meta = {};
1413
+ try {
1414
+ meta = JSON.parse(e.metadata);
1415
+ } catch {
1416
+ }
1417
+ const date = new Date(e.timestamp).toLocaleString();
1418
+ const title = meta.title ? ` \u2014 ${meta.title}` : "";
1419
+ const roleStr = meta.role ? ` (${meta.role})` : "";
1420
+ return `### #${e.id} \u2014 ${e.tool}${roleStr} (${date})${title}
1421
+ ${meta.summary ? `Summary: ${meta.summary}
1422
+ ` : ""}Prompt: ${e.prompt.slice(0, 200)}${e.prompt.length > 200 ? "..." : ""}`;
1423
+ }).join("\n\n"),
1424
+ "",
1425
+ `Page ${page}/${totalPages} | ${result.total} total prompts`
1426
+ ].join("\n");
1427
+ return { content: [{ type: "text", text }] };
1428
+ }
1429
+ if (name === "save_decision") {
1430
+ const { project, summary, keyInsights, technicalDecisions } = z.object({
1431
+ project: z.string(),
1432
+ summary: z.string(),
1433
+ keyInsights: z.array(z.string()).optional(),
1434
+ technicalDecisions: z.array(z.string()).optional()
1435
+ }).parse(args);
1436
+ const id = db.insertMemory({
1437
+ project,
1438
+ prompt_ids: [],
1439
+ summary,
1440
+ key_insights: keyInsights ?? [],
1441
+ technical_decisions: technicalDecisions ?? [],
1442
+ created_at: (/* @__PURE__ */ new Date()).toISOString(),
1443
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
1444
+ access_count: 0
1445
+ });
1446
+ db.upsertProjectSummary({
1447
+ project,
1448
+ summary,
1449
+ key_insights: keyInsights ?? [],
1450
+ technical_decisions: technicalDecisions ?? []
1451
+ });
1452
+ return {
1453
+ content: [{ type: "text", text: `Decision saved as memory #${id} for project "${project}".
1454
+
1455
+ Summary: ${summary}
1456
+ Key Insights: ${(keyInsights ?? []).length}
1457
+ Technical Decisions: ${(technicalDecisions ?? []).length}` }]
1458
+ };
1459
+ }
1460
+ if (name === "get_project_diff") {
1461
+ const { project, since, until } = z.object({
1462
+ project: z.string(),
1463
+ since: z.string(),
1464
+ until: z.string().optional()
1465
+ }).parse(args);
1466
+ const sinceDate = new Date(since);
1467
+ const untilDate = until ? new Date(until) : /* @__PURE__ */ new Date();
1468
+ const prompts = db.search({
1469
+ project,
1470
+ since: sinceDate,
1471
+ until: untilDate,
1472
+ limit: 1e3
1473
+ });
1474
+ const memories = db.getAllMemoriesByProject(project).filter((m) => {
1475
+ const created = new Date(m.created_at);
1476
+ return created >= sinceDate && created <= untilDate;
1477
+ });
1478
+ const lines = [
1479
+ `## Project Diff: ${project}`,
1480
+ `From: ${since} To: ${until || "now"}`,
1481
+ `Prompts: ${prompts.length} Memories: ${memories.length}
1482
+ `
1483
+ ];
1484
+ if (prompts.length > 0) {
1485
+ lines.push("### Prompts\n");
1486
+ for (const p of prompts.reverse()) {
1487
+ let meta = {};
1488
+ try {
1489
+ meta = JSON.parse(p.metadata);
1490
+ } catch {
1491
+ }
1492
+ const title = meta.title ? ` \u2014 ${meta.title}` : "";
1493
+ const roleStr = meta.role ? ` (${meta.role})` : "";
1494
+ lines.push(`- **#${p.id}** ${p.tool}${roleStr}${title} (${new Date(p.timestamp).toLocaleDateString()})`);
1495
+ if (meta.summary) lines.push(` Summary: ${meta.summary}`);
1496
+ }
1497
+ }
1498
+ if (memories.length > 0) {
1499
+ lines.push("\n### Memory Entries\n");
1500
+ for (const m of memories) {
1501
+ lines.push(`- **Memory #${m.id}**: ${m.summary}`);
1502
+ for (const i of m.key_insights) lines.push(` - Insight: ${i}`);
1503
+ for (const d of m.technical_decisions) lines.push(` - Decision: ${d}`);
1504
+ }
1505
+ }
1506
+ return { content: [{ type: "text", text: lines.join("\n") }] };
1507
+ }
931
1508
  if (name === "search_project_memory") {
932
1509
  const { query, project, limit = 5 } = z.object({
933
1510
  query: z.string(),
@@ -940,15 +1517,30 @@ async function runMCPServer() {
940
1517
  if (!queryVec) {
941
1518
  throw new Error("Failed to generate embedding for query");
942
1519
  }
943
- const results = db.searchSemantic(queryVec, limit * 2);
944
- const filtered = results.filter((e) => {
945
- try {
946
- const meta = JSON.parse(e.metadata);
947
- return meta.project === project;
948
- } catch {
949
- return false;
1520
+ const filtered = db.searchSemantic(queryVec, limit, project);
1521
+ if (filtered.length === 0) {
1522
+ const merged = db.getProjectSummary(project);
1523
+ if (merged) {
1524
+ const parts = [
1525
+ `## Project Knowledge: ${project}
1526
+ `,
1527
+ `Based on ${merged.prompt_count} interactions.
1528
+ `
1529
+ ];
1530
+ if (merged.summary) parts.push(`${merged.summary}
1531
+ `);
1532
+ if (merged.key_insights.length > 0) {
1533
+ parts.push("\n**Key Insights:**");
1534
+ for (const i of merged.key_insights) parts.push(`- ${i}`);
1535
+ }
1536
+ if (merged.technical_decisions.length > 0) {
1537
+ parts.push("\n**Technical Decisions:**");
1538
+ for (const d of merged.technical_decisions) parts.push(`- ${d}`);
1539
+ }
1540
+ return { content: [{ type: "text", text: parts.join("\n") }] };
950
1541
  }
951
- }).slice(0, limit);
1542
+ return { content: [{ type: "text", text: `No relevant memory found for project "${project}".` }] };
1543
+ }
952
1544
  return {
953
1545
  content: [{ type: "text", text: formatResultsAsMarkdown(filtered) }]
954
1546
  };
@@ -958,59 +1550,39 @@ async function runMCPServer() {
958
1550
  project: z.string(),
959
1551
  limit: z.number().optional()
960
1552
  }).parse(args);
961
- const memories = db.searchMemories(project, 3);
962
- const prompts = db.getProjectMemory(project, limit);
963
- const parts = [];
964
- if (memories.length > 0) {
965
- parts.push("## Project Knowledge\n");
966
- for (const mem of memories) {
967
- if (mem.summary) parts.push(`${mem.summary}
968
- `);
969
- if (mem.key_insights.length > 0) {
970
- parts.push("Key Insights:");
971
- for (const i of mem.key_insights) parts.push(` - ${i}`);
972
- parts.push("");
973
- }
974
- if (mem.technical_decisions.length > 0) {
975
- parts.push("Technical Decisions:");
976
- for (const d of mem.technical_decisions) parts.push(` - ${d}`);
977
- parts.push("");
978
- }
979
- }
980
- }
981
- if (prompts.length > 0) {
982
- if (parts.length > 0) parts.push("---\n");
983
- parts.push("## Recent Interactions\n");
984
- parts.push(formatResultsAsMarkdown(prompts));
985
- }
1553
+ const context = await getProjectContext(db, { project, limit, memoryLimit: 3 });
986
1554
  return {
987
- content: [{ type: "text", text: parts.join("\n") || "No context found for this project." }]
1555
+ content: [{ type: "text", text: formatProjectContext(context, {
1556
+ promptExcerptLength: 300,
1557
+ responseExcerptLength: 200,
1558
+ emptyMessage: "No context found for this project."
1559
+ }) }]
988
1560
  };
989
1561
  }
990
1562
  if (name === "get_project_summary") {
991
1563
  const { project } = z.object({
992
1564
  project: z.string()
993
1565
  }).parse(args);
994
- const memories = db.searchMemories(project, 5);
995
- if (memories.length === 0) {
1566
+ const summary = db.getProjectSummary(project);
1567
+ if (!summary) {
996
1568
  return {
997
1569
  content: [{ type: "text", text: `No accumulated knowledge for project "${project}". Run "ph analyze" to generate insights.` }]
998
1570
  };
999
1571
  }
1000
1572
  const parts = [];
1001
- for (const mem of memories) {
1002
- parts.push(`## ${mem.summary || "Project Memory"}
1573
+ parts.push(`## ${summary.summary || "Project Memory"}
1003
1574
  `);
1004
- if (mem.key_insights.length > 0) {
1005
- parts.push("**Key Insights:**");
1006
- for (const i of mem.key_insights) parts.push(`- ${i}`);
1007
- parts.push("");
1008
- }
1009
- if (mem.technical_decisions.length > 0) {
1010
- parts.push("**Technical Decisions:**");
1011
- for (const d of mem.technical_decisions) parts.push(`- ${d}`);
1012
- parts.push("");
1013
- }
1575
+ parts.push(`Based on ${summary.prompt_count} interactions.
1576
+ `);
1577
+ if (summary.key_insights.length > 0) {
1578
+ parts.push("**Key Insights:**");
1579
+ for (const i of summary.key_insights) parts.push(`- ${i}`);
1580
+ parts.push("");
1581
+ }
1582
+ if (summary.technical_decisions.length > 0) {
1583
+ parts.push("**Technical Decisions:**");
1584
+ for (const d of summary.technical_decisions) parts.push(`- ${d}`);
1585
+ parts.push("");
1014
1586
  }
1015
1587
  return {
1016
1588
  content: [{ type: "text", text: parts.join("\n") }]
@@ -1067,32 +1639,37 @@ async function runMCPServer() {
1067
1639
  if (!queryVec) {
1068
1640
  throw new Error("Failed to generate embedding for query");
1069
1641
  }
1070
- const results = db.searchSemantic(queryVec, limit * 2);
1071
- const filtered = project ? results.filter((e) => {
1072
- try {
1073
- const meta = JSON.parse(e.metadata);
1074
- return meta.project === project;
1075
- } catch {
1076
- return false;
1077
- }
1078
- }).slice(0, limit) : results.slice(0, limit);
1642
+ const filtered = db.searchSemantic(queryVec, limit, project);
1079
1643
  return {
1080
1644
  content: [{ type: "text", text: formatPromptList(filtered, query) }]
1081
1645
  };
1082
1646
  }
1083
1647
  if (name === "get_project_timeline") {
1084
- const { project } = z.object({
1085
- project: z.string()
1648
+ const { project, since, until, limit = 100 } = z.object({
1649
+ project: z.string(),
1650
+ since: z.string().optional(),
1651
+ until: z.string().optional(),
1652
+ limit: z.number().optional()
1086
1653
  }).parse(args);
1087
- const prompts = db.getAllPromptsByProject(project);
1088
- const memories = db.getAllMemoriesByProject(project);
1089
- if (prompts.length === 0 && memories.length === 0) {
1654
+ let allPrompts = db.getAllPromptsByProject(project);
1655
+ let memories = db.getAllMemoriesByProject(project);
1656
+ if (since) {
1657
+ const sinceDate = new Date(since);
1658
+ allPrompts = allPrompts.filter((p) => new Date(p.timestamp) >= sinceDate);
1659
+ memories = memories.filter((m) => new Date(m.created_at) >= sinceDate);
1660
+ }
1661
+ if (until) {
1662
+ const untilDate = new Date(until);
1663
+ allPrompts = allPrompts.filter((p) => new Date(p.timestamp) <= untilDate);
1664
+ memories = memories.filter((m) => new Date(m.created_at) <= untilDate);
1665
+ }
1666
+ if (allPrompts.length === 0 && memories.length === 0) {
1090
1667
  return {
1091
- content: [{ type: "text", text: `No history found for project "${project}".` }]
1668
+ content: [{ type: "text", text: `No history found for project "${project}"${since ? ` since ${since}` : ""}${until ? ` until ${until}` : ""}.` }]
1092
1669
  };
1093
1670
  }
1094
1671
  const events = [];
1095
- for (const p of prompts) {
1672
+ for (const p of allPrompts.slice(0, limit)) {
1096
1673
  let meta = {};
1097
1674
  try {
1098
1675
  meta = JSON.parse(p.metadata);
@@ -1128,9 +1705,10 @@ ${m.technical_decisions.map((d) => `- ${d}`).join("\n")}` : ""
1128
1705
  });
1129
1706
  }
1130
1707
  events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
1131
- const header = `# Timeline: ${project}
1708
+ const range = since || until ? ` (${since || "\u2026"} \u2192 ${until || "now"})` : "";
1709
+ const header = `# Timeline: ${project}${range}
1132
1710
 
1133
- ${prompts.length} prompts \xB7 ${memories.length} memory entries
1711
+ ${allPrompts.length} prompts \xB7 ${memories.length} memory entries
1134
1712
 
1135
1713
  `;
1136
1714
  const body = events.map((e) => {
@@ -1180,15 +1758,7 @@ ${m.technical_decisions.map((d) => `- ${d}`).join("\n")}`);
1180
1758
  try {
1181
1759
  const [queryVec] = await getEmbeddings([`${project}: ${task}`], ollamaUrl, embedModel, 1);
1182
1760
  if (queryVec) {
1183
- const results = db.searchSemantic(queryVec, limit * 3);
1184
- const projectPrompts = results.filter((e) => {
1185
- try {
1186
- const meta = JSON.parse(e.metadata);
1187
- return meta.project === project;
1188
- } catch {
1189
- return false;
1190
- }
1191
- }).slice(0, limit);
1761
+ const projectPrompts = db.searchSemantic(queryVec, limit, project);
1192
1762
  if (projectPrompts.length > 0) {
1193
1763
  parts.push(`## Related Prompts
1194
1764
  `);
@@ -1226,6 +1796,13 @@ ${m.technical_decisions.map((d) => `- ${d}`).join("\n")}`);
1226
1796
  };
1227
1797
  }
1228
1798
  });
1799
+ return server;
1800
+ }
1801
+ async function runMCPServer() {
1802
+ const cfg = load();
1803
+ const dbPath = process.env.PH_DB ?? cfg.dbPath ?? defaultPath();
1804
+ const db = new PhDB(dbPath);
1805
+ const server = createMCPServer(db, cfg);
1229
1806
  const transport = new StdioServerTransport();
1230
1807
  await server.connect(transport);
1231
1808
  console.error("ph MCP server running on stdio");
@@ -1327,12 +1904,31 @@ function formatSinglePrompt(e) {
1327
1904
 
1328
1905
  // src/server/index.ts
1329
1906
  import http from "http";
1907
+ import { URL } from "url";
1908
+
1909
+ // src/utils/syncHash.ts
1330
1910
  import { createHash as createHash2 } from "crypto";
1911
+ function syncHash(entry) {
1912
+ const body = entry.args ? `${entry.tool}|${entry.prompt}|${entry.response}|${entry.args}` : `${entry.tool}|${entry.prompt}|${entry.response}`;
1913
+ return createHash2("sha256").update(body).digest("hex");
1914
+ }
1915
+
1916
+ // src/server/index.ts
1917
+ import { timingSafeEqual } from "crypto";
1331
1918
  async function runServer(port = 3001, host = "0.0.0.0") {
1332
1919
  const cfg = load();
1333
1920
  const dbPath = process.env.PH_DB ?? cfg.dbPath ?? defaultPath();
1334
1921
  const db = new PhDB(dbPath);
1335
- const server = http.createServer(async (req, res) => {
1922
+ const server = http.createServer(createRequestHandler(db, cfg));
1923
+ return new Promise((resolve) => {
1924
+ server.listen(port, host, () => {
1925
+ console.error(`ph server listening on http://${host}:${port}`);
1926
+ resolve();
1927
+ });
1928
+ });
1929
+ }
1930
+ function createRequestHandler(db, cfg) {
1931
+ return async (req, res) => {
1336
1932
  res.setHeader("Access-Control-Allow-Origin", "*");
1337
1933
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1338
1934
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
@@ -1341,28 +1937,34 @@ async function runServer(port = 3001, host = "0.0.0.0") {
1341
1937
  res.end();
1342
1938
  return;
1343
1939
  }
1940
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1941
+ if (url.pathname !== "/health" && !authorized(req, cfg)) {
1942
+ json(res, 401, { error: "unauthorized" });
1943
+ return;
1944
+ }
1344
1945
  try {
1345
1946
  const body = await readBody(req);
1346
1947
  await route(req, res, body, db, cfg);
1347
1948
  } catch (err) {
1348
1949
  json(res, 500, { error: err.message });
1349
1950
  }
1350
- });
1351
- return new Promise((resolve) => {
1352
- server.listen(port, host, () => {
1353
- console.error(`ph server listening on http://${host}:${port}`);
1354
- resolve();
1355
- });
1356
- });
1951
+ };
1952
+ }
1953
+ function authorized(req, cfg) {
1954
+ if (!cfg.remoteApiKey) return true;
1955
+ const expected = `Bearer ${cfg.remoteApiKey}`;
1956
+ const a = Buffer.from(req.headers.authorization ?? "");
1957
+ const b = Buffer.from(expected);
1958
+ return a.length === b.length && timingSafeEqual(a, b);
1357
1959
  }
1358
1960
  async function route(req, res, body, db, cfg) {
1359
1961
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1360
- const path9 = url.pathname;
1962
+ const path10 = url.pathname;
1361
1963
  const method = req.method || "GET";
1362
- if (method === "GET" && path9 === "/health") {
1363
- return json(res, 200, { status: "ok", dbPath: db.db?.name || "unknown" });
1964
+ if (method === "GET" && path10 === "/health") {
1965
+ return json(res, 200, { status: "ok", dbPath: db.dbPath });
1364
1966
  }
1365
- if (method === "POST" && path9 === "/api/prompts/search") {
1967
+ if (method === "POST" && path10 === "/api/prompts/search") {
1366
1968
  const { query, tool, project, role, tag, since, until, limit = 10 } = JSON.parse(body);
1367
1969
  const results = db.search({
1368
1970
  query,
@@ -1376,31 +1978,23 @@ async function route(req, res, body, db, cfg) {
1376
1978
  });
1377
1979
  return json(res, 200, { prompts: results });
1378
1980
  }
1379
- if (method === "POST" && path9 === "/api/prompts/by-id") {
1981
+ if (method === "POST" && path10 === "/api/prompts/by-id") {
1380
1982
  const { id } = JSON.parse(body);
1381
1983
  const entry = db.getById(id);
1382
1984
  if (!entry) return json(res, 404, { error: `Prompt #${id} not found` });
1383
1985
  return json(res, 200, { prompt: entry });
1384
1986
  }
1385
- if (method === "POST" && path9 === "/api/prompts/semantic") {
1987
+ if (method === "POST" && path10 === "/api/prompts/semantic") {
1386
1988
  const { query, project, limit = 5 } = JSON.parse(body);
1387
1989
  if (!query) return json(res, 400, { error: "query is required" });
1388
1990
  const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
1389
1991
  const model = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
1390
1992
  const [queryVec] = await getEmbeddings([query], ollamaUrl, model, 1);
1391
1993
  if (!queryVec) return json(res, 500, { error: "Failed to generate embedding" });
1392
- const results = db.searchSemantic(queryVec, limit * 2);
1393
- const filtered = project ? results.filter((e) => {
1394
- try {
1395
- const meta = JSON.parse(e.metadata);
1396
- return meta.project === project;
1397
- } catch {
1398
- return false;
1399
- }
1400
- }).slice(0, limit) : results.slice(0, limit);
1994
+ const filtered = db.searchSemantic(queryVec, limit, project);
1401
1995
  return json(res, 200, { prompts: filtered });
1402
1996
  }
1403
- if (method === "POST" && path9 === "/api/sync/push") {
1997
+ if (method === "POST" && path10 === "/api/sync/push") {
1404
1998
  const { prompts } = JSON.parse(body);
1405
1999
  if (!Array.isArray(prompts)) return json(res, 400, { error: "prompts must be an array" });
1406
2000
  let imported = 0;
@@ -1408,8 +2002,8 @@ async function route(req, res, body, db, cfg) {
1408
2002
  const errors = [];
1409
2003
  for (const p of prompts) {
1410
2004
  try {
1411
- const hash = sha256(`${p.tool}|${p.prompt}|${p.response}`);
1412
- const existing = db.db.prepare("SELECT id FROM prompts WHERE json_extract(metadata, '$.sync_hash') = ?").get(hash);
2005
+ const hash = syncHash(p);
2006
+ const existing = db.getPromptBySyncHash(hash);
1413
2007
  if (existing) {
1414
2008
  skipped++;
1415
2009
  continue;
@@ -1433,35 +2027,28 @@ async function route(req, res, body, db, cfg) {
1433
2027
  }
1434
2028
  return json(res, 200, { imported, skipped, errors });
1435
2029
  }
1436
- if (method === "POST" && path9 === "/api/sync/pull") {
2030
+ if (method === "POST" && path10 === "/api/sync/pull") {
1437
2031
  const { since } = JSON.parse(body);
1438
2032
  const limit = 1e4;
1439
- let results;
1440
- if (since) {
1441
- results = db.db.prepare("SELECT * FROM prompts WHERE timestamp > ? ORDER BY timestamp ASC LIMIT ?").all(since, limit);
1442
- } else {
1443
- results = db.db.prepare("SELECT * FROM prompts ORDER BY timestamp ASC LIMIT ?").all(limit);
1444
- }
2033
+ const results = since ? db.getPromptsSince(since, limit) : db.getAllPrompts(limit);
1445
2034
  return json(res, 200, { prompts: results, hasMore: results.length >= limit });
1446
2035
  }
1447
- if (method === "POST" && path9 === "/api/memories/search") {
2036
+ if (method === "POST" && path10 === "/api/memories/search") {
1448
2037
  const { project, limit = 10 } = JSON.parse(body);
1449
2038
  const memories = project ? db.searchMemories(project, limit) : [];
1450
2039
  return json(res, 200, { memories });
1451
2040
  }
1452
- if (method === "POST" && path9 === "/api/memories/summary") {
2041
+ if (method === "POST" && path10 === "/api/memories/summary") {
1453
2042
  const { project } = JSON.parse(body);
1454
2043
  if (!project) return json(res, 400, { error: "project is required" });
1455
2044
  const memories = db.searchMemories(project, 5);
1456
2045
  return json(res, 200, { memories });
1457
2046
  }
1458
- if (method === "GET" && path9 === "/api/stats") {
1459
- const total = db.db.prepare("SELECT count(*) as c FROM prompts").get().c;
1460
- const totalMemories = db.db.prepare("SELECT count(*) as c FROM memories").get().c;
1461
- const byTool = db.db.prepare("SELECT tool, count(*) as count FROM prompts GROUP BY tool ORDER BY count DESC").all();
2047
+ if (method === "GET" && path10 === "/api/stats") {
2048
+ const { total, totalMemories, byTool } = db.getStats();
1462
2049
  return json(res, 200, { total, totalMemories, byTool });
1463
2050
  }
1464
- json(res, 404, { error: `Not found: ${method} ${path9}` });
2051
+ json(res, 404, { error: `Not found: ${method} ${path10}` });
1465
2052
  }
1466
2053
  function json(res, status2, data) {
1467
2054
  res.writeHead(status2, { "Content-Type": "application/json" });
@@ -1476,12 +2063,8 @@ function readBody(req) {
1476
2063
  req.on("error", reject);
1477
2064
  });
1478
2065
  }
1479
- function sha256(s) {
1480
- return createHash2("sha256").update(s).digest("hex");
1481
- }
1482
2066
 
1483
2067
  // src/commands/remote.ts
1484
- import { createHash as createHash3 } from "crypto";
1485
2068
  function remoteUrl(cfg) {
1486
2069
  return process.env.PH_REMOTE_URL || cfg.remoteUrl || "";
1487
2070
  }
@@ -1584,7 +2167,7 @@ async function pull(dbPath, cfg, url) {
1584
2167
  let imported = 0;
1585
2168
  let skipped = 0;
1586
2169
  for (const p of result.prompts) {
1587
- const hash = sha2562(`${p.tool}|${p.prompt}|${p.response}`);
2170
+ const hash = syncHash(p);
1588
2171
  const existing = db.getPromptBySyncHash(hash);
1589
2172
  if (existing) {
1590
2173
  skipped++;
@@ -1622,7 +2205,7 @@ async function status(dbPath, cfg, url) {
1622
2205
  const localTotal = db.getPromptCount();
1623
2206
  const lastPush = cfg.remoteLastPush || "never";
1624
2207
  const lastPull = cfg.remoteLastPull || "never";
1625
- const pending = cfg.remoteLastPush ? db.db.prepare("SELECT count(*) as c FROM prompts WHERE timestamp > ?").get(cfg.remoteLastPush).c : localTotal;
2208
+ const pending = cfg.remoteLastPush ? db.getPromptCountSince(cfg.remoteLastPush) : localTotal;
1626
2209
  process.stdout.write(`Remote: ${url}
1627
2210
  `);
1628
2211
  process.stdout.write(`Local total: ${localTotal} prompts
@@ -1635,13 +2218,10 @@ async function status(dbPath, cfg, url) {
1635
2218
  `);
1636
2219
  db.close();
1637
2220
  }
1638
- function sha2562(s) {
1639
- return createHash3("sha256").update(s).digest("hex");
1640
- }
1641
2221
 
1642
2222
  // src/ui/BrowseApp.tsx
1643
- import React6, { useState as useState2, useCallback, useEffect as useEffect2, useMemo as useMemo3 } from "react";
1644
- import { Box as Box4, Text as Text5, useInput as useInput2, useApp } from "ink";
2223
+ import React7, { useState as useState3, useCallback, useEffect, useMemo as useMemo4 } from "react";
2224
+ import { Box as Box5, Text as Text6, useInput as useInput3, useApp } from "ink";
1645
2225
  import { spawn as spawn2 } from "child_process";
1646
2226
 
1647
2227
  // src/ui/Header.tsx
@@ -1652,10 +2232,13 @@ import { AppHeader, THEMES } from "@gi4nks/ink";
1652
2232
  // package.json
1653
2233
  var package_default = {
1654
2234
  name: "@gi4nks/ph",
1655
- version: "1.4.0",
2235
+ version: "1.6.0",
1656
2236
  description: "Transparent CLI wrapper that captures and indexes AI prompts",
1657
2237
  type: "module",
1658
2238
  license: "MIT",
2239
+ engines: {
2240
+ node: ">=22"
2241
+ },
1659
2242
  bin: {
1660
2243
  ph: "dist/cli.js"
1661
2244
  },
@@ -1703,37 +2286,40 @@ var package_default = {
1703
2286
  dev: "tsx src/cli.ts",
1704
2287
  start: "node dist/cli.js",
1705
2288
  test: "vitest run",
1706
- lint: "eslint src"
2289
+ lint: "eslint src",
2290
+ "eval:retrieval": "tsx scripts/eval-context-retrieval.ts"
1707
2291
  },
1708
2292
  dependencies: {
1709
2293
  "@gi4nks/ink": "^1.5.0",
2294
+ "@gi4nks/wise": "^2.1.0",
1710
2295
  "@lydell/node-pty": "^1.2.0-beta.3",
1711
2296
  "@modelcontextprotocol/sdk": "^1.29.0",
2297
+ ai: "^7.0.136",
1712
2298
  "better-sqlite3": "^12.8.0",
1713
- ink: "^6.8.0",
1714
- react: "^19.2.4",
2299
+ ink: "^7.0.5",
2300
+ react: "^19.2.7",
1715
2301
  "sqlite-vec": "^0.1.9",
1716
2302
  zod: "^4.3.6"
1717
2303
  },
1718
2304
  devDependencies: {
2305
+ "@eslint/js": "^9.39.5",
1719
2306
  "@semantic-release/changelog": "^6.0.3",
1720
2307
  "@semantic-release/git": "^10.0.1",
1721
2308
  "@semantic-release/github": "^12.0.8",
1722
2309
  "@semantic-release/npm": "^13.1.5",
1723
2310
  "@types/better-sqlite3": "^7.6.13",
1724
- "@types/node": "^25.5.0",
1725
- "@types/react": "^19.2.14",
1726
- "@typescript-eslint/eslint-plugin": "^8.57.2",
1727
- "@typescript-eslint/parser": "^8.57.2",
1728
- eslint: "^9.39.4",
2311
+ "@types/node": "^25.9.3",
2312
+ "@types/react": "^19.2.7",
2313
+ "@typescript-eslint/eslint-plugin": "^8.58.2",
2314
+ "@typescript-eslint/parser": "^8.58.2",
2315
+ eslint: "^9.39.5",
1729
2316
  "eslint-plugin-react": "^7.37.5",
1730
2317
  "eslint-plugin-react-hooks": "^7.0.1",
1731
2318
  "semantic-release": "^25.0.3",
1732
- "standard-version": "^9.5.0",
1733
2319
  tsup: "^8.5.1",
1734
2320
  tsx: "^4.21.0",
1735
- typescript: "^5.9.3",
1736
- vitest: "^4.1.0"
2321
+ typescript: "^6.0.3",
2322
+ vitest: "^4.1.8"
1737
2323
  }
1738
2324
  };
1739
2325
 
@@ -1753,6 +2339,7 @@ var FilterBadges = ({ active, textFilter }) => /* @__PURE__ */ React.createEleme
1753
2339
  var Header = ({
1754
2340
  entriesCount,
1755
2341
  allEntriesCount,
2342
+ archiveCount,
1756
2343
  activeFilters,
1757
2344
  textFilter,
1758
2345
  isTextFiltering,
@@ -1762,7 +2349,7 @@ var Header = ({
1762
2349
  const version = package_default.version;
1763
2350
  const rawCwd = process.cwd();
1764
2351
  const cwd = rawCwd.replace(process.env.HOME || "", "~");
1765
- const leftExtra = /* @__PURE__ */ React.createElement(Text, { color: "white", dimColor: true }, " ", entriesCount, entriesCount < allEntriesCount ? `/${allEntriesCount}` : "", " prompts");
2352
+ const leftExtra = /* @__PURE__ */ React.createElement(Text, { color: "white", dimColor: true }, " ", entriesCount, entriesCount < allEntriesCount ? `/${allEntriesCount}` : "", " prompts", archiveCount !== void 0 && archiveCount > 0 ? /* @__PURE__ */ React.createElement(Text, { color: "yellow" }, " (", archiveCount, " archived)") : null);
1766
2353
  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]"));
1767
2354
  return /* @__PURE__ */ React.createElement(
1768
2355
  AppHeader,
@@ -1804,6 +2391,7 @@ var Footer = ({
1804
2391
  );
1805
2392
  if (hasProject2) base.push({ key: "C", description: "chat" });
1806
2393
  base.push({ key: "r", description: "rerun" });
2394
+ base.push({ key: "p", description: "model" });
1807
2395
  base.push("|");
1808
2396
  base.push(
1809
2397
  { key: "x", description: "del" },
@@ -1825,113 +2413,217 @@ var Footer = ({
1825
2413
  );
1826
2414
  };
1827
2415
 
1828
- // src/ui/themes.ts
1829
- import { THEMES as THEMES3, THEME_NAMES } from "@gi4nks/ink";
2416
+ // src/ui/FilterPanel.tsx
2417
+ import React3, { useMemo as useMemo2, useState } from "react";
2418
+ import { Box, Text as Text2, useInput } from "ink";
1830
2419
 
1831
- // src/ui/SearchBar.tsx
1832
- import React3 from "react";
1833
- import { Box, Text as Text2 } from "ink";
1834
- var SearchBar = ({ value, isActive, theme }) => {
1835
- const isEmpty = value === "";
1836
- const bgColor = isActive ? theme.primary : theme.dim;
1837
- return /* @__PURE__ */ React3.createElement(Box, { paddingX: 1, paddingY: 0 }, /* @__PURE__ */ React3.createElement(Text2, { color: bgColor, bold: isActive }, isActive ? "\u2315" : "\u2315"), /* @__PURE__ */ React3.createElement(Text2, { color: isActive ? "white" : theme.dim }, " ", isEmpty ? isActive ? "" : "search (/)" : value, isActive ? "\u2588" : ""));
2420
+ // src/ui/filtering.ts
2421
+ var FILTER_CATEGORIES = ["project", "language", "role", "tool", "tag", "starred", "quality", "relevance"];
2422
+ var ROLE_COLOR2 = {
2423
+ debug: "red",
2424
+ refactor: "yellow",
2425
+ explain: "blue",
2426
+ review: "magenta",
2427
+ architect: "green",
2428
+ test: "cyan",
2429
+ docs: "white",
2430
+ generate: "green",
2431
+ research: "blue"
1838
2432
  };
1839
-
1840
- // src/ui/ListEntry.tsx
1841
- import React4 from "react";
1842
- import { Box as Box2, Text as Text3 } from "ink";
1843
-
1844
- // src/utils/extractTopic.ts
1845
- function extractTopic(prompt) {
1846
- let text = prompt.trim();
1847
- if (!text) {
1848
- return "(empty prompt)";
1849
- }
1850
- if (text.startsWith("```") || text.startsWith("`")) {
1851
- return truncateAtWordBoundary(text, 72);
2433
+ function parseMetadata(raw) {
2434
+ try {
2435
+ return JSON.parse(raw);
2436
+ } catch {
2437
+ return {};
1852
2438
  }
1853
- const originalTrimmed = text;
1854
- if (text.startsWith("#")) {
1855
- const headingMatch = text.match(/^#+\s+(.*?)(?:\s+#+)?(?:\n|$)/);
1856
- if (headingMatch && headingMatch[1]) {
1857
- text = headingMatch[1].trim();
2439
+ }
2440
+ var CODEX_RETRY_WINDOW_MS = 2 * 60 * 1e3;
2441
+ var CODEX_RETRY_MIN_PREFIX_LENGTH = 80;
2442
+ function displayTitle(entry) {
2443
+ return parseMetadata(entry.metadata).title || extractTopic(entry.prompt);
2444
+ }
2445
+ function normalizedPrompt(prompt) {
2446
+ return prompt.trim().replace(/\s+/g, " ");
2447
+ }
2448
+ function collapseCodexRetries(entries) {
2449
+ const buckets = /* @__PURE__ */ new Map();
2450
+ for (const entry of entries) {
2451
+ if (entry.tool.toLowerCase() !== "codex") continue;
2452
+ const key = `${entry.workdir}\0${displayTitle(entry)}`;
2453
+ const bucket = buckets.get(key) ?? [];
2454
+ bucket.push(entry);
2455
+ buckets.set(key, bucket);
2456
+ }
2457
+ const hidden = /* @__PURE__ */ new Set();
2458
+ for (const bucket of buckets.values()) {
2459
+ const incomplete = bucket.filter((entry) => !entry.response.trim());
2460
+ const completed = bucket.filter((entry) => !!entry.response.trim());
2461
+ for (const pending of incomplete) {
2462
+ const pendingPrompt = normalizedPrompt(pending.prompt);
2463
+ if (pendingPrompt.length < CODEX_RETRY_MIN_PREFIX_LENGTH) continue;
2464
+ const pendingTime = Date.parse(pending.timestamp);
2465
+ if (!Number.isFinite(pendingTime)) continue;
2466
+ const retried = completed.some((candidate) => {
2467
+ const candidateTime = Date.parse(candidate.timestamp);
2468
+ const candidatePrompt = normalizedPrompt(candidate.prompt);
2469
+ const delta = Math.abs(candidateTime - pendingTime);
2470
+ return Number.isFinite(candidateTime) && delta <= CODEX_RETRY_WINDOW_MS && candidatePrompt.length > pendingPrompt.length && candidatePrompt.startsWith(pendingPrompt);
2471
+ });
2472
+ if (retried) hidden.add(pending.id);
1858
2473
  }
1859
2474
  }
1860
- const prefixes = [
1861
- // English
1862
- "please, ",
1863
- "please ",
1864
- "can you please ",
1865
- "could you please ",
1866
- "can you ",
1867
- "could you ",
1868
- "would you ",
1869
- "i need you to ",
1870
- "i need to ",
1871
- "i want you to ",
1872
- "i want to ",
1873
- "i'd like you to ",
1874
- "i'd like to ",
1875
- "help me to ",
1876
- "help me ",
1877
- "i need ",
1878
- "claude, ",
1879
- "gemini, ",
1880
- "chatgpt, ",
1881
- // Italiano
1882
- "puoi ",
1883
- "potresti ",
1884
- "potreste ",
1885
- "per favore ",
1886
- "per piacere ",
1887
- "ho bisogno che ",
1888
- "ho bisogno di ",
1889
- "voglio che ",
1890
- "vorrei che ",
1891
- "voglio ",
1892
- "vorrei ",
1893
- "aiutami a ",
1894
- "aiutami ",
1895
- "fammi vedere ",
1896
- "fammi ",
1897
- "si prega di ",
1898
- "si "
1899
- ];
1900
- let changed = true;
1901
- while (changed) {
1902
- changed = false;
1903
- const lowerText = text.toLowerCase();
1904
- for (const prefix of prefixes) {
1905
- if (lowerText.startsWith(prefix.toLowerCase())) {
1906
- text = text.slice(prefix.length).trim();
1907
- changed = true;
1908
- break;
1909
- }
2475
+ return hidden.size ? entries.filter((entry) => !hidden.has(entry.id)) : entries;
2476
+ }
2477
+ function getDistinctValues(entries, category) {
2478
+ const values = /* @__PURE__ */ new Set();
2479
+ for (const entry of entries) {
2480
+ if (category === "tool") {
2481
+ values.add(entry.tool);
2482
+ continue;
1910
2483
  }
2484
+ const metadata = parseMetadata(entry.metadata);
2485
+ if (category === "project" && metadata.project) values.add(metadata.project);
2486
+ if (category === "language" && metadata.language) values.add(metadata.language);
2487
+ if (category === "role" && metadata.role) values.add(metadata.role);
2488
+ if (category === "tag") metadata.tags?.forEach((tag) => values.add(tag));
1911
2489
  }
1912
- const sentenceEndMatch = text.match(/[\n.?!`]/);
1913
- if (sentenceEndMatch && sentenceEndMatch.index !== void 0) {
1914
- text = text.slice(0, sentenceEndMatch.index).trim();
2490
+ return [...values].sort();
2491
+ }
2492
+ function applyFilters(entries, active, textFilter) {
2493
+ let result = collapseCodexRetries(entries);
2494
+ const hasActiveFilter = Object.values(active).some((value) => value !== void 0 && value !== false);
2495
+ if (hasActiveFilter) {
2496
+ result = result.filter((entry) => {
2497
+ const metadata = parseMetadata(entry.metadata);
2498
+ if (active.tool && entry.tool !== active.tool) return false;
2499
+ if (active.project && metadata.project !== active.project) return false;
2500
+ if (active.language && metadata.language !== active.language) return false;
2501
+ if (active.role && metadata.role !== active.role) return false;
2502
+ if (active.tag && !metadata.tags?.includes(active.tag)) return false;
2503
+ if (active.starred && !metadata.starred) return false;
2504
+ if (active.minQuality !== void 0 && (metadata.quality ?? 0) < active.minQuality) return false;
2505
+ if (active.minRelevance !== void 0 && (metadata.relevance ?? 0) < active.minRelevance) return false;
2506
+ return true;
2507
+ });
1915
2508
  }
1916
- if (!text) {
1917
- return truncateAtWordBoundary(originalTrimmed, 72);
2509
+ if (textFilter) {
2510
+ const query = textFilter.toLowerCase();
2511
+ result = result.filter((entry) => {
2512
+ const metadata = parseMetadata(entry.metadata);
2513
+ return entry.prompt.toLowerCase().includes(query) || entry.tool.toLowerCase().includes(query) || (metadata.project?.toLowerCase().includes(query) ?? false) || (metadata.role?.toLowerCase().includes(query) ?? false) || (metadata.tags?.some((tag) => tag.toLowerCase().includes(query)) ?? false);
2514
+ });
1918
2515
  }
1919
- text = truncateAtWordBoundary(text, 72);
1920
- return text.charAt(0).toUpperCase() + text.slice(1);
2516
+ return result;
1921
2517
  }
1922
- function truncateAtWordBoundary(str, limit) {
1923
- const clean = str.replace(/\s+/g, " ").trim();
1924
- if (clean.length <= limit) return clean;
1925
- const sub = clean.slice(0, limit);
1926
- const lastSpace = sub.lastIndexOf(" ");
1927
- if (lastSpace > 0) {
1928
- return sub.slice(0, lastSpace);
2518
+ function toggleFilter(active, option) {
2519
+ if (option.category === "starred") return { ...active, starred: !option.active || void 0 };
2520
+ if (option.category === "quality" || option.category === "relevance") {
2521
+ const value = parseInt(option.label.replace(option.category === "quality" ? "Q \u2265 " : "R \u2265 ", ""), 10);
2522
+ return option.category === "quality" ? { ...active, minQuality: option.active ? void 0 : value } : { ...active, minRelevance: option.active ? void 0 : value };
2523
+ }
2524
+ const filterKey = option.category;
2525
+ if (option.active) {
2526
+ const updated = { ...active };
2527
+ delete updated[filterKey];
2528
+ return updated;
2529
+ }
2530
+ return { ...active, [filterKey]: option.label };
2531
+ }
2532
+ function buildFilterOptions(entries, active) {
2533
+ const options = [];
2534
+ for (const category of FILTER_CATEGORIES) {
2535
+ if (category === "starred") {
2536
+ const count = entries.filter((entry) => parseMetadata(entry.metadata).starred).length;
2537
+ options.push({ category, label: "\u2605 Only starred", count, active: !!active.starred });
2538
+ continue;
2539
+ }
2540
+ if (category === "quality" || category === "relevance") {
2541
+ const isQuality = category === "quality";
2542
+ const activeValue = isQuality ? active.minQuality : active.minRelevance;
2543
+ const prefix = isQuality ? "Q \u2265 " : "R \u2265 ";
2544
+ options.push({ category, label: `${prefix}${activeValue ?? "?"}`, count: 0, active: activeValue !== void 0 });
2545
+ for (const value of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
2546
+ const count = entries.filter((entry) => {
2547
+ const metadata = parseMetadata(entry.metadata);
2548
+ return ((isQuality ? metadata.quality : metadata.relevance) ?? 0) >= value;
2549
+ }).length;
2550
+ options.push({ category, label: `${prefix}${value}`, count, active: activeValue === value });
2551
+ }
2552
+ continue;
2553
+ }
2554
+ for (const value of getDistinctValues(entries, category)) {
2555
+ const count = entries.filter((entry) => {
2556
+ const metadata = parseMetadata(entry.metadata);
2557
+ if (category === "project") return metadata.project === value;
2558
+ if (category === "language") return metadata.language === value;
2559
+ if (category === "role") return metadata.role === value;
2560
+ if (category === "tool") return entry.tool === value;
2561
+ if (category === "tag") return metadata.tags?.includes(value) ?? false;
2562
+ return false;
2563
+ }).length;
2564
+ const activeValue = active[category];
2565
+ options.push({ category, label: value, count, active: activeValue === value });
2566
+ }
1929
2567
  }
1930
- return sub;
2568
+ return options;
1931
2569
  }
1932
2570
 
2571
+ // src/ui/FilterPanel.tsx
2572
+ var FilterPanel = ({ allEntries, active, onUpdate, onClose, theme }) => {
2573
+ const options = useMemo2(() => buildFilterOptions(allEntries, active), [allEntries, active]);
2574
+ const [cursor, setCursor] = useState(0);
2575
+ const visibleCount = Math.min(options.length, 16);
2576
+ const scrollOffset = Math.max(0, Math.min(cursor - Math.floor(visibleCount / 2), options.length - visibleCount));
2577
+ const visible = options.slice(Math.max(0, scrollOffset), scrollOffset + visibleCount);
2578
+ useInput((char, key) => {
2579
+ if (key.escape) {
2580
+ onClose();
2581
+ return;
2582
+ }
2583
+ if (char === "c") {
2584
+ onUpdate({});
2585
+ return;
2586
+ }
2587
+ if (key.return || char === " ") {
2588
+ const option = options[cursor];
2589
+ if (!option) return;
2590
+ onUpdate(toggleFilter(active, option));
2591
+ return;
2592
+ }
2593
+ if (key.upArrow) setCursor((value) => Math.max(0, value - 1));
2594
+ if (key.downArrow) setCursor((value) => Math.min(options.length - 1, value + 1));
2595
+ if (key.pageUp) setCursor((value) => Math.max(0, value - visibleCount));
2596
+ if (key.pageDown) setCursor((value) => Math.min(options.length - 1, value + visibleCount));
2597
+ if (char && /^[a-z]$/.test(char)) {
2598
+ const index = options.findIndex((option, i) => i > cursor && option.category[0] === char);
2599
+ if (index !== -1) setCursor(index);
2600
+ }
2601
+ });
2602
+ const activeCount = Object.values(active).filter((value) => value !== void 0 && value !== false).length;
2603
+ return /* @__PURE__ */ React3.createElement(Box, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React3.createElement(Box, { marginBottom: 1 }, /* @__PURE__ */ React3.createElement(Text2, { color: theme.primary, bold: true }, "Filters "), activeCount > 0 ? /* @__PURE__ */ React3.createElement(Text2, { color: theme.warning }, activeCount, " active ") : /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, "none active "), activeCount > 0 && /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, "(c clear)")), /* @__PURE__ */ React3.createElement(Box, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1, minHeight: 18 }, /* @__PURE__ */ React3.createElement(Box, { flexDirection: "column" }, visible.map((option, index) => {
2604
+ const absoluteIndex = Math.max(0, scrollOffset) + index;
2605
+ const isCurrent = absoluteIndex === cursor;
2606
+ const categoryColor = option.category === "project" ? "blue" : option.category === "language" ? "green" : option.category === "role" ? ROLE_COLOR2[option.label] || "cyan" : option.category === "tool" ? "yellow" : option.category === "tag" ? "cyan" : "white";
2607
+ return /* @__PURE__ */ React3.createElement(Box, { key: `${option.category}-${option.label}` }, /* @__PURE__ */ React3.createElement(Text2, { bold: isCurrent, color: isCurrent ? theme.primary : theme.dim }, isCurrent ? "\u276F " : " "), /* @__PURE__ */ React3.createElement(Text2, { color: option.active ? theme.warning : categoryColor, bold: option.active || isCurrent }, option.category, ":", option.label), /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, " [", option.count, "]"), option.active && /* @__PURE__ */ React3.createElement(Text2, { color: theme.success }, " \u2713"));
2608
+ })), options.length > visibleCount && /* @__PURE__ */ React3.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, " ", cursor + 1, "/", options.length, " \xB7 \u2191\u2193 navigate \xB7 Enter toggle \xB7 c clear \xB7 ESC close"))), /* @__PURE__ */ React3.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React3.createElement(Text2, { dimColor: true }, "\u2191\u2193 navigate \xB7 Enter toggle \xB7 c clear all \xB7 ESC close \xB7 letter jumps to category")));
2609
+ };
2610
+
2611
+ // src/ui/themes.ts
2612
+ import { THEMES as THEMES3, THEME_NAMES } from "@gi4nks/ink";
2613
+
2614
+ // src/ui/SearchBar.tsx
2615
+ import React4 from "react";
2616
+ import { Box as Box2, Text as Text3 } from "ink";
2617
+ var SearchBar = ({ value, isActive, theme }) => {
2618
+ const isEmpty = value === "";
2619
+ const bgColor = isActive ? theme.primary : theme.dim;
2620
+ return /* @__PURE__ */ React4.createElement(Box2, { paddingX: 1, paddingY: 0 }, /* @__PURE__ */ React4.createElement(Text3, { color: bgColor, bold: isActive }, isActive ? "\u2315" : "\u2315"), /* @__PURE__ */ React4.createElement(Text3, { color: isActive ? "white" : theme.dim }, " ", isEmpty ? isActive ? "" : "search (/)" : value, isActive ? "\u2588" : ""));
2621
+ };
2622
+
1933
2623
  // src/ui/ListEntry.tsx
1934
- var ROLE_COLOR2 = {
2624
+ import React5 from "react";
2625
+ import { Box as Box3, Text as Text4 } from "ink";
2626
+ var ROLE_COLOR3 = {
1935
2627
  debug: "red",
1936
2628
  refactor: "yellow",
1937
2629
  explain: "blue",
@@ -1946,6 +2638,7 @@ function toolIndicator(tool) {
1946
2638
  if (tool === "claude") return { char: "\u25CF", color: "#f5a623" };
1947
2639
  if (tool === "gemini") return { char: "\u25CF", color: "#4fc3f7" };
1948
2640
  if (tool === "opencode") return { char: "\u25CF", color: "#e040fb" };
2641
+ if (tool === "codex") return { char: "\u25CF", color: "#74aa9c" };
1949
2642
  return { char: "\u25CB", color: "gray" };
1950
2643
  }
1951
2644
  function truncate(str, width) {
@@ -1966,32 +2659,195 @@ var ListEntry = ({ entry, isSelected, paneWidth, theme }) => {
1966
2659
  hour12: false
1967
2660
  });
1968
2661
  const role = meta.role ? truncate(meta.role, 6) : "";
1969
- const roleColor = meta.role ? ROLE_COLOR2[meta.role] || "white" : "white";
2662
+ const roleColor = meta.role ? ROLE_COLOR3[meta.role] || "white" : "white";
1970
2663
  const hasAnalysis = Boolean(meta.summary || meta.role || meta.tags && meta.tags.length > 0);
1971
- const roleBar = meta.role ? ROLE_COLOR2[meta.role] || theme.dim : theme.dim;
2664
+ const roleBar = meta.role ? ROLE_COLOR3[meta.role] || theme.dim : theme.dim;
1972
2665
  const rawTitle = meta.title || extractTopic(entry.prompt);
1973
2666
  const displayPrompt = truncate(rawTitle, paneWidth - 5);
1974
2667
  let summaryLine = "";
1975
2668
  if (meta.summary) {
1976
2669
  summaryLine = truncate(meta.summary.replace(/\n/g, " "), paneWidth - 5);
1977
2670
  }
1978
- return /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "row", width: paneWidth, overflow: "hidden" }, /* @__PURE__ */ React4.createElement(Text3, { color: roleBar }, "\u2502"), /* @__PURE__ */ React4.createElement(Text3, { color: indicator.color }, " ", indicator.char), /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, " ", entry.tool.padEnd(8)), meta.starred ? /* @__PURE__ */ React4.createElement(Text3, { color: theme.warning }, "\u2605") : /* @__PURE__ */ React4.createElement(Text3, null, " "), hasAnalysis ? /* @__PURE__ */ React4.createElement(Text3, { color: theme.success }, "\u25CF") : /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, "\u25CB"), /* @__PURE__ */ React4.createElement(Text3, null, " "), /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, hhmm, " "), role && /* @__PURE__ */ React4.createElement(Text3, { color: roleColor }, role), meta.quality !== void 0 && /* @__PURE__ */ React4.createElement(Text3, { color: theme.success }, " Q", meta.quality), meta.relevance !== void 0 && /* @__PURE__ */ React4.createElement(Text3, { color: theme.warning }, " R", meta.relevance)), /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "row" }, /* @__PURE__ */ React4.createElement(Text3, { color: roleBar }, "\u2502"), isSelected ? /* @__PURE__ */ React4.createElement(Text3, { inverse: true, bold: true }, " ", displayPrompt.padEnd(paneWidth - 3)) : /* @__PURE__ */ React4.createElement(Text3, null, " ", displayPrompt)), summaryLine && /* @__PURE__ */ React4.createElement(Box2, { flexDirection: "row" }, /* @__PURE__ */ React4.createElement(Text3, { color: roleBar }, "\u2502"), /* @__PURE__ */ React4.createElement(Text3, { dimColor: true }, " ", summaryLine)));
2671
+ return /* @__PURE__ */ React5.createElement(Box3, { flexDirection: "column" }, /* @__PURE__ */ React5.createElement(Box3, { flexDirection: "row", width: paneWidth, overflow: "hidden" }, /* @__PURE__ */ React5.createElement(Text4, { color: roleBar }, "\u2502"), /* @__PURE__ */ React5.createElement(Text4, { color: indicator.color }, " ", indicator.char), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, " ", entry.tool.padEnd(8)), meta.starred ? /* @__PURE__ */ React5.createElement(Text4, { color: theme.warning }, "\u2605") : /* @__PURE__ */ React5.createElement(Text4, null, " "), hasAnalysis ? /* @__PURE__ */ React5.createElement(Text4, { color: theme.success }, "\u25CF") : /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, "\u25CB"), /* @__PURE__ */ React5.createElement(Text4, null, " "), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, hhmm, " "), role && /* @__PURE__ */ React5.createElement(Text4, { color: roleColor }, 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(Box3, { flexDirection: "row" }, /* @__PURE__ */ React5.createElement(Text4, { color: roleBar }, "\u2502"), isSelected ? /* @__PURE__ */ React5.createElement(Text4, { inverse: true, bold: true }, " ", displayPrompt.padEnd(paneWidth - 3)) : /* @__PURE__ */ React5.createElement(Text4, null, " ", displayPrompt)), summaryLine && /* @__PURE__ */ React5.createElement(Box3, { flexDirection: "row" }, /* @__PURE__ */ React5.createElement(Text4, { color: roleBar }, "\u2502"), /* @__PURE__ */ React5.createElement(Text4, { dimColor: true }, " ", summaryLine)));
1979
2672
  };
1980
2673
 
1981
2674
  // src/ui/PreviewPane.tsx
1982
- import React5, { useState, useEffect, useMemo as useMemo2 } from "react";
1983
- import { Box as Box3, Text as Text4, useInput } from "ink";
1984
- var ROLE_COLOR3 = {
1985
- debug: "red",
1986
- refactor: "yellow",
1987
- explain: "blue",
1988
- review: "magenta",
1989
- architect: "green",
1990
- test: "cyan",
1991
- docs: "white",
1992
- generate: "green",
1993
- research: "blue"
1994
- };
2675
+ import React6, { useState as useState2, useMemo as useMemo3 } from "react";
2676
+ import { Box as Box4, Text as Text5, useInput as useInput2 } from "ink";
2677
+
2678
+ // src/utils/syntaxHighlight.ts
2679
+ var KEYWORDS = /* @__PURE__ */ new Set([
2680
+ "if",
2681
+ "else",
2682
+ "for",
2683
+ "while",
2684
+ "do",
2685
+ "switch",
2686
+ "case",
2687
+ "break",
2688
+ "continue",
2689
+ "return",
2690
+ "function",
2691
+ "class",
2692
+ "struct",
2693
+ "interface",
2694
+ "enum",
2695
+ "type",
2696
+ "const",
2697
+ "let",
2698
+ "var",
2699
+ "import",
2700
+ "export",
2701
+ "from",
2702
+ "default",
2703
+ "async",
2704
+ "await",
2705
+ "yield",
2706
+ "throw",
2707
+ "try",
2708
+ "catch",
2709
+ "finally",
2710
+ "new",
2711
+ "delete",
2712
+ "typeof",
2713
+ "instanceof",
2714
+ "void",
2715
+ "null",
2716
+ "undefined",
2717
+ "true",
2718
+ "false",
2719
+ "this",
2720
+ "super",
2721
+ "extends",
2722
+ "implements",
2723
+ "package",
2724
+ "namespace",
2725
+ "using",
2726
+ "def",
2727
+ "in",
2728
+ "not",
2729
+ "is",
2730
+ "and",
2731
+ "or",
2732
+ "with",
2733
+ "as",
2734
+ "pass",
2735
+ "raise",
2736
+ "lambda",
2737
+ "pub",
2738
+ "fn",
2739
+ "let",
2740
+ "mut",
2741
+ "impl",
2742
+ "trait",
2743
+ "use",
2744
+ "mod",
2745
+ "where",
2746
+ "ref",
2747
+ "func",
2748
+ "go",
2749
+ "defer",
2750
+ "select",
2751
+ "chan",
2752
+ "range",
2753
+ "map",
2754
+ "SELECT",
2755
+ "FROM",
2756
+ "WHERE",
2757
+ "INSERT",
2758
+ "UPDATE",
2759
+ "DELETE",
2760
+ "CREATE",
2761
+ "TABLE",
2762
+ "INTO",
2763
+ "VALUES",
2764
+ "SET",
2765
+ "JOIN",
2766
+ "LEFT",
2767
+ "RIGHT",
2768
+ "INNER",
2769
+ "OUTER",
2770
+ "ON",
2771
+ "AND",
2772
+ "OR",
2773
+ "NOT",
2774
+ "IN",
2775
+ "AS",
2776
+ "ORDER",
2777
+ "BY",
2778
+ "GROUP",
2779
+ "HAVING",
2780
+ "LIMIT",
2781
+ "OFFSET",
2782
+ "static",
2783
+ "public",
2784
+ "private",
2785
+ "protected",
2786
+ "readonly",
2787
+ "abstract",
2788
+ "virtual",
2789
+ "override",
2790
+ "final",
2791
+ "sealed",
2792
+ "internal",
2793
+ "extern",
2794
+ "module",
2795
+ "requires",
2796
+ "exports",
2797
+ "opens",
2798
+ "provides",
2799
+ "to",
2800
+ "transitive",
2801
+ "match",
2802
+ "enum",
2803
+ "macro",
2804
+ "unsafe"
2805
+ ]);
2806
+ function tokenizeLine(line) {
2807
+ const segments = [];
2808
+ let i = 0;
2809
+ while (i < line.length) {
2810
+ const rest = line.slice(i);
2811
+ const strMatch = rest.match(/^("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/);
2812
+ if (strMatch) {
2813
+ segments.push({ text: strMatch[1], color: "green" });
2814
+ i += strMatch[1].length;
2815
+ continue;
2816
+ }
2817
+ const commentMatch = rest.match(/^(\/\/.*)/);
2818
+ if (commentMatch) {
2819
+ segments.push({ text: commentMatch[1], dim: true });
2820
+ i += commentMatch[1].length;
2821
+ continue;
2822
+ }
2823
+ if (rest[0] === "#") {
2824
+ segments.push({ text: rest.slice(i), dim: true });
2825
+ break;
2826
+ }
2827
+ const numMatch = rest.match(/^(\d+(?:\.\d+)?)/);
2828
+ if (numMatch) {
2829
+ segments.push({ text: numMatch[1], color: "yellow" });
2830
+ i += numMatch[1].length;
2831
+ continue;
2832
+ }
2833
+ const wordMatch = rest.match(/^([a-zA-Z_][a-zA-Z0-9_]*)/);
2834
+ if (wordMatch) {
2835
+ const word = wordMatch[1];
2836
+ if (KEYWORDS.has(word)) {
2837
+ segments.push({ text: word, color: "blue" });
2838
+ } else if (/^[A-Z]/.test(word)) {
2839
+ segments.push({ text: word, color: "cyan" });
2840
+ } else {
2841
+ segments.push({ text: word });
2842
+ }
2843
+ i += word.length;
2844
+ continue;
2845
+ }
2846
+ segments.push({ text: rest[0] });
2847
+ i++;
2848
+ }
2849
+ return segments;
2850
+ }
1995
2851
  function wrapTextLines(text, width) {
1996
2852
  const lines = text.split("\n");
1997
2853
  const result = [];
@@ -2002,13 +2858,77 @@ function wrapTextLines(text, width) {
2002
2858
  }
2003
2859
  let current = line;
2004
2860
  while (current.length > width) {
2005
- result.push(current.substring(0, width));
2006
- current = current.substring(width);
2861
+ result.push(current.slice(0, width));
2862
+ current = current.slice(width);
2007
2863
  }
2008
2864
  if (current.length > 0) result.push(current);
2009
2865
  }
2010
2866
  return result;
2011
2867
  }
2868
+ function wrapRichLines(lines, width) {
2869
+ const result = [];
2870
+ for (const line of lines) {
2871
+ const totalLen = line.segments.reduce((sum, s) => sum + s.text.length, 0);
2872
+ if (totalLen <= width) {
2873
+ result.push(line);
2874
+ continue;
2875
+ }
2876
+ let currentSegs = [];
2877
+ let currentLen = 0;
2878
+ for (const seg of line.segments) {
2879
+ let remaining = seg.text;
2880
+ while (remaining.length > 0) {
2881
+ const space = width - currentLen;
2882
+ if (remaining.length <= space) {
2883
+ currentSegs.push({ text: remaining, color: seg.color, dim: seg.dim });
2884
+ currentLen += remaining.length;
2885
+ remaining = "";
2886
+ } else {
2887
+ currentSegs.push({ text: remaining.slice(0, space), color: seg.color, dim: seg.dim });
2888
+ result.push({ segments: currentSegs });
2889
+ currentSegs = [];
2890
+ currentLen = 0;
2891
+ remaining = remaining.slice(space);
2892
+ }
2893
+ }
2894
+ }
2895
+ if (currentLen > 0) {
2896
+ result.push({ segments: currentSegs });
2897
+ }
2898
+ }
2899
+ return result;
2900
+ }
2901
+ function buildRichLines(text, width) {
2902
+ const rawLines = text.split("\n");
2903
+ let inCode = false;
2904
+ const parsed = [];
2905
+ for (const rawLine of rawLines) {
2906
+ const trimmed = rawLine.trim();
2907
+ if (trimmed.startsWith("```")) {
2908
+ inCode = !inCode;
2909
+ continue;
2910
+ }
2911
+ if (inCode) {
2912
+ parsed.push({ segments: tokenizeLine(rawLine) });
2913
+ } else {
2914
+ parsed.push({ segments: [{ text: rawLine }] });
2915
+ }
2916
+ }
2917
+ return wrapRichLines(parsed, width);
2918
+ }
2919
+
2920
+ // src/ui/PreviewPane.tsx
2921
+ var ROLE_COLOR4 = {
2922
+ debug: "red",
2923
+ refactor: "yellow",
2924
+ explain: "blue",
2925
+ review: "magenta",
2926
+ architect: "green",
2927
+ test: "cyan",
2928
+ docs: "white",
2929
+ generate: "green",
2930
+ research: "blue"
2931
+ };
2012
2932
  function formatTimestamp(ts) {
2013
2933
  try {
2014
2934
  const d = new Date(ts);
@@ -2037,80 +2957,78 @@ var PreviewPane = ({
2037
2957
  theme,
2038
2958
  getProjectMemories
2039
2959
  }) => {
2040
- const [activeTab, setActiveTab] = useState("prompt");
2041
- const [scrollOffset, setScrollOffset] = useState(0);
2042
- useEffect(() => {
2043
- setActiveTab(entry?.response ? "response" : "prompt");
2044
- setScrollOffset(0);
2045
- }, [entry?.id]);
2046
- useEffect(() => {
2960
+ const [activeTab, setActiveTab] = useState2(
2961
+ entry?.response ? "response" : "prompt"
2962
+ );
2963
+ const [scrollOffset, setScrollOffset] = useState2(0);
2964
+ const switchTab = (tab) => {
2965
+ setActiveTab(tab);
2047
2966
  setScrollOffset(0);
2048
- }, [activeTab]);
2049
- const meta = useMemo2(() => entry ? parseMeta(entry.metadata) : {}, [entry]);
2967
+ };
2968
+ const meta = useMemo3(() => entry ? parseMeta(entry.metadata) : {}, [entry]);
2050
2969
  const contentWidth = Math.max(10, paneWidth - 4);
2051
2970
  const contentHeight = Math.max(3, paneHeight - 6);
2052
- const allLines = useMemo2(() => {
2971
+ const allLines = useMemo3(() => {
2053
2972
  if (!entry) return [];
2054
- if (activeTab === "prompt") return wrapTextLines(entry.prompt, contentWidth);
2055
- if (activeTab === "response") return wrapTextLines(entry.response || "(no response captured)", contentWidth);
2973
+ if (activeTab === "prompt") return buildRichLines(entry.prompt, contentWidth);
2974
+ if (activeTab === "response") return buildRichLines(entry.response || "(no response captured)", contentWidth);
2056
2975
  const lines = [];
2976
+ const push2 = (text) => lines.push({ segments: [{ text }] });
2057
2977
  if (meta.project && getProjectMemories) {
2058
2978
  const memories = getProjectMemories(meta.project);
2059
2979
  if (memories.length > 0) {
2060
- lines.push(`\u2500\u2500 Project Knowledge: ${meta.project} \u2500\u2500`);
2061
- lines.push("");
2980
+ push2(`\u2500\u2500 Project Knowledge: ${meta.project} \u2500\u2500`);
2981
+ push2("");
2062
2982
  for (const mem of memories) {
2063
2983
  if (mem.summary) {
2064
- lines.push(...wrapTextLines(mem.summary, contentWidth));
2065
- lines.push("");
2984
+ for (const l of wrapTextLines(mem.summary, contentWidth)) push2(l);
2985
+ push2("");
2066
2986
  }
2067
2987
  if (mem.key_insights.length > 0) {
2068
- lines.push("Key Insights:");
2069
- for (const i of mem.key_insights) lines.push(...wrapTextLines(` \u2022 ${i}`, contentWidth));
2070
- lines.push("");
2988
+ push2("Key Insights:");
2989
+ for (const i of mem.key_insights) for (const l of wrapTextLines(` \u2022 ${i}`, contentWidth)) push2(l);
2990
+ push2("");
2071
2991
  }
2072
2992
  if (mem.technical_decisions.length > 0) {
2073
- lines.push("Technical Decisions:");
2074
- for (const d of mem.technical_decisions) lines.push(...wrapTextLines(` \u2022 ${d}`, contentWidth));
2075
- lines.push("");
2993
+ push2("Technical Decisions:");
2994
+ for (const d of mem.technical_decisions) for (const l of wrapTextLines(` \u2022 ${d}`, contentWidth)) push2(l);
2995
+ push2("");
2076
2996
  }
2077
2997
  }
2078
- lines.push("\u2500\u2500 This Entry \u2500\u2500");
2079
- lines.push("");
2998
+ push2("\u2500\u2500 This Entry \u2500\u2500");
2999
+ push2("");
2080
3000
  }
2081
3001
  }
2082
3002
  if (meta.summary) {
2083
- lines.push(...wrapTextLines(meta.summary, contentWidth));
2084
- lines.push("");
3003
+ for (const l of wrapTextLines(meta.summary, contentWidth)) push2(l);
3004
+ push2("");
2085
3005
  }
2086
3006
  if (meta.key_insights && meta.key_insights.length > 0) {
2087
3007
  for (const insight of meta.key_insights) {
2088
- lines.push(...wrapTextLines(`\u2022 ${insight}`, contentWidth));
3008
+ for (const l of wrapTextLines(`\u2022 ${insight}`, contentWidth)) push2(l);
2089
3009
  }
2090
- lines.push("");
2091
- }
2092
- if (lines.length === 0) {
2093
- lines.push("(no AI analysis - run ph analyze)");
3010
+ push2("");
2094
3011
  }
3012
+ if (lines.length === 0) push2("(no AI analysis - run ph analyze)");
2095
3013
  return lines;
2096
3014
  }, [entry, activeTab, contentWidth, meta, getProjectMemories]);
2097
3015
  const maxScroll = Math.max(0, allLines.length - contentHeight);
2098
- useInput((_char, key) => {
3016
+ useInput2((_char, key) => {
2099
3017
  if (!isFocused) return;
2100
- if (_char === "1") setActiveTab("prompt");
2101
- if (_char === "2") setActiveTab("response");
2102
- if (_char === "3") setActiveTab("memory");
3018
+ if (_char === "1") switchTab("prompt");
3019
+ if (_char === "2") switchTab("response");
3020
+ if (_char === "3") switchTab("memory");
2103
3021
  if (key.upArrow) setScrollOffset((s) => Math.max(0, s - 1));
2104
3022
  if (key.downArrow) setScrollOffset((s) => Math.min(maxScroll, s + 1));
2105
3023
  if (key.pageUp) setScrollOffset((s) => Math.max(0, s - contentHeight));
2106
3024
  if (key.pageDown) setScrollOffset((s) => Math.min(maxScroll, s + contentHeight));
2107
3025
  });
2108
3026
  if (!entry) {
2109
- 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)"));
3027
+ return /* @__PURE__ */ React6.createElement(Box4, { width: paneWidth, height: paneHeight, paddingX: 2, flexDirection: "column", justifyContent: "center" }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "(select an entry to preview)"));
2110
3028
  }
2111
3029
  const visibleLines = allLines.slice(scrollOffset, scrollOffset + contentHeight);
2112
3030
  const paddedLines = [...visibleLines];
2113
- while (paddedLines.length < contentHeight) paddedLines.push("");
3031
+ while (paddedLines.length < contentHeight) paddedLines.push({ segments: [{ text: " " }] });
2114
3032
  const topIndicator = scrollOffset > 0 ? ` \u2191 ${scrollOffset} above` : " ";
2115
3033
  const remaining = allLines.length - scrollOffset - contentHeight;
2116
3034
  const botIndicator = scrollOffset < maxScroll ? ` \u2193 ${remaining} more` : " ";
@@ -2120,8 +3038,8 @@ var PreviewPane = ({
2120
3038
  const tabPromptColor = isPromptActive && isFocused ? theme.primary : theme.dim;
2121
3039
  const tabResponseColor = isResponseActive && isFocused ? theme.primary : theme.dim;
2122
3040
  const tabMemoryColor = isMemoryActive && isFocused ? theme.primary : theme.dim;
2123
- 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 ", formatTimestamp(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(
2124
- Text4,
3041
+ return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", paddingX: 2 }, /* @__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 ", formatTimestamp(entry.timestamp)), meta.starred && /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, " \u2605")), /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, 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, null, /* @__PURE__ */ React6.createElement(
3042
+ Text5,
2125
3043
  {
2126
3044
  color: tabPromptColor,
2127
3045
  bold: isPromptActive && isFocused,
@@ -2129,8 +3047,8 @@ var PreviewPane = ({
2129
3047
  },
2130
3048
  isPromptActive ? "\u25CF " : "\u25CB ",
2131
3049
  "1:PROMPT"
2132
- ), /* @__PURE__ */ React5.createElement(Text4, null, " "), /* @__PURE__ */ React5.createElement(
2133
- Text4,
3050
+ ), /* @__PURE__ */ React6.createElement(Text5, null, " "), /* @__PURE__ */ React6.createElement(
3051
+ Text5,
2134
3052
  {
2135
3053
  color: tabResponseColor,
2136
3054
  bold: isResponseActive && isFocused,
@@ -2138,8 +3056,8 @@ var PreviewPane = ({
2138
3056
  },
2139
3057
  isResponseActive ? "\u25CF " : "\u25CB ",
2140
3058
  "2:RESPONSE"
2141
- ), /* @__PURE__ */ React5.createElement(Text4, null, " "), /* @__PURE__ */ React5.createElement(
2142
- Text4,
3059
+ ), /* @__PURE__ */ React6.createElement(Text5, null, " "), /* @__PURE__ */ React6.createElement(
3060
+ Text5,
2143
3061
  {
2144
3062
  color: tabMemoryColor,
2145
3063
  bold: isMemoryActive && isFocused,
@@ -2147,22 +3065,56 @@ var PreviewPane = ({
2147
3065
  },
2148
3066
  isMemoryActive ? "\u25CF " : "\u25CB ",
2149
3067
  "3:MEMORY"
2150
- )), /* @__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));
3068
+ )), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, topIndicator), paddedLines.map((line, i) => /* @__PURE__ */ React6.createElement(Text5, { key: i }, line.segments.length > 0 ? line.segments.map((seg, j) => /* @__PURE__ */ React6.createElement(Text5, { key: j, color: seg.color, dimColor: seg.dim }, seg.text)) : " ")), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, botIndicator));
2151
3069
  };
2152
3070
 
3071
+ // src/config/provider-summary.ts
3072
+ function getProviderSummaries(cfg) {
3073
+ const active = cfg.analyzeProvider ?? "ollama";
3074
+ return [
3075
+ {
3076
+ id: "ollama",
3077
+ label: "Ollama",
3078
+ model: cfg.ollamaModel ?? "llama3.1:latest",
3079
+ url: cfg.ollamaUrl ?? "http://localhost:11434",
3080
+ credential: "not required",
3081
+ active: active === "ollama",
3082
+ ready: true
3083
+ },
3084
+ {
3085
+ id: "gemini",
3086
+ label: "Gemini",
3087
+ model: "gemini-2.5-flash",
3088
+ url: "(default)",
3089
+ credential: process.env.GEMINI_API_KEY || cfg.geminiApiKey ? "configured" : "not set",
3090
+ active: active === "gemini",
3091
+ ready: Boolean(process.env.GEMINI_API_KEY || cfg.geminiApiKey)
3092
+ },
3093
+ {
3094
+ id: "openrouter",
3095
+ label: "OpenRouter",
3096
+ model: process.env.OPENROUTER_MODEL ?? cfg.openrouterModel ?? "(not set)",
3097
+ url: process.env.OPENROUTER_BASE_URL ?? cfg.openrouterUrl ?? "https://openrouter.ai/api/v1",
3098
+ credential: process.env.OPENROUTER_API_KEY || cfg.openrouterApiKey ? "configured" : "not set",
3099
+ active: active === "openrouter",
3100
+ ready: Boolean((process.env.OPENROUTER_API_KEY || cfg.openrouterApiKey) && (process.env.OPENROUTER_MODEL || cfg.openrouterModel))
3101
+ },
3102
+ {
3103
+ id: "mlx-serve",
3104
+ label: "MLX-Serve",
3105
+ model: process.env.MLX_SERVE_MODEL ?? cfg.mlxServeModel ?? "(not set)",
3106
+ url: process.env.MLX_SERVE_BASE_URL ?? cfg.mlxServeUrl ?? "http://127.0.0.1:11234/v1",
3107
+ credential: process.env.MLX_SERVE_API_KEY || process.env.GLB_OMLX_API_KEY || cfg.mlxServeApiKey ? "configured" : "not required",
3108
+ active: active === "mlx-serve",
3109
+ ready: Boolean(process.env.MLX_SERVE_MODEL || cfg.mlxServeModel)
3110
+ }
3111
+ ];
3112
+ }
3113
+ function formatProviderSummary(provider) {
3114
+ return `${provider.active ? "*" : " "} ${provider.label}: model=${provider.model}, url=${provider.url}, key=${provider.credential}, setup=${provider.ready ? "ready" : "incomplete"}`;
3115
+ }
3116
+
2153
3117
  // src/ui/BrowseApp.tsx
2154
- var FILTER_CATEGORIES = ["project", "language", "role", "tool", "tag", "starred", "quality", "relevance"];
2155
- var ROLE_COLOR4 = {
2156
- debug: "red",
2157
- refactor: "yellow",
2158
- explain: "blue",
2159
- review: "magenta",
2160
- architect: "green",
2161
- test: "cyan",
2162
- docs: "white",
2163
- generate: "green",
2164
- research: "blue"
2165
- };
2166
3118
  function copyToClipboard(text) {
2167
3119
  try {
2168
3120
  const platform = process.platform;
@@ -2182,28 +3134,12 @@ function copyToClipboard(text) {
2182
3134
  } catch {
2183
3135
  }
2184
3136
  }
2185
- function wrapTextLines2(text, width) {
2186
- const result = [];
2187
- for (const line of text.split("\n")) {
2188
- let current = line;
2189
- if (current.length === 0) {
2190
- result.push("");
2191
- continue;
2192
- }
2193
- while (current.length > width) {
2194
- result.push(current.slice(0, width));
2195
- current = current.slice(width);
2196
- }
2197
- if (current.length > 0) result.push(current);
2198
- }
2199
- return result;
2200
- }
2201
3137
  function useStdoutDimensions() {
2202
- const [dimensions, setDimensions] = useState2({
3138
+ const [dimensions, setDimensions] = useState3({
2203
3139
  columns: process.stdout.columns || 120,
2204
3140
  rows: process.stdout.rows || 24
2205
3141
  });
2206
- useEffect2(() => {
3142
+ useEffect(() => {
2207
3143
  const handler = () => {
2208
3144
  setDimensions({
2209
3145
  columns: process.stdout.columns || 120,
@@ -2226,16 +3162,9 @@ function formatTimestamp2(ts) {
2226
3162
  return ts.slice(0, 16);
2227
3163
  }
2228
3164
  }
2229
- function parseMeta2(raw) {
2230
- try {
2231
- return JSON.parse(raw);
2232
- } catch {
2233
- return {};
2234
- }
2235
- }
2236
3165
  function hasProject(entry) {
2237
3166
  if (!entry) return false;
2238
- const meta = parseMeta2(entry.metadata);
3167
+ const meta = parseMetadata(entry.metadata);
2239
3168
  return Boolean(meta.project);
2240
3169
  }
2241
3170
  var SESSION_GAP_MS = 2 * 60 * 60 * 1e3;
@@ -2254,94 +3183,49 @@ function needsSessionSeparator(entries, idx) {
2254
3183
  const curr = new Date(entries[idx].timestamp).getTime();
2255
3184
  return prev - curr > SESSION_GAP_MS;
2256
3185
  }
2257
- function getDistinctValues(entries, category) {
2258
- const set = /* @__PURE__ */ new Set();
2259
- for (const e of entries) {
2260
- if (category === "tool") {
2261
- set.add(e.tool);
2262
- continue;
2263
- }
2264
- const meta = parseMeta2(e.metadata);
2265
- if (category === "project" && meta.project) set.add(meta.project);
2266
- if (category === "language" && meta.language) set.add(meta.language);
2267
- if (category === "role" && meta.role) set.add(meta.role);
2268
- if (category === "tag") meta.tags?.forEach((t) => set.add(t));
2269
- }
2270
- return [...set].sort();
2271
- }
2272
- function applyFilters(entries, active, textFilter) {
2273
- let result = entries;
2274
- const hasActiveFilter = Object.values(active).some((v) => v !== void 0 && v !== false);
2275
- if (hasActiveFilter) {
2276
- result = result.filter((e) => {
2277
- const meta = parseMeta2(e.metadata);
2278
- if (active.tool && e.tool !== active.tool) return false;
2279
- if (active.project && meta.project !== active.project) return false;
2280
- if (active.language && meta.language !== active.language) return false;
2281
- if (active.role && meta.role !== active.role) return false;
2282
- if (active.tag && !meta.tags?.includes(active.tag)) return false;
2283
- if (active.starred && !meta.starred) return false;
2284
- if (active.minQuality !== void 0 && (meta.quality ?? 0) < active.minQuality) return false;
2285
- if (active.minRelevance !== void 0 && (meta.relevance ?? 0) < active.minRelevance) return false;
2286
- return true;
2287
- });
2288
- }
2289
- if (textFilter) {
2290
- const lq = textFilter.toLowerCase();
2291
- result = result.filter((e) => {
2292
- const meta = parseMeta2(e.metadata);
2293
- 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);
2294
- });
2295
- }
2296
- return result;
2297
- }
2298
3186
  var DetailView = ({ entry, onClose, onEdit, termWidth, termHeight, theme }) => {
2299
- const meta = parseMeta2(entry.metadata);
2300
- const [activeTab, setActiveTab] = useState2(entry.response ? "response" : "prompt");
2301
- const [scrollOffset, setScrollOffset] = useState2(0);
2302
- const [copied, setCopied] = useState2(false);
2303
- useEffect2(() => {
3187
+ const meta = parseMetadata(entry.metadata);
3188
+ const [activeTab, setActiveTab] = useState3(entry.response ? "response" : "prompt");
3189
+ const [scrollOffset, setScrollOffset] = useState3(0);
3190
+ const [copied, setCopied] = useState3(false);
3191
+ const switchTab = (tab) => {
3192
+ setActiveTab(tab);
2304
3193
  setScrollOffset(0);
2305
- }, [activeTab]);
3194
+ };
2306
3195
  const contentWidth = Math.max(10, termWidth - 4);
2307
3196
  const text = activeTab === "prompt" ? entry.prompt : entry.response || "(no response captured)";
2308
- const lines = useMemo3(() => {
3197
+ const lines = useMemo4(() => {
2309
3198
  if (activeTab === "prompt" || activeTab === "response") {
2310
- return wrapTextLines2(text, contentWidth);
3199
+ return buildRichLines(text, contentWidth);
2311
3200
  }
2312
3201
  const memLines = [];
3202
+ const push2 = (t) => memLines.push({ segments: [{ text: t }] });
2313
3203
  if (meta.summary) {
2314
- memLines.push("SUMMARY:");
2315
- memLines.push(...wrapTextLines2(meta.summary, contentWidth));
2316
- memLines.push("");
3204
+ push2("SUMMARY:");
3205
+ for (const l of wrapTextLines(meta.summary, contentWidth)) push2(l);
3206
+ push2("");
2317
3207
  }
2318
3208
  if (meta.key_insights && meta.key_insights.length > 0) {
2319
- memLines.push("KEY INSIGHTS:");
3209
+ push2("KEY INSIGHTS:");
2320
3210
  for (const insight of meta.key_insights) {
2321
- memLines.push(...wrapTextLines2(`\u2022 ${insight}`, contentWidth));
3211
+ for (const l of wrapTextLines(`\u2022 ${insight}`, contentWidth)) push2(l);
2322
3212
  }
2323
3213
  }
2324
- if (memLines.length === 0) {
2325
- memLines.push("(no AI analysis found - run ph analyze)");
2326
- }
3214
+ if (memLines.length === 0) push2("(no AI analysis found - run ph analyze)");
2327
3215
  return memLines;
2328
3216
  }, [activeTab, text, contentWidth, meta]);
2329
3217
  const contentHeight = Math.max(1, termHeight - 10);
2330
3218
  const maxScroll = Math.max(0, lines.length - contentHeight);
2331
- useInput2((char, key) => {
3219
+ useInput3((char, key) => {
2332
3220
  if (key.escape || key.return) onClose();
2333
3221
  else if (char === "e") onEdit();
2334
3222
  else if (key.tab || char === "1" || char === "2" || char === "3") {
2335
- if (char === "1") setActiveTab("prompt");
2336
- else if (char === "2") setActiveTab("response");
2337
- else if (char === "3") setActiveTab("memory");
2338
- else setActiveTab((t) => {
2339
- if (t === "prompt") return "response";
2340
- if (t === "response") return "memory";
2341
- return "prompt";
2342
- });
3223
+ if (char === "1") switchTab("prompt");
3224
+ else if (char === "2") switchTab("response");
3225
+ else if (char === "3") switchTab("memory");
3226
+ else switchTab(activeTab === "prompt" ? "response" : activeTab === "response" ? "memory" : "prompt");
2343
3227
  } else if (char === "y") {
2344
- const copyText = activeTab === "memory" ? lines.join("\n") : text;
3228
+ const copyText = activeTab === "memory" ? lines.map((l) => l.segments.map((s) => s.text).join("")).join("\n") : text;
2345
3229
  copyToClipboard(copyText);
2346
3230
  setCopied(true);
2347
3231
  setTimeout(() => setCopied(false), 2e3);
@@ -2352,15 +3236,15 @@ var DetailView = ({ entry, onClose, onEdit, termWidth, termHeight, theme }) => {
2352
3236
  });
2353
3237
  const visibleLines = lines.slice(scrollOffset, scrollOffset + contentHeight);
2354
3238
  const remaining = lines.length - (scrollOffset + contentHeight);
2355
- 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 ", formatTimestamp2(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: activeTab === "prompt" }, activeTab === "prompt" ? "\u25CF" : "\u25CB", " 1:PROMPT"), /* @__PURE__ */ React6.createElement(Text5, null, " "), /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true, underline: activeTab === "response" }, activeTab === "response" ? "\u25CF" : "\u25CB", " 2:RESPONSE"), /* @__PURE__ */ React6.createElement(Text5, null, " "), /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true, underline: activeTab === "memory" }, activeTab === "memory" ? "\u25CF" : "\u25CB", " 3:MEMORY")), /* @__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")));
3239
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", paddingX: 2, paddingTop: 1, flexGrow: 1 }, /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "#", entry.id), /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, " \xB7 ", entry.tool, " \xB7 ", formatTimestamp2(entry.timestamp), " "), meta.starred && /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "\u2605")), /* @__PURE__ */ React7.createElement(Box5, null, meta.project && /* @__PURE__ */ React7.createElement(Text6, { color: theme.accent }, "proj:", meta.project, " "), meta.language && /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, "lang:", meta.language, " "), meta.role && /* @__PURE__ */ React7.createElement(Text6, { color: ROLE_COLOR2[meta.role] ?? theme.primary }, "role:", meta.role, " "), meta.quality !== void 0 && /* @__PURE__ */ React7.createElement(Text6, { color: theme.success }, "Q:", meta.quality, " "), meta.relevance !== void 0 && /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "R:", meta.relevance, " "), /* @__PURE__ */ React7.createElement(Text6, { color: entry.exit_code === 0 ? theme.dim : theme.error }, "exit:", entry.exit_code)), /* @__PURE__ */ React7.createElement(Box5, { height: 1 }), /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 0, justifyContent: "space-between" }, /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true, underline: activeTab === "prompt" }, activeTab === "prompt" ? "\u25CF" : "\u25CB", " 1:PROMPT"), /* @__PURE__ */ React7.createElement(Text6, null, " "), /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true, underline: activeTab === "response" }, activeTab === "response" ? "\u25CF" : "\u25CB", " 2:RESPONSE"), /* @__PURE__ */ React7.createElement(Text6, null, " "), /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true, underline: activeTab === "memory" }, activeTab === "memory" ? "\u25CF" : "\u25CB", " 3:MEMORY")), /* @__PURE__ */ React7.createElement(Box5, null, copied && /* @__PURE__ */ React7.createElement(Text6, { color: theme.success }, "Copied! "), /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "[y:copy e:edit ESC:back]"))), /* @__PURE__ */ React7.createElement(Box5, { height: 1 }, scrollOffset > 0 && /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, " \u2191 ", scrollOffset, " above")), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", flexGrow: 1 }, visibleLines.map((line, i) => /* @__PURE__ */ React7.createElement(Text6, { key: i }, line.segments.length > 0 ? line.segments.map((seg, j) => /* @__PURE__ */ React7.createElement(Text6, { key: j, color: seg.color, dimColor: seg.dim }, seg.text)) : " ")), Array.from({ length: Math.max(0, contentHeight - visibleLines.length) }).map((_, i) => /* @__PURE__ */ React7.createElement(Text6, { key: `pad-${i}` }, " "))), /* @__PURE__ */ React7.createElement(Box5, { height: 1 }, remaining > 0 && /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, " \u2193 ", remaining, " more")));
2356
3240
  };
2357
3241
  var EditView = ({ entry, onSave, onClose, theme }) => {
2358
- const initMeta = parseMeta2(entry.metadata);
2359
- const [field, setField] = useState2("title");
2360
- const [titleValue, setTitleValue] = useState2(initMeta.title ?? extractTopic(entry.prompt));
2361
- const [roleValue, setRoleValue] = useState2(initMeta.role ?? "");
2362
- const [tagsValue, setTagsValue] = useState2(initMeta.tags?.join(", ") ?? "");
2363
- useInput2((char, key) => {
3242
+ const initMeta = parseMetadata(entry.metadata);
3243
+ const [field, setField] = useState3("title");
3244
+ const [titleValue, setTitleValue] = useState3(initMeta.title ?? extractTopic(entry.prompt));
3245
+ const [roleValue, setRoleValue] = useState3(initMeta.role ?? "");
3246
+ const [tagsValue, setTagsValue] = useState3(initMeta.tags?.join(", ") ?? "");
3247
+ useInput3((char, key) => {
2364
3248
  if (key.escape) {
2365
3249
  onClose();
2366
3250
  return;
@@ -2392,15 +3276,15 @@ var EditView = ({ entry, onSave, onClose, theme }) => {
2392
3276
  }
2393
3277
  });
2394
3278
  const ROLES = "debug \xB7 refactor \xB7 explain \xB7 review \xB7 architect \xB7 test \xB7 docs \xB7 generate \xB7 research";
2395
- 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")));
3279
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "Edit Metadata \u2014 Prompt #", entry.id)), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", borderStyle: "single", borderColor: theme.dim, padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Title: "), /* @__PURE__ */ React7.createElement(Text6, { color: field === "title" ? theme.primary : "white", bold: field === "title" }, titleValue || "(none)", field === "title" ? "\u2588" : "")), /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Role: "), /* @__PURE__ */ React7.createElement(Text6, { color: field === "role" ? theme.primary : "white", bold: field === "role" }, roleValue || "(none)", field === "role" ? "\u2588" : "")), /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tags: "), /* @__PURE__ */ React7.createElement(Text6, { color: field === "tags" ? theme.primary : "white", bold: field === "tags" }, tagsValue || "(none)", field === "tags" ? "\u2588" : ""))), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Roles: ", ROLES)), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tab switch field \xB7 Enter save \xB7 ESC cancel")));
2396
3280
  };
2397
3281
  var RerunView = ({ entry, onConfirm, onClose, theme }) => {
2398
- const [field, setField] = useState2("prompt");
2399
- const [toolValue, setToolValue] = useState2(entry.tool);
2400
- const [promptValue, setPromptValue] = useState2(entry.prompt);
2401
- const meta = parseMeta2(entry.metadata);
3282
+ const [field, setField] = useState3("prompt");
3283
+ const [toolValue, setToolValue] = useState3(entry.tool);
3284
+ const [promptValue, setPromptValue] = useState3(entry.prompt);
3285
+ const meta = parseMetadata(entry.metadata);
2402
3286
  const git = meta.git_context;
2403
- useInput2((char, key) => {
3287
+ useInput3((char, key) => {
2404
3288
  if (key.escape) {
2405
3289
  onClose();
2406
3290
  return;
@@ -2420,137 +3304,36 @@ var RerunView = ({ entry, onConfirm, onClose, theme }) => {
2420
3304
  setter((v) => v + char);
2421
3305
  }
2422
3306
  });
2423
- 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, formatTimestamp2(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")));
3307
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "Rerun Prompt #", entry.id)), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tool: ", /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, entry.tool)), /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Date: ", /* @__PURE__ */ React7.createElement(Text6, null, formatTimestamp2(entry.timestamp))), meta.project && /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Project: ", /* @__PURE__ */ React7.createElement(Text6, { color: theme.accent }, meta.project)), git && /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Git: ", /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "Captured on branch: ", git.branch, ", ", git.files?.length || 0, " files modified"))), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", borderStyle: "single", borderColor: theme.dim, padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tool: "), /* @__PURE__ */ React7.createElement(Text6, { color: field === "tool" ? theme.primary : "white", bold: field === "tool" }, toolValue, field === "tool" ? "\u2588" : "")), /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column" }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Prompt: "), /* @__PURE__ */ React7.createElement(Box5, { borderStyle: "round", borderColor: field === "prompt" ? theme.primary : theme.dim, paddingX: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: field === "prompt" ? theme.primary : "white" }, promptValue, field === "prompt" ? "\u2588" : "")))), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Tab switch \xB7 Enter run \xB7 ESC cancel")));
2424
3308
  };
2425
- var FilterPanel = ({ allEntries, active, onUpdate, onClose, theme }) => {
2426
- const options = useMemo3(() => {
2427
- const opts = [];
2428
- for (const cat of FILTER_CATEGORIES) {
2429
- if (cat === "starred") {
2430
- const count = allEntries.filter((e) => {
2431
- try {
2432
- return JSON.parse(e.metadata).starred;
2433
- } catch {
2434
- return false;
2435
- }
2436
- }).length;
2437
- opts.push({ category: "starred", label: "\u2605 Only starred", count, active: !!active.starred });
2438
- } else if (cat === "quality") {
2439
- const qActive = active.minQuality !== void 0;
2440
- opts.push({ category: "quality", label: `Q \u2265 ${active.minQuality ?? "?"}`, count: 0, active: qActive });
2441
- for (const v of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
2442
- const c = allEntries.filter((e) => {
2443
- try {
2444
- return (JSON.parse(e.metadata).quality ?? 0) >= v;
2445
- } catch {
2446
- return false;
2447
- }
2448
- }).length;
2449
- opts.push({ category: "quality", label: `Q \u2265 ${v}`, count: c, active: active.minQuality === v });
2450
- }
2451
- } else if (cat === "relevance") {
2452
- const rActive = active.minRelevance !== void 0;
2453
- opts.push({ category: "relevance", label: `R \u2265 ${active.minRelevance ?? "?"}`, count: 0, active: rActive });
2454
- for (const v of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {
2455
- const c = allEntries.filter((e) => {
2456
- try {
2457
- return (JSON.parse(e.metadata).relevance ?? 0) >= v;
2458
- } catch {
2459
- return false;
2460
- }
2461
- }).length;
2462
- opts.push({ category: "relevance", label: `R \u2265 ${v}`, count: c, active: active.minRelevance === v });
2463
- }
2464
- } else {
2465
- const vals = getDistinctValues(allEntries, cat);
2466
- for (const v of vals) {
2467
- const c = allEntries.filter((e) => {
2468
- try {
2469
- const m = JSON.parse(e.metadata);
2470
- if (cat === "project") return m.project === v;
2471
- if (cat === "language") return m.language === v;
2472
- if (cat === "role") return m.role === v;
2473
- if (cat === "tool") return e.tool === v;
2474
- if (cat === "tag") return m.tags?.includes(v);
2475
- return false;
2476
- } catch {
2477
- return false;
2478
- }
2479
- }).length;
2480
- const currentVal = active[cat];
2481
- opts.push({ category: cat, label: v, count: c, active: currentVal === v });
2482
- }
2483
- }
2484
- }
2485
- return opts;
2486
- }, [allEntries, active]);
2487
- const [cursor, setCursor] = useState2(0);
2488
- const visibleCount = Math.min(options.length, 16);
2489
- const scrollOffset = Math.max(0, Math.min(cursor - Math.floor(visibleCount / 2), options.length - visibleCount));
2490
- const visible = options.slice(Math.max(0, scrollOffset), scrollOffset + visibleCount);
2491
- useInput2((char, key) => {
2492
- if (key.escape) {
3309
+ var ProviderPicker = ({ entry, onPick, onClose, theme }) => {
3310
+ const providers = getProviderSummaries(load());
3311
+ useInput3((char) => {
3312
+ if (char === "q" || char === "\x1B") {
2493
3313
  onClose();
2494
3314
  return;
2495
3315
  }
2496
- if (char === "c") {
2497
- onUpdate({});
2498
- return;
2499
- }
2500
- if (key.return || char === " ") {
2501
- const opt = options[cursor];
2502
- if (!opt) return;
2503
- if (opt.category === "starred") {
2504
- onUpdate({ ...active, starred: !opt.active || void 0 });
2505
- } else if (opt.category === "quality") {
2506
- const v = parseInt(opt.label.replace("Q \u2265 ", ""), 10);
2507
- onUpdate({ ...active, minQuality: opt.active ? void 0 : v });
2508
- } else if (opt.category === "relevance") {
2509
- const v = parseInt(opt.label.replace("R \u2265 ", ""), 10);
2510
- onUpdate({ ...active, minRelevance: opt.active ? void 0 : v });
2511
- } else {
2512
- const key2 = opt.category;
2513
- if (opt.active) {
2514
- const updated = { ...active };
2515
- delete updated[key2];
2516
- onUpdate(updated);
2517
- } else {
2518
- onUpdate({ ...active, [key2]: opt.label });
2519
- }
2520
- }
2521
- return;
2522
- }
2523
- if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
2524
- if (key.downArrow) setCursor((c) => Math.min(options.length - 1, c + 1));
2525
- if (key.pageUp) setCursor((c) => Math.max(0, c - visibleCount));
2526
- if (key.pageDown) setCursor((c) => Math.min(options.length - 1, c + visibleCount));
2527
- if (char && /^[a-z]$/.test(char)) {
2528
- const idx = options.findIndex((o, i) => i > cursor && o.category[0] === char);
2529
- if (idx !== -1) setCursor(idx);
3316
+ const index = Number(char) - 1;
3317
+ const provider = providers[index];
3318
+ if (provider?.ready) {
3319
+ onPick(`wise:${provider.id}`, entry.prompt);
2530
3320
  }
2531
3321
  });
2532
- const activeCount = Object.values(active).filter((v) => v !== void 0 && v !== false).length;
2533
- return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true }, "Filters "), activeCount > 0 ? /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, activeCount, " active ") : /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "none active "), activeCount > 0 && /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "(c clear)")), /* @__PURE__ */ React6.createElement(Box4, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1, minHeight: 18 }, /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column" }, visible.map((opt, i) => {
2534
- const absIdx = Math.max(0, scrollOffset) + i;
2535
- const isCur = absIdx === cursor;
2536
- const catColor = opt.category === "project" ? "blue" : opt.category === "language" ? "green" : opt.category === "role" ? ROLE_COLOR4[opt.label] || "cyan" : opt.category === "tool" ? "yellow" : opt.category === "tag" ? "cyan" : "white";
2537
- return /* @__PURE__ */ React6.createElement(Box4, { key: `${opt.category}-${opt.label}` }, /* @__PURE__ */ React6.createElement(Text5, { bold: isCur, color: isCur ? theme.primary : theme.dim }, isCur ? "\u276F " : " "), /* @__PURE__ */ React6.createElement(Text5, { color: opt.active ? theme.warning : catColor, bold: opt.active || isCur }, opt.category, ":", opt.label), /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, " [", opt.count, "]"), opt.active && /* @__PURE__ */ React6.createElement(Text5, { color: theme.success }, " \u2713"));
2538
- })), options.length > visibleCount && /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, " ", cursor + 1, "/", options.length, " \xB7 \u2191\u2193 navigate \xB7 Enter toggle \xB7 c clear \xB7 ESC close"))), /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "\u2191\u2193 navigate \xB7 Enter toggle \xB7 c clear all \xB7 ESC close \xB7 letter jumps to category")));
3322
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 2 }, /* @__PURE__ */ React7.createElement(Text6, { bold: true, color: theme.primary }, "Run prompt #", entry.id, " with an analysis provider:"), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1, flexDirection: "column" }, providers.map((provider, index) => /* @__PURE__ */ React7.createElement(Text6, { key: provider.id, color: provider.ready ? "white" : theme.dim }, /* @__PURE__ */ React7.createElement(Text6, { color: provider.ready ? theme.warning : theme.dim }, provider.ready ? index + 1 : " "), /* @__PURE__ */ React7.createElement(Text6, null, ": ", formatProviderSummary(provider).trim())))), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "Pick a configured provider \xB7 q to cancel \xB7 r reruns with the original CLI tool")));
2539
3323
  };
2540
3324
  var SETTING_FIELDS = [
2541
3325
  { key: "backgroundAnalysis", label: "Auto-analysis on capture", type: "boolean" },
2542
3326
  { key: "ollamaUrl", label: "Ollama URL", type: "string" },
2543
3327
  { key: "ollamaModel", label: "Ollama model (analysis)", type: "string" },
2544
3328
  { key: "ollamaEmbedModel", label: "Ollama model (embeddings)", type: "string" },
2545
- { key: "analyzeProvider", label: "Analysis provider", type: "string" },
2546
3329
  { key: "filterMinLength", label: "Min prompt length filter", type: "number" },
2547
3330
  { key: "filterMinRelevance", label: "Min relevance filter", type: "number" }
2548
3331
  ];
2549
3332
  var SettingsView = ({ onClose, theme }) => {
2550
- const [cfg, setCfg] = useState2(() => load());
2551
- const [cursor, setCursor] = useState2(0);
2552
- const [dirty, setDirty] = useState2(false);
2553
- const [saved, setSaved] = useState2(false);
3333
+ const [cfg, setCfg] = useState3(() => load());
3334
+ const [cursor, setCursor] = useState3(0);
3335
+ const [dirty, setDirty] = useState3(false);
3336
+ const [saved, setSaved] = useState3(false);
2554
3337
  const toggleField = (key) => {
2555
3338
  const updated = { ...cfg };
2556
3339
  if (key === "backgroundAnalysis") {
@@ -2566,7 +3349,7 @@ var SettingsView = ({ onClose, theme }) => {
2566
3349
  setSaved(true);
2567
3350
  setTimeout(() => setSaved(false), 2e3);
2568
3351
  };
2569
- useInput2((char, key) => {
3352
+ useInput3((char, key) => {
2570
3353
  if (key.escape) {
2571
3354
  onClose();
2572
3355
  return;
@@ -2589,7 +3372,7 @@ var SettingsView = ({ onClose, theme }) => {
2589
3372
  }
2590
3373
  }
2591
3374
  });
2592
- return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React6.createElement(Box4, { marginBottom: 1 }, /* @__PURE__ */ React6.createElement(Text5, { color: theme.primary, bold: true }, "Settings "), dirty && /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, "modified "), saved && /* @__PURE__ */ React6.createElement(Text5, { color: theme.success }, "\u2713 saved")), /* @__PURE__ */ React6.createElement(Box4, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1 }, SETTING_FIELDS.map((field, i) => {
3375
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Box5, { marginBottom: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.primary, bold: true }, "Settings "), dirty && /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, "modified "), saved && /* @__PURE__ */ React7.createElement(Text6, { color: theme.success }, "\u2713 saved")), /* @__PURE__ */ React7.createElement(Box5, { borderStyle: "single", borderColor: theme.dim, flexDirection: "column", padding: 1 }, /* @__PURE__ */ React7.createElement(Text6, { bold: true, color: theme.primary }, "Analysis providers (* = active; edit with `ph config set`)"), getProviderSummaries(cfg).map((provider) => /* @__PURE__ */ React7.createElement(Text6, { key: provider.id, color: provider.active ? theme.success : theme.dim }, formatProviderSummary(provider))), /* @__PURE__ */ React7.createElement(Text6, null, " "), SETTING_FIELDS.map((field, i) => {
2593
3376
  const isCur = i === cursor;
2594
3377
  const val = cfg[field.key];
2595
3378
  let display;
@@ -2599,25 +3382,27 @@ var SettingsView = ({ onClose, theme }) => {
2599
3382
  display = val !== void 0 ? String(val) : "(default)";
2600
3383
  }
2601
3384
  const valColor = field.type === "boolean" ? val ? theme.success : theme.dim : val !== void 0 ? "white" : theme.dim;
2602
- return /* @__PURE__ */ React6.createElement(Box4, { key: field.key }, /* @__PURE__ */ React6.createElement(Text5, { bold: isCur, color: isCur ? theme.primary : theme.dim }, isCur ? "\u276F " : " "), /* @__PURE__ */ React6.createElement(Text5, { color: isCur ? theme.primary : "white" }, field.label), /* @__PURE__ */ React6.createElement(Text5, null, " ".repeat(Math.max(1, 30 - field.label.length))), /* @__PURE__ */ React6.createElement(Text5, { color: valColor, bold: field.type === "boolean" && isCur }, display), field.type === "boolean" && isCur && /* @__PURE__ */ React6.createElement(Text5, { color: theme.warning }, " [Enter toggle]"));
2603
- })), /* @__PURE__ */ React6.createElement(Box4, { marginTop: 1 }, /* @__PURE__ */ React6.createElement(Text5, { dimColor: true }, "\u2191\u2193 navigate \xB7 Enter toggle boolean \xB7 ", dirty ? "s save \xB7 " : "", "q/ESC close")));
3385
+ return /* @__PURE__ */ React7.createElement(Box5, { key: field.key }, /* @__PURE__ */ React7.createElement(Text6, { bold: isCur, color: isCur ? theme.primary : theme.dim }, isCur ? "\u276F " : " "), /* @__PURE__ */ React7.createElement(Text6, { color: isCur ? theme.primary : "white" }, field.label), /* @__PURE__ */ React7.createElement(Text6, null, " ".repeat(Math.max(1, 30 - field.label.length))), /* @__PURE__ */ React7.createElement(Text6, { color: valColor, bold: field.type === "boolean" && isCur }, display), field.type === "boolean" && isCur && /* @__PURE__ */ React7.createElement(Text6, { color: theme.warning }, " [Enter toggle]"));
3386
+ })), /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "\u2191\u2193 navigate \xB7 Enter toggle boolean \xB7 ", dirty ? "s save \xB7 " : "", "q/ESC close")));
2604
3387
  };
2605
3388
  var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2606
3389
  const { exit } = useApp();
2607
- const [allEntries, setAllEntries] = useState2(() => db.search({ limit: 1e3 }));
2608
- const [refreshKey, setRefreshKey] = useState2(0);
2609
- const [textFilter, setTextFilter] = useState2(initialTextFilter ?? "");
2610
- const [isTextFiltering, setTextFiltering] = useState2(false);
2611
- const [activeFilters, setActiveFilters] = useState2(initialFilters ?? {});
2612
- const [showFilterPanel, setFilterPanel] = useState2(false);
2613
- const [showSettings, setShowSettings] = useState2(false);
2614
- const [cursor, setCursor] = useState2(0);
2615
- const [detail, setDetail] = useState2(null);
2616
- const [editing, setEditing] = useState2(null);
2617
- const [rerunning, setRerunning] = useState2(null);
2618
- const [copiedId, setCopiedId] = useState2(null);
2619
- const [focusPane, setFocusPane] = useState2("list");
2620
- const [scrollOffset, setScrollOffset] = useState2(0);
3390
+ const [allEntries, setAllEntries] = useState3(() => db.search({ limit: 1e3 }));
3391
+ const [refreshKey, setRefreshKey] = useState3(0);
3392
+ const [archiveCount] = useState3(() => db.getArchiveStats().total);
3393
+ const [textFilter, setTextFilter] = useState3(initialTextFilter ?? "");
3394
+ const [isTextFiltering, setTextFiltering] = useState3(false);
3395
+ const [activeFilters, setActiveFilters] = useState3(initialFilters ?? {});
3396
+ const [showFilterPanel, setFilterPanel] = useState3(false);
3397
+ const [showSettings, setShowSettings] = useState3(false);
3398
+ const [cursor, setCursor] = useState3(0);
3399
+ const [detail, setDetail] = useState3(null);
3400
+ const [editing, setEditing] = useState3(null);
3401
+ const [rerunning, setRerunning] = useState3(null);
3402
+ const [providerPicking, setProviderPicking] = useState3(null);
3403
+ const [copiedId, setCopiedId] = useState3(null);
3404
+ const [focusPane, setFocusPane] = useState3("list");
3405
+ const [scrollOffset, setScrollOffset] = useState3(0);
2621
3406
  const currentThemeName = "dark";
2622
3407
  const theme = THEMES3[currentThemeName] || THEMES3.dark;
2623
3408
  const { columns: termWidth, rows: termHeight } = useStdoutDimensions();
@@ -2625,15 +3410,15 @@ var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2625
3410
  const isWide = termWidth >= SPLIT_THRESHOLD;
2626
3411
  const leftPaneWidth = isWide ? Math.max(32, Math.min(50, Math.floor(termWidth * 0.33))) : termWidth;
2627
3412
  const PAGE_SIZE = Math.max(1, Math.floor((termHeight - 3) / 4));
2628
- const entries = useMemo3(
3413
+ const entries = useMemo4(
2629
3414
  () => applyFilters(allEntries, activeFilters, textFilter),
2630
3415
  // eslint-disable-next-line react-hooks/exhaustive-deps
2631
3416
  [allEntries, activeFilters, textFilter, refreshKey]
2632
3417
  );
2633
- useEffect2(() => {
3418
+ useEffect(() => {
2634
3419
  setCursor((c) => Math.min(c, Math.max(0, entries.length - 1)));
2635
3420
  }, [entries.length]);
2636
- useEffect2(() => {
3421
+ useEffect(() => {
2637
3422
  setScrollOffset((prev) => {
2638
3423
  const clamped = Math.max(0, Math.min(prev, Math.max(0, entries.length - PAGE_SIZE)));
2639
3424
  if (cursor < clamped) return cursor;
@@ -2645,7 +3430,7 @@ var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2645
3430
  const visible = entries.slice(pageStart, pageStart + PAGE_SIZE);
2646
3431
  const triggerRefresh = useCallback(() => setRefreshKey((k) => k + 1), []);
2647
3432
  const toggleStar = useCallback((entry) => {
2648
- const meta = parseMeta2(entry.metadata);
3433
+ const meta = parseMetadata(entry.metadata);
2649
3434
  meta.starred = !meta.starred;
2650
3435
  const newMeta = JSON.stringify(meta);
2651
3436
  db.updateMetadata(entry.id, newMeta);
@@ -2668,8 +3453,8 @@ var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2668
3453
  setCursor(0);
2669
3454
  setScrollOffset(0);
2670
3455
  }, []);
2671
- useInput2((char, key) => {
2672
- if (detail || editing || showFilterPanel || showSettings || rerunning) return;
3456
+ useInput3((char, key) => {
3457
+ if (detail || editing || showFilterPanel || showSettings || rerunning || providerPicking) return;
2673
3458
  if (isTextFiltering) {
2674
3459
  if (key.escape) {
2675
3460
  setTextFiltering(false);
@@ -2700,6 +3485,8 @@ var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2700
3485
  if (entries[cursor]) setEditing(entries[cursor]);
2701
3486
  } else if (char === "r") {
2702
3487
  if (entries[cursor]) setRerunning(entries[cursor]);
3488
+ } else if (char === "p") {
3489
+ if (entries[cursor]) setProviderPicking(entries[cursor]);
2703
3490
  } else if (char === "x") {
2704
3491
  if (entries[cursor]) handleDelete(entries[cursor]);
2705
3492
  } else if (char === "f") {
@@ -2714,7 +3501,7 @@ var BrowseApp = ({ db, initialTextFilter, initialFilters, onRerun }) => {
2714
3501
  } else if (char === "C") {
2715
3502
  const entry = entries[cursor];
2716
3503
  if (!entry) return;
2717
- const meta = parseMeta2(entry.metadata);
3504
+ const meta = parseMetadata(entry.metadata);
2718
3505
  const project = meta.project;
2719
3506
  if (!project) return;
2720
3507
  const memories = db.searchMemories(project, 3);
@@ -2738,7 +3525,7 @@ ${mem.summary}`);
2738
3525
  if (recent.length > 0) {
2739
3526
  ctx.push("\n---\n## Recent Context");
2740
3527
  for (const p of recent) {
2741
- const pm = parseMeta2(p.metadata);
3528
+ const pm = parseMetadata(p.metadata);
2742
3529
  ctx.push(`
2743
3530
  - #${p.id}: ${pm.summary || p.prompt.slice(0, 100).replace(/\n/g, " ")}`);
2744
3531
  }
@@ -2784,7 +3571,7 @@ ${entry.prompt}`;
2784
3571
  });
2785
3572
  let mainContent;
2786
3573
  if (showSettings) {
2787
- mainContent = /* @__PURE__ */ React6.createElement(
3574
+ mainContent = /* @__PURE__ */ React7.createElement(
2788
3575
  SettingsView,
2789
3576
  {
2790
3577
  onClose: () => setShowSettings(false),
@@ -2792,7 +3579,7 @@ ${entry.prompt}`;
2792
3579
  }
2793
3580
  );
2794
3581
  } else if (showFilterPanel) {
2795
- mainContent = /* @__PURE__ */ React6.createElement(
3582
+ mainContent = /* @__PURE__ */ React7.createElement(
2796
3583
  FilterPanel,
2797
3584
  {
2798
3585
  allEntries,
@@ -2802,8 +3589,22 @@ ${entry.prompt}`;
2802
3589
  theme
2803
3590
  }
2804
3591
  );
3592
+ } else if (providerPicking) {
3593
+ mainContent = /* @__PURE__ */ React7.createElement(
3594
+ ProviderPicker,
3595
+ {
3596
+ entry: providerPicking,
3597
+ onPick: (tool, prompt) => {
3598
+ onRerun?.(tool, prompt);
3599
+ setProviderPicking(null);
3600
+ exit();
3601
+ },
3602
+ onClose: () => setProviderPicking(null),
3603
+ theme
3604
+ }
3605
+ );
2805
3606
  } else if (rerunning) {
2806
- mainContent = /* @__PURE__ */ React6.createElement(
3607
+ mainContent = /* @__PURE__ */ React7.createElement(
2807
3608
  RerunView,
2808
3609
  {
2809
3610
  entry: rerunning,
@@ -2817,7 +3618,7 @@ ${entry.prompt}`;
2817
3618
  }
2818
3619
  );
2819
3620
  } else if (editing) {
2820
- mainContent = /* @__PURE__ */ React6.createElement(
3621
+ mainContent = /* @__PURE__ */ React7.createElement(
2821
3622
  EditView,
2822
3623
  {
2823
3624
  entry: editing,
@@ -2827,7 +3628,7 @@ ${entry.prompt}`;
2827
3628
  }
2828
3629
  );
2829
3630
  } else if (detail && !isWide) {
2830
- mainContent = /* @__PURE__ */ React6.createElement(
3631
+ mainContent = /* @__PURE__ */ React7.createElement(
2831
3632
  DetailView,
2832
3633
  {
2833
3634
  entry: detail,
@@ -2844,10 +3645,10 @@ ${entry.prompt}`;
2844
3645
  } else if (isWide) {
2845
3646
  const listFocused = focusPane === "list";
2846
3647
  const previewFocused = focusPane === "preview";
2847
- 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) => {
3648
+ mainContent = /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "row", flexGrow: 1 }, /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", width: leftPaneWidth }, /* @__PURE__ */ React7.createElement(Box5, { paddingX: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: listFocused ? theme.primary : theme.dim, bold: listFocused }, listFocused ? "\u276F " : " ", "HISTORY"), entries.length > 0 && /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, " ", cursor + 1, "/", entries.length)), entries.length === 0 ? /* @__PURE__ */ React7.createElement(Box5, { paddingX: 1 }, /* @__PURE__ */ React7.createElement(Text6, { dimColor: true }, "(no results)")) : visible.map((entry, i) => {
2848
3649
  const absIdx = pageStart + i;
2849
3650
  const isNewSession = needsSessionSeparator(entries, absIdx);
2850
- return /* @__PURE__ */ React6.createElement(React6.Fragment, { key: entry.id }, isNewSession && /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "\u254C".repeat(2), " ", formatDateLabel(entry.timestamp), " ", "\u254C".repeat(leftPaneWidth - 4 - formatDateLabel(entry.timestamp).length))), /* @__PURE__ */ React6.createElement(
3651
+ return /* @__PURE__ */ React7.createElement(React7.Fragment, { key: entry.id }, isNewSession && /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, "\u254C".repeat(2), " ", formatDateLabel(entry.timestamp), " ", "\u254C".repeat(leftPaneWidth - 4 - formatDateLabel(entry.timestamp).length))), /* @__PURE__ */ React7.createElement(
2851
3652
  ListEntry,
2852
3653
  {
2853
3654
  entry,
@@ -2855,9 +3656,9 @@ ${entry.prompt}`;
2855
3656
  paneWidth: leftPaneWidth,
2856
3657
  theme
2857
3658
  }
2858
- ), i < visible.length - 1 && !needsSessionSeparator(entries, absIdx + 1) && /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "\xB7".repeat(Math.max(1, leftPaneWidth - 2)))));
2859
- })), /* @__PURE__ */ React6.createElement(
2860
- Box4,
3659
+ ), i < visible.length - 1 && !needsSessionSeparator(entries, absIdx + 1) && /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, "\xB7".repeat(Math.max(1, leftPaneWidth - 2)))));
3660
+ })), /* @__PURE__ */ React7.createElement(
3661
+ Box5,
2861
3662
  {
2862
3663
  borderLeft: true,
2863
3664
  borderStyle: "single",
@@ -2865,9 +3666,10 @@ ${entry.prompt}`;
2865
3666
  flexGrow: 1,
2866
3667
  flexDirection: "column"
2867
3668
  },
2868
- /* @__PURE__ */ React6.createElement(
3669
+ /* @__PURE__ */ React7.createElement(
2869
3670
  PreviewPane,
2870
3671
  {
3672
+ key: entries[cursor]?.id ?? "empty",
2871
3673
  entry: entries[cursor] ?? null,
2872
3674
  paneWidth: termWidth - leftPaneWidth - 1,
2873
3675
  paneHeight: termHeight - 3,
@@ -2878,10 +3680,10 @@ ${entry.prompt}`;
2878
3680
  )
2879
3681
  ));
2880
3682
  } else {
2881
- 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) => {
3683
+ mainContent = /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", paddingX: 1, flexGrow: 1 }, entries.length === 0 ? /* @__PURE__ */ React7.createElement(Box5, { marginTop: 1 }, /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, "(no results)")) : visible.map((entry, i) => {
2882
3684
  const absIdx = pageStart + i;
2883
3685
  const isNewSession = needsSessionSeparator(entries, absIdx);
2884
- return /* @__PURE__ */ React6.createElement(React6.Fragment, { key: entry.id }, isNewSession && /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "\u254C".repeat(2), " ", formatDateLabel(entry.timestamp), " ", "\u254C".repeat(termWidth - 4 - formatDateLabel(entry.timestamp).length))), /* @__PURE__ */ React6.createElement(
3686
+ return /* @__PURE__ */ React7.createElement(React7.Fragment, { key: entry.id }, isNewSession && /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, "\u254C".repeat(2), " ", formatDateLabel(entry.timestamp), " ", "\u254C".repeat(termWidth - 4 - formatDateLabel(entry.timestamp).length))), /* @__PURE__ */ React7.createElement(
2885
3687
  ListEntry,
2886
3688
  {
2887
3689
  entry,
@@ -2889,20 +3691,21 @@ ${entry.prompt}`;
2889
3691
  paneWidth: termWidth,
2890
3692
  theme
2891
3693
  }
2892
- ), i < visible.length - 1 && !needsSessionSeparator(entries, absIdx + 1) && /* @__PURE__ */ React6.createElement(Box4, null, /* @__PURE__ */ React6.createElement(Text5, { color: theme.dim }, "\xB7".repeat(Math.max(1, termWidth - 2)))));
3694
+ ), i < visible.length - 1 && !needsSessionSeparator(entries, absIdx + 1) && /* @__PURE__ */ React7.createElement(Box5, null, /* @__PURE__ */ React7.createElement(Text6, { color: theme.dim }, "\xB7".repeat(Math.max(1, termWidth - 2)))));
2893
3695
  }));
2894
3696
  }
2895
- return /* @__PURE__ */ React6.createElement(Box4, { flexDirection: "column", height: termHeight }, /* @__PURE__ */ React6.createElement(
3697
+ return /* @__PURE__ */ React7.createElement(Box5, { flexDirection: "column", height: termHeight }, /* @__PURE__ */ React7.createElement(
2896
3698
  Header,
2897
3699
  {
2898
3700
  entriesCount: entries.length,
2899
3701
  allEntriesCount: allEntries.length,
3702
+ archiveCount,
2900
3703
  activeFilters,
2901
3704
  textFilter,
2902
3705
  isTextFiltering: false,
2903
3706
  theme: currentThemeName
2904
3707
  }
2905
- ), /* @__PURE__ */ React6.createElement(SearchBar, { value: textFilter, isActive: isTextFiltering, theme }), /* @__PURE__ */ React6.createElement(Box4, { flexGrow: 1 }, mainContent), /* @__PURE__ */ React6.createElement(
3708
+ ), /* @__PURE__ */ React7.createElement(SearchBar, { value: textFilter, isActive: isTextFiltering, theme }), /* @__PURE__ */ React7.createElement(Box5, { flexGrow: 1 }, mainContent), /* @__PURE__ */ React7.createElement(
2906
3709
  Footer,
2907
3710
  {
2908
3711
  cursor,
@@ -3028,6 +3831,32 @@ function parseDate(s, label) {
3028
3831
  async function cmdSearch(db, cfg, args) {
3029
3832
  const { flags, positional } = parseFlags(args);
3030
3833
  const query = positional.join(" ");
3834
+ if (flags["archive"]) {
3835
+ const archiveResults = db.searchArchive({
3836
+ since: flags["since"] ? new Date(flags["since"]).toISOString() : void 0,
3837
+ until: flags["until"] ? new Date(flags["until"]).toISOString() : void 0,
3838
+ limit: Number(flags["limit"] ?? 50)
3839
+ });
3840
+ if (archiveResults.length === 0) {
3841
+ console.log("No archived prompts found.");
3842
+ return;
3843
+ }
3844
+ console.log(`Found ${archiveResults.length} archived prompts:
3845
+ `);
3846
+ for (const r of archiveResults) {
3847
+ const date = new Date(r.timestamp).toLocaleString();
3848
+ const id = r.original_id ?? r.id;
3849
+ const prompt = r.prompt.replace(/\n/g, " ").slice(0, 120);
3850
+ console.log(` #${id} [${r.tool}] (${date})`);
3851
+ console.log(` ${prompt}${r.prompt.length > 120 ? "..." : ""}`);
3852
+ if (r.response) {
3853
+ const resp = r.response.replace(/\n/g, " ").slice(0, 80);
3854
+ console.log(` Response: ${resp}${r.response.length > 80 ? "..." : ""}`);
3855
+ }
3856
+ console.log();
3857
+ }
3858
+ return;
3859
+ }
3031
3860
  if (flags["semantic"] && query) {
3032
3861
  await cmdSearchSemantic(db, cfg, query, Number(flags["limit"] ?? 50), Boolean(flags["full"]));
3033
3862
  return;
@@ -3207,8 +4036,8 @@ ${meta.git_context.diff}
3207
4036
  }
3208
4037
 
3209
4038
  // src/commands/import.ts
3210
- import path7 from "path";
3211
- import os7 from "os";
4039
+ import path8 from "path";
4040
+ import os8 from "os";
3212
4041
 
3213
4042
  // src/importer/gemini.ts
3214
4043
  import fs5 from "fs";
@@ -3216,6 +4045,7 @@ import path4 from "path";
3216
4045
  import os4 from "os";
3217
4046
 
3218
4047
  // src/analyzer/index.ts
4048
+ import { createHash as createHash3 } from "crypto";
3219
4049
  var ANALYSIS_PROMPT = (text) => `Analyze this AI tool prompt and response and return ONLY a JSON object with these exact fields:
3220
4050
  {
3221
4051
  "project": "<project name if clearly identifiable, else empty string>",
@@ -3325,7 +4155,7 @@ function mergeMetadata(existing, result, force) {
3325
4155
  if (!merged.tags?.length) delete merged.tags;
3326
4156
  return merged;
3327
4157
  }
3328
- function parseMeta3(raw) {
4158
+ function parseMeta2(raw) {
3329
4159
  try {
3330
4160
  return JSON.parse(raw);
3331
4161
  } catch {
@@ -3333,20 +4163,28 @@ function parseMeta3(raw) {
3333
4163
  }
3334
4164
  }
3335
4165
  function hasMetadata(entry) {
3336
- const meta = parseMeta3(entry.metadata);
4166
+ const meta = parseMeta2(entry.metadata);
3337
4167
  return Boolean(meta.role || meta.tags?.length || meta.project || meta.language);
3338
4168
  }
4169
+ function gitFingerprint(ctx) {
4170
+ const hash = createHash3("sha256");
4171
+ hash.update(ctx.branch);
4172
+ hash.update(ctx.diff);
4173
+ return `git:${hash.digest("hex").slice(0, 14)}`;
4174
+ }
3339
4175
  async function analyzeAll(entries, provider, db, opts = {}) {
3340
4176
  const { force = false, pruneBelow = 0, dryRun = false, onProgress, onPrune } = opts;
3341
4177
  const toProcess = force ? entries : entries.filter((e) => !hasMetadata(e));
3342
4178
  const stats = { updated: 0, skipped: entries.length - toProcess.length, failed: 0, pruned: 0 };
4179
+ const gitCtx = captureGitContext(process.cwd());
4180
+ const gitFp = gitCtx ? gitFingerprint(gitCtx) : void 0;
3343
4181
  for (let i = 0; i < toProcess.length; i++) {
3344
4182
  const entry = toProcess[i];
3345
4183
  let result = null;
3346
4184
  let errMsg;
3347
4185
  try {
3348
4186
  result = await analyzePrompt(entry.prompt, provider);
3349
- const existing = parseMeta3(entry.metadata);
4187
+ const existing = parseMeta2(entry.metadata);
3350
4188
  const merged = mergeMetadata(existing, result, force);
3351
4189
  if (pruneBelow > 0 && result.relevance !== void 0 && result.relevance < pruneBelow) {
3352
4190
  onPrune?.(entry, result.relevance);
@@ -3357,11 +4195,25 @@ async function analyzeAll(entries, provider, db, opts = {}) {
3357
4195
  } else {
3358
4196
  db.updateMetadata(entry.id, JSON.stringify(merged));
3359
4197
  if (result.project && result.summary) {
4198
+ if (gitFp) {
4199
+ const lastMem = db.getLastMemoryForProject(result.project);
4200
+ if (lastMem?.git_context_snapshot === gitFp) {
4201
+ stats.updated++;
4202
+ continue;
4203
+ }
4204
+ }
3360
4205
  db.upsertProjectMemory({
3361
4206
  project: result.project,
3362
4207
  prompt_id: entry.id,
3363
4208
  summary: result.summary,
3364
4209
  key_insights: result.key_insights ?? [],
4210
+ technical_decisions: result.technical_decisions ?? [],
4211
+ git_context_snapshot: gitFp
4212
+ });
4213
+ db.upsertProjectSummary({
4214
+ project: result.project,
4215
+ summary: result.summary,
4216
+ key_insights: result.key_insights ?? [],
3365
4217
  technical_decisions: result.technical_decisions ?? []
3366
4218
  });
3367
4219
  }
@@ -3501,7 +4353,7 @@ async function importGeminiHistory(db, geminiDir, dryRun, analyzer, onProgress,
3501
4353
  if (response.length > 8e3) response = response.slice(0, 8e3) + "\n... (truncated)";
3502
4354
  }
3503
4355
  try {
3504
- const id = db.insert({
4356
+ const id = db.insert(createCaptureRecord({
3505
4357
  timestamp: new Date(msg.timestamp).toISOString(),
3506
4358
  tool: "gemini",
3507
4359
  prompt: content,
@@ -3510,8 +4362,8 @@ async function importGeminiHistory(db, geminiDir, dryRun, analyzer, onProgress,
3510
4362
  workdir: inferWorkDir(filePath, geminiDir),
3511
4363
  hostname,
3512
4364
  exit_code: 0,
3513
- metadata
3514
- });
4365
+ metadata: JSON.parse(metadata)
4366
+ }));
3515
4367
  filter?.registerHash(content, id);
3516
4368
  result.promptsImported++;
3517
4369
  } catch (e) {
@@ -3669,7 +4521,7 @@ async function importClaudeHistory(db, claudeDir, dryRun, analyzer, onProgress,
3669
4521
  } catch {
3670
4522
  }
3671
4523
  }
3672
- const id = db.insert({
4524
+ const id = db.insert(createCaptureRecord({
3673
4525
  timestamp: entry.timestamp,
3674
4526
  tool: "claude",
3675
4527
  prompt: entry.prompt,
@@ -3678,8 +4530,8 @@ async function importClaudeHistory(db, claudeDir, dryRun, analyzer, onProgress,
3678
4530
  workdir: entry.workdir,
3679
4531
  hostname,
3680
4532
  exit_code: 0,
3681
- metadata
3682
- });
4533
+ metadata: JSON.parse(metadata)
4534
+ }));
3683
4535
  filter?.registerHash(entry.prompt, id);
3684
4536
  result.promptsImported++;
3685
4537
  } catch (e) {
@@ -3852,7 +4704,7 @@ async function importOpenCodeHistory(db, opencodeDir, dryRun, analyzer, onProgre
3852
4704
  metadata.tokens = entry.tokens;
3853
4705
  }
3854
4706
  }
3855
- const id = db.insert({
4707
+ const id = db.insert(createCaptureRecord({
3856
4708
  timestamp: entry.timestamp,
3857
4709
  tool: "opencode",
3858
4710
  prompt: entry.prompt,
@@ -3861,8 +4713,8 @@ async function importOpenCodeHistory(db, opencodeDir, dryRun, analyzer, onProgre
3861
4713
  workdir: entry.workdir,
3862
4714
  hostname,
3863
4715
  exit_code: 0,
3864
- metadata: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : "{}"
3865
- });
4716
+ metadata
4717
+ }));
3866
4718
  filter?.registerHash(entry.prompt, id);
3867
4719
  result.promptsImported++;
3868
4720
  } catch (e) {
@@ -3873,90 +4725,16 @@ async function importOpenCodeHistory(db, opencodeDir, dryRun, analyzer, onProgre
3873
4725
  return result;
3874
4726
  }
3875
4727
 
3876
- // src/ai/ollama.ts
3877
- var OllamaProvider = class {
3878
- constructor(url, model) {
3879
- this.url = url;
3880
- this.model = model;
3881
- }
3882
- name = "ollama";
3883
- async generate(prompt) {
3884
- const controller = new AbortController();
3885
- const timer = setTimeout(() => controller.abort(), 6e4);
3886
- let res;
3887
- try {
3888
- res = await fetch(`${this.url}/api/generate`, {
3889
- method: "POST",
3890
- headers: { "Content-Type": "application/json" },
3891
- body: JSON.stringify({ model: this.model, prompt, stream: false }),
3892
- signal: controller.signal
3893
- });
3894
- } catch (err) {
3895
- clearTimeout(timer);
3896
- const msg = err.message ?? String(err);
3897
- if (msg.includes("abort") || msg.includes("timeout")) {
3898
- throw new Error(`Ollama request timed out (60s). Is Ollama running at ${this.url}?`);
3899
- }
3900
- throw new Error(`Ollama unreachable at ${this.url}: ${msg}`);
3901
- } finally {
3902
- clearTimeout(timer);
3903
- }
3904
- if (!res.ok) {
3905
- const body = await res.text().catch(() => "");
3906
- throw new Error(`Ollama error ${res.status}: ${body}`);
3907
- }
3908
- const data = await res.json();
3909
- return data.response ?? "";
3910
- }
3911
- };
3912
-
3913
- // src/ai/gemini.ts
3914
- var GENERATE_API = "https://generativelanguage.googleapis.com/v1beta/models";
3915
- async function generateContent(prompt, model, apiKey) {
3916
- const res = await fetch(`${GENERATE_API}/${model}:generateContent?key=${apiKey}`, {
3917
- method: "POST",
3918
- headers: { "Content-Type": "application/json" },
3919
- body: JSON.stringify({
3920
- contents: [{ parts: [{ text: prompt }] }]
3921
- })
3922
- });
3923
- if (!res.ok) {
3924
- const err = await res.text();
3925
- throw new Error(`Gemini API error ${res.status}: ${err}`);
3926
- }
3927
- const data = await res.json();
3928
- return data.candidates[0]?.content?.parts[0]?.text ?? "";
3929
- }
3930
- var GeminiProvider = class {
3931
- constructor(model, apiKey) {
3932
- this.model = model;
3933
- this.apiKey = apiKey;
3934
- }
3935
- name = "gemini";
3936
- generate(prompt) {
3937
- return generateContent(prompt, this.model, this.apiKey);
3938
- }
3939
- };
3940
-
3941
- // src/ai/provider.ts
3942
- function getProvider(cfg) {
3943
- const provider = cfg.analyzeProvider ?? "ollama";
3944
- if (provider === "gemini") {
3945
- const apiKey = process.env.GEMINI_API_KEY ?? cfg.geminiApiKey;
3946
- if (!apiKey) return null;
3947
- return new GeminiProvider("gemini-2.5-flash", apiKey);
3948
- }
3949
- return new OllamaProvider(
3950
- cfg.ollamaUrl ?? "http://localhost:11434",
3951
- cfg.ollamaModel ?? "llama3.1:latest"
3952
- );
3953
- }
4728
+ // src/importer/codex.ts
4729
+ import fs8 from "fs";
4730
+ import path7 from "path";
4731
+ import os7 from "os";
3954
4732
 
3955
4733
  // src/filter/index.ts
3956
4734
  import { createHash as createHash4 } from "crypto";
3957
- 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;
4735
+ 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|grazie|aspetta|wait|[.!?,;:]+|\s*)$/i;
3958
4736
  var ORPHAN_CODE_PATTERN = /^([{}()[\]<>|&!=\-+*/%^~#@\\:;.,\s]+|\.\.\.|console\.log.*|print.*)$/i;
3959
- var NON_PRINTABLE_PATTERN = /^[\x00-\x1f\x7f\x1b[\]()#;\d;A-Za-z]*$/;
4737
+ var NON_PRINTABLE_PATTERN = /^(?=.*[\x00-\x1f\x7f])[\x00-\x1f\x7f\x1b[\]()#;\d;A-Za-z]*$/;
3960
4738
  var FilterPipeline = class _FilterPipeline {
3961
4739
  minLength;
3962
4740
  minRelevance;
@@ -4029,11 +4807,287 @@ var FilterPipeline = class _FilterPipeline {
4029
4807
  }
4030
4808
  };
4031
4809
 
4810
+ // src/importer/codex.ts
4811
+ function textFromContent(content) {
4812
+ if (typeof content === "string") return content;
4813
+ if (!Array.isArray(content)) return "";
4814
+ return content.flatMap((part) => {
4815
+ if (!part || typeof part !== "object") return [];
4816
+ const item = part;
4817
+ return typeof item.text === "string" && ["output_text", "input_text", "text"].includes(String(item.type)) ? [item.text] : [];
4818
+ }).join("");
4819
+ }
4820
+ function rolloutFiles(sessionsDir) {
4821
+ if (!fs8.existsSync(sessionsDir)) return [];
4822
+ const files = [];
4823
+ const visit = (dir) => {
4824
+ for (const entry of fs8.readdirSync(dir, { withFileTypes: true })) {
4825
+ const full = path7.join(dir, entry.name);
4826
+ if (entry.isDirectory()) visit(full);
4827
+ else if (entry.isFile() && /^rollout-.*\.jsonl$/.test(entry.name)) files.push(full);
4828
+ }
4829
+ };
4830
+ visit(sessionsDir);
4831
+ return files;
4832
+ }
4833
+ async function importCodexHistory(db, codexDir, dryRun, analyzer, onProgress, filter, transcriptFile) {
4834
+ const result = { filesScanned: 0, promptsFound: 0, promptsImported: 0, skipped: 0, filtered: 0, errors: [] };
4835
+ const dedupFilter = filter ?? new FilterPipeline({ minLength: 0, minRelevance: 0, existingHashes: db.getAllPromptHashes() });
4836
+ const files = transcriptFile ? [transcriptFile] : rolloutFiles(path7.join(codexDir, "sessions"));
4837
+ result.filesScanned = files.length;
4838
+ const hostname = os7.hostname();
4839
+ const entries = [];
4840
+ for (const file of files) {
4841
+ let lines;
4842
+ try {
4843
+ lines = fs8.readFileSync(file, "utf8").split("\n");
4844
+ } catch (error) {
4845
+ result.errors.push(`read ${file}: ${error.message}`);
4846
+ continue;
4847
+ }
4848
+ let workdir = "";
4849
+ let sessionId = path7.basename(file, ".jsonl").replace(/^rollout-.*?-/, "");
4850
+ const events = [];
4851
+ for (const line of lines) {
4852
+ if (!line.trim()) continue;
4853
+ try {
4854
+ const event = JSON.parse(line);
4855
+ events.push(event);
4856
+ if (event.type === "session_meta" && event.payload) {
4857
+ if (typeof event.payload.cwd === "string") workdir = event.payload.cwd;
4858
+ if (typeof event.payload.id === "string") sessionId = event.payload.id;
4859
+ }
4860
+ } catch {
4861
+ }
4862
+ }
4863
+ for (let i = 0; i < events.length; i++) {
4864
+ const event = events[i];
4865
+ const payload = event.payload ?? {};
4866
+ const isUserMessage = event.type === "event_msg" && payload.type === "user_message" || event.type === "response_item" && payload.type === "message" && payload.role === "user";
4867
+ const prompt = !isUserMessage ? "" : typeof payload.message === "string" ? payload.message.trim() : textFromContent(payload.content).trim();
4868
+ if (!prompt) continue;
4869
+ const agentMessages = [];
4870
+ const assistantItems = [];
4871
+ const finalAssistantItems = [];
4872
+ for (let j = i + 1; j < events.length; j++) {
4873
+ const candidate = events[j].payload ?? {};
4874
+ const nextIsUserMessage = events[j].type === "event_msg" && candidate.type === "user_message" || events[j].type === "response_item" && candidate.type === "message" && candidate.role === "user";
4875
+ if (nextIsUserMessage) break;
4876
+ if (events[j].type === "event_msg" && candidate.type === "agent_message" && typeof candidate.message === "string") agentMessages.push(candidate.message);
4877
+ if (events[j].type === "response_item" && candidate.type === "message" && candidate.role === "assistant") {
4878
+ const text = textFromContent(candidate.content);
4879
+ assistantItems.push(text);
4880
+ if (candidate.phase === "final_answer") finalAssistantItems.push(text);
4881
+ }
4882
+ }
4883
+ const response = agentMessages.length > 0 ? agentMessages.join("") : finalAssistantItems.length > 0 ? finalAssistantItems.join("") : assistantItems.join("");
4884
+ entries.push({ prompt, response: response.slice(0, 8e3), timestamp: event.timestamp ?? (/* @__PURE__ */ new Date(0)).toISOString(), workdir, sessionId });
4885
+ }
4886
+ }
4887
+ for (let i = 0; i < entries.length; i++) {
4888
+ const entry = entries[i];
4889
+ result.promptsFound++;
4890
+ onProgress?.(i + 1, result.promptsImported, entries.length, entry.prompt.slice(0, 60));
4891
+ if (dryRun) {
4892
+ result.promptsImported++;
4893
+ continue;
4894
+ }
4895
+ const rules = dedupFilter.checkRules(entry.prompt);
4896
+ if (!rules.keep || !dedupFilter.checkDuplicate(entry.prompt).keep) {
4897
+ result.filtered++;
4898
+ continue;
4899
+ }
4900
+ try {
4901
+ let metadata = {};
4902
+ if (analyzer) {
4903
+ try {
4904
+ metadata = mergeMetadata({}, await analyzePrompt(entry.prompt, analyzer), false);
4905
+ } catch {
4906
+ }
4907
+ }
4908
+ const id = db.insert(createCaptureRecord({
4909
+ timestamp: entry.timestamp,
4910
+ tool: "codex",
4911
+ prompt: entry.prompt,
4912
+ response: entry.response,
4913
+ args: entry.prompt,
4914
+ workdir: entry.workdir,
4915
+ hostname,
4916
+ exit_code: 0,
4917
+ metadata: { ...metadata }
4918
+ }));
4919
+ dedupFilter.registerHash(entry.prompt, id);
4920
+ result.promptsImported++;
4921
+ } catch (error) {
4922
+ result.errors.push(`insert session ${entry.sessionId}: ${error.message}`);
4923
+ result.skipped++;
4924
+ }
4925
+ }
4926
+ return result;
4927
+ }
4928
+
4929
+ // src/ai/ollama.ts
4930
+ var OllamaProvider = class {
4931
+ constructor(url, model) {
4932
+ this.url = url;
4933
+ this.model = model;
4934
+ }
4935
+ name = "ollama";
4936
+ async generate(prompt) {
4937
+ const controller = new AbortController();
4938
+ const timer = setTimeout(() => controller.abort(), 6e4);
4939
+ let res;
4940
+ try {
4941
+ res = await fetch(`${this.url}/api/generate`, {
4942
+ method: "POST",
4943
+ headers: { "Content-Type": "application/json" },
4944
+ body: JSON.stringify({ model: this.model, prompt, stream: false }),
4945
+ signal: controller.signal
4946
+ });
4947
+ } catch (err) {
4948
+ clearTimeout(timer);
4949
+ const msg = err.message ?? String(err);
4950
+ if (msg.includes("abort") || msg.includes("timeout")) {
4951
+ throw new Error(`Ollama request timed out (60s). Is Ollama running at ${this.url}?`, { cause: err });
4952
+ }
4953
+ throw new Error(`Ollama unreachable at ${this.url}: ${msg}`, { cause: err });
4954
+ } finally {
4955
+ clearTimeout(timer);
4956
+ }
4957
+ if (!res.ok) {
4958
+ const body = await res.text().catch(() => "");
4959
+ throw new Error(`Ollama error ${res.status}: ${body}`);
4960
+ }
4961
+ const data = await res.json();
4962
+ return data.response ?? "";
4963
+ }
4964
+ };
4965
+
4966
+ // src/ai/gemini.ts
4967
+ var GENERATE_API = "https://generativelanguage.googleapis.com/v1beta/models";
4968
+ async function generateContent(prompt, model, apiKey) {
4969
+ const res = await fetch(`${GENERATE_API}/${model}:generateContent?key=${apiKey}`, {
4970
+ method: "POST",
4971
+ headers: { "Content-Type": "application/json" },
4972
+ body: JSON.stringify({
4973
+ contents: [{ parts: [{ text: prompt }] }]
4974
+ })
4975
+ });
4976
+ if (!res.ok) {
4977
+ const err = await res.text();
4978
+ throw new Error(`Gemini API error ${res.status}: ${err}`);
4979
+ }
4980
+ const data = await res.json();
4981
+ return data.candidates[0]?.content?.parts[0]?.text ?? "";
4982
+ }
4983
+ var GeminiProvider = class {
4984
+ constructor(model, apiKey) {
4985
+ this.model = model;
4986
+ this.apiKey = apiKey;
4987
+ }
4988
+ name = "gemini";
4989
+ generate(prompt) {
4990
+ return generateContent(prompt, this.model, this.apiKey);
4991
+ }
4992
+ };
4993
+
4994
+ // src/ai/wise.ts
4995
+ import { generateText } from "ai";
4996
+ import { createAIModel } from "@gi4nks/wise";
4997
+ var OPENROUTER_DEFAULT_URL = "https://openrouter.ai/api/v1";
4998
+ var MLX_SERVE_DEFAULT_URL = "http://127.0.0.1:11234/v1";
4999
+ var WiseLLMProvider = class {
5000
+ name;
5001
+ model;
5002
+ constructor(wiseProvider, displayName, modelId, config) {
5003
+ this.name = `${displayName} (${modelId})`;
5004
+ this.model = createAIModel(wiseProvider, modelId, config);
5005
+ }
5006
+ async generate(prompt) {
5007
+ const result = await generateText({
5008
+ model: this.model,
5009
+ prompt,
5010
+ // OpenRouter requests can incur cost. Avoid hidden automatic replays.
5011
+ maxRetries: 0
5012
+ });
5013
+ return result.text;
5014
+ }
5015
+ };
5016
+ async function discoverModels(provider, apiKey, baseUrl) {
5017
+ if (provider === "mlx-serve") {
5018
+ const url2 = (baseUrl || MLX_SERVE_DEFAULT_URL).replace(/\/+$/, "");
5019
+ const response2 = await fetch(`${url2}/models`, {
5020
+ headers: { Authorization: `Bearer ${apiKey}` },
5021
+ signal: AbortSignal.timeout(15e3)
5022
+ });
5023
+ if (!response2.ok) throw new Error(`HTTP ${response2.status}`);
5024
+ const data2 = await response2.json();
5025
+ return (data2.data ?? []).filter((model) => typeof model.id === "string" && model.id.length > 0).map((model) => ({ id: model.id, name: model.name || model.id, loaded: model.loaded, state: model.state }));
5026
+ }
5027
+ const url = (baseUrl || OPENROUTER_DEFAULT_URL).replace(/\/+$/, "");
5028
+ const response = await fetch(`${url}/models`, {
5029
+ headers: { Authorization: `Bearer ${apiKey}` },
5030
+ signal: AbortSignal.timeout(15e3)
5031
+ });
5032
+ if (!response.ok) {
5033
+ throw new Error(`HTTP ${response.status}`);
5034
+ }
5035
+ const data = await response.json();
5036
+ return (data.data ?? []).filter((model) => typeof model.id === "string" && model.id.length > 0).map((model) => ({ id: model.id, name: model.name || model.id }));
5037
+ }
5038
+
5039
+ // src/ai/provider.ts
5040
+ function getProviderSetupError(cfg, selectedProvider) {
5041
+ const provider = selectedProvider ?? cfg.analyzeProvider ?? "ollama";
5042
+ if (provider === "openrouter") {
5043
+ if (!(process.env.OPENROUTER_API_KEY || cfg.openrouterApiKey)) return "Set a key with `ph config set openrouter-api-key` or OPENROUTER_API_KEY.";
5044
+ if (!(process.env.OPENROUTER_MODEL || cfg.openrouterModel)) return "Choose a model with `ph config set openrouter-model <provider/model>`.";
5045
+ }
5046
+ if (provider === "mlx-serve" && !(process.env.MLX_SERVE_MODEL || cfg.mlxServeModel)) {
5047
+ return "Choose an MLX-Serve model with `ph config set mlx-serve-model <model-id>`.";
5048
+ }
5049
+ return null;
5050
+ }
5051
+ function getProvider(cfg, selectedProvider) {
5052
+ const provider = selectedProvider ?? cfg.analyzeProvider ?? "ollama";
5053
+ if (provider === "openrouter") {
5054
+ const apiKey = process.env.OPENROUTER_API_KEY ?? cfg.openrouterApiKey;
5055
+ const model = process.env.OPENROUTER_MODEL ?? cfg.openrouterModel;
5056
+ if (!apiKey || !model) return null;
5057
+ return new WiseLLMProvider("openai", "OpenRouter", model, {
5058
+ openai: {
5059
+ apiKey,
5060
+ baseUrl: process.env.OPENROUTER_BASE_URL ?? cfg.openrouterUrl ?? OPENROUTER_DEFAULT_URL
5061
+ }
5062
+ });
5063
+ }
5064
+ if (provider === "mlx-serve") {
5065
+ const model = process.env.MLX_SERVE_MODEL ?? cfg.mlxServeModel;
5066
+ if (!model) return null;
5067
+ const apiKey = process.env.MLX_SERVE_API_KEY ?? process.env.GLB_OMLX_API_KEY ?? cfg.mlxServeApiKey ?? "local";
5068
+ return new WiseLLMProvider("omlx", "MLX-Serve", model, {
5069
+ omlx: {
5070
+ apiKey,
5071
+ baseUrl: process.env.MLX_SERVE_BASE_URL ?? cfg.mlxServeUrl ?? MLX_SERVE_DEFAULT_URL
5072
+ }
5073
+ });
5074
+ }
5075
+ if (provider === "gemini") {
5076
+ const apiKey = process.env.GEMINI_API_KEY ?? cfg.geminiApiKey;
5077
+ if (!apiKey) return null;
5078
+ return new GeminiProvider("gemini-2.5-flash", apiKey);
5079
+ }
5080
+ return new OllamaProvider(
5081
+ cfg.ollamaUrl ?? "http://localhost:11434",
5082
+ cfg.ollamaModel ?? "llama3.1:latest"
5083
+ );
5084
+ }
5085
+
4032
5086
  // src/commands/import.ts
4033
5087
  async function cmdImport(db, cfg, args) {
4034
5088
  if (args.length === 0) {
4035
5089
  process.stderr.write("Usage: ph import <source> [--dry-run] [--analyze]\n");
4036
- process.stderr.write("Supported sources: gemini, claude, opencode\n");
5090
+ process.stderr.write("Supported sources: gemini, claude, opencode, codex\n");
4037
5091
  process.exit(1);
4038
5092
  }
4039
5093
  const source = args[0];
@@ -4041,13 +5095,16 @@ async function cmdImport(db, cfg, args) {
4041
5095
  const dryRun = Boolean(flags["dry-run"]);
4042
5096
  const analyze = Boolean(flags["analyze"]);
4043
5097
  const useFilter = Boolean(flags["filter"]);
4044
- const homeDir = os7.homedir();
5098
+ const transcriptFile = typeof flags.file === "string" ? flags.file : void 0;
5099
+ const homeDir = os8.homedir();
4045
5100
  if (dryRun) console.log("Dry run \u2014 nothing will be written to the database.");
4046
5101
  let analyzer;
4047
5102
  if (analyze) {
4048
5103
  analyzer = getProvider(cfg);
4049
5104
  if (!analyzer) {
4050
- process.stderr.write("ph: no LLM provider configured. Run: ph config set analyze-provider ollama\n");
5105
+ const detail = getProviderSetupError(cfg) ?? "No LLM provider is configured. Run: ph config set analyze-provider ollama";
5106
+ process.stderr.write(`ph: ${detail}
5107
+ `);
4051
5108
  process.exit(1);
4052
5109
  }
4053
5110
  console.log(`Analyzing with ${analyzer.name} during import...`);
@@ -4076,16 +5133,19 @@ async function cmdImport(db, cfg, args) {
4076
5133
  let result;
4077
5134
  switch (source) {
4078
5135
  case "gemini":
4079
- result = await importGeminiHistory(db, path7.join(homeDir, ".gemini"), dryRun, analyzer, onProgress, filter);
5136
+ result = await importGeminiHistory(db, path8.join(homeDir, ".gemini"), dryRun, analyzer, onProgress, filter);
4080
5137
  break;
4081
5138
  case "claude":
4082
- result = await importClaudeHistory(db, path7.join(homeDir, ".claude"), dryRun, analyzer, onProgress, filter);
5139
+ result = await importClaudeHistory(db, path8.join(homeDir, ".claude"), dryRun, analyzer, onProgress, filter);
4083
5140
  break;
4084
5141
  case "opencode":
4085
- result = await importOpenCodeHistory(db, path7.join(homeDir, ".local", "share", "opencode", "storage"), dryRun, analyzer, onProgress, filter);
5142
+ result = await importOpenCodeHistory(db, path8.join(homeDir, ".local", "share", "opencode", "storage"), dryRun, analyzer, onProgress, filter);
5143
+ break;
5144
+ case "codex":
5145
+ result = await importCodexHistory(db, path8.join(homeDir, ".codex"), dryRun, analyzer, onProgress, filter, transcriptFile);
4086
5146
  break;
4087
5147
  default:
4088
- process.stderr.write(`ph: unknown import source "${source}". Supported: gemini, claude, opencode
5148
+ process.stderr.write(`ph: unknown import source "${source}". Supported: gemini, claude, opencode, codex
4089
5149
  `);
4090
5150
  process.exit(1);
4091
5151
  }
@@ -4117,7 +5177,9 @@ async function cmdAnalyze(db, cfg, args) {
4117
5177
  const pruneBelow = prune ? flags["min-score"] ? Number(flags["min-score"]) : cfg.filterMinRelevance ?? 3 : 0;
4118
5178
  const provider = getProvider(cfg);
4119
5179
  if (!provider) {
4120
- process.stderr.write("ph: no LLM provider configured. Set analyze-provider in config.\n");
5180
+ const detail = getProviderSetupError(cfg) ?? "No LLM provider is configured. Set analyze-provider in config.";
5181
+ process.stderr.write(`ph: ${detail}
5182
+ `);
4121
5183
  process.exit(1);
4122
5184
  }
4123
5185
  const allRows = db.search({ limit: 1e4 });
@@ -4190,9 +5252,6 @@ ${e.response}` : e.prompt);
4190
5252
  console.log("\nDone!");
4191
5253
  }
4192
5254
 
4193
- // src/commands/log.ts
4194
- import os8 from "os";
4195
-
4196
5255
  // src/background/analyzer.ts
4197
5256
  import { spawn as spawn3 } from "child_process";
4198
5257
  function spawnBackgroundAnalysis(id, dbPath) {
@@ -4245,32 +5304,16 @@ async function cmdLog(dbPath, cfg, args) {
4245
5304
  process.exit(1);
4246
5305
  }
4247
5306
  }
4248
- const { rootDir, projectName } = detectProject(workdir);
4249
- const language = detectLanguage(rootDir);
4250
- const metaObj = { $schema_version: 1 };
4251
- if (projectName) metaObj.project = projectName;
4252
- if (language) metaObj.language = language;
4253
- const title = extractTopic(prompt);
4254
- if (title) metaObj.title = title;
5307
+ const record = createCaptureRecord({ tool, prompt, response, args: "", workdir });
4255
5308
  const db = new PhDB(dbPath);
4256
- const id = db.insert({
4257
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4258
- tool,
4259
- prompt,
4260
- response,
4261
- args: "",
4262
- workdir,
4263
- hostname: os8.hostname(),
4264
- exit_code: 0,
4265
- metadata: JSON.stringify(metaObj)
4266
- });
5309
+ const id = db.insert(record);
4267
5310
  db.close();
4268
5311
  if (cfg.backgroundAnalysis) {
4269
5312
  spawnBackgroundAnalysis(id, dbPath);
4270
5313
  }
4271
5314
  const pushUrl = process.env.PH_REMOTE_URL || cfg.remoteUrl;
4272
5315
  if (pushUrl) {
4273
- pushToRemote(pushUrl, cfg.remoteApiKey, { timestamp: (/* @__PURE__ */ new Date()).toISOString(), tool, prompt, response, args: "", workdir, hostname: os8.hostname(), exit_code: 0, metadata: JSON.stringify(metaObj) }).catch(() => {
5316
+ pushToRemote(pushUrl, cfg.remoteApiKey, record).catch(() => {
4274
5317
  });
4275
5318
  }
4276
5319
  }
@@ -4290,17 +5333,29 @@ async function pushToRemote(url, apiKey, entry) {
4290
5333
  }
4291
5334
 
4292
5335
  // src/commands/config.ts
4293
- function cmdConfig(args) {
4294
- if (args.length < 3 || args[0] !== "set") {
4295
- console.log("Usage: ph config set <key> <value>");
5336
+ var SECRET_KEYS = ["gemini-api-key", "openrouter-api-key", "mlx-serve-api-key", "remote-api-key"];
5337
+ async function cmdConfig(args) {
5338
+ if (args[0] === "show") {
5339
+ const cfg2 = load();
5340
+ console.log("Analysis providers (* = active; credentials are never shown):");
5341
+ for (const provider of getProviderSummaries(cfg2)) console.log(formatProviderSummary(provider));
5342
+ console.log("Configure with: ph config set analyze-provider <ollama|gemini|openrouter|mlx-serve>");
5343
+ return;
5344
+ }
5345
+ if (args[0] !== "set" || args.length < 2) {
5346
+ console.log("Usage: ph config show | ph config set <key> [value]");
4296
5347
  console.log("Keys: gemini-api-key, db-path, analyze-provider, ollama-url, ollama-model,");
5348
+ console.log(" openrouter-model, openrouter-url, openrouter-api-key,");
5349
+ console.log(" mlx-serve-model, mlx-serve-url, mlx-serve-api-key,");
4297
5350
  console.log(" ollama-embed-model, filter-min-length, filter-min-relevance, background-analysis,");
4298
5351
  console.log(" remote-url, remote-api-key");
4299
- process.exit(1);
5352
+ return;
4300
5353
  }
4301
5354
  const cfg = load();
4302
5355
  const key = args[1];
4303
- const val = args[2];
5356
+ let val = args.slice(2).join(" ");
5357
+ if (!val && SECRET_KEYS.includes(key)) val = await readSecret(`${key}: `);
5358
+ if (!val) throw new Error(`A value is required for ${key}.`);
4304
5359
  switch (key) {
4305
5360
  case "gemini-api-key":
4306
5361
  cfg.geminiApiKey = val;
@@ -4309,10 +5364,7 @@ function cmdConfig(args) {
4309
5364
  cfg.dbPath = val;
4310
5365
  break;
4311
5366
  case "analyze-provider":
4312
- if (val !== "ollama" && val !== "gemini") {
4313
- process.stderr.write('ph: analyze-provider must be "ollama" or "gemini"\n');
4314
- process.exit(1);
4315
- }
5367
+ if (!["ollama", "gemini", "openrouter", "mlx-serve"].includes(val)) throw new Error("analyze-provider must be ollama, gemini, openrouter, or mlx-serve");
4316
5368
  cfg.analyzeProvider = val;
4317
5369
  break;
4318
5370
  case "ollama-url":
@@ -4321,24 +5373,36 @@ function cmdConfig(args) {
4321
5373
  case "ollama-model":
4322
5374
  cfg.ollamaModel = val;
4323
5375
  break;
5376
+ case "openrouter-model":
5377
+ cfg.openrouterModel = val;
5378
+ break;
5379
+ case "openrouter-url":
5380
+ cfg.openrouterUrl = val;
5381
+ break;
5382
+ case "openrouter-api-key":
5383
+ cfg.openrouterApiKey = val;
5384
+ break;
5385
+ case "mlx-serve-model":
5386
+ cfg.mlxServeModel = val;
5387
+ break;
5388
+ case "mlx-serve-url":
5389
+ cfg.mlxServeUrl = val;
5390
+ break;
5391
+ case "mlx-serve-api-key":
5392
+ cfg.mlxServeApiKey = val;
5393
+ break;
4324
5394
  case "ollama-embed-model":
4325
5395
  cfg.ollamaEmbedModel = val;
4326
5396
  break;
4327
5397
  case "filter-min-length": {
4328
5398
  const n = Number(val);
4329
- if (isNaN(n) || n < 0) {
4330
- process.stderr.write("ph: filter-min-length must be a non-negative integer\n");
4331
- process.exit(1);
4332
- }
5399
+ if (!Number.isInteger(n) || n < 0) throw new Error("filter-min-length must be a non-negative integer");
4333
5400
  cfg.filterMinLength = n;
4334
5401
  break;
4335
5402
  }
4336
5403
  case "filter-min-relevance": {
4337
5404
  const n = Number(val);
4338
- if (isNaN(n) || n < 0 || n > 10) {
4339
- process.stderr.write("ph: filter-min-relevance must be 0-10\n");
4340
- process.exit(1);
4341
- }
5405
+ if (!Number.isInteger(n) || n < 0 || n > 10) throw new Error("filter-min-relevance must be 0-10");
4342
5406
  cfg.filterMinRelevance = n;
4343
5407
  break;
4344
5408
  }
@@ -4352,15 +5416,53 @@ function cmdConfig(args) {
4352
5416
  cfg.remoteApiKey = val;
4353
5417
  break;
4354
5418
  default:
4355
- process.stderr.write(`ph: unknown config key "${key}"
4356
- `);
4357
- process.exit(1);
5419
+ throw new Error(`unknown config key "${key}"`);
4358
5420
  }
4359
5421
  save(cfg);
4360
- console.log(`Config "${key}" updated successfully.`);
5422
+ console.log(SECRET_KEYS.includes(key) ? `Config "${key}" updated (value hidden).` : `Config "${key}" updated successfully.`);
5423
+ }
5424
+ function readSecret(prompt) {
5425
+ if (!process.stdin.isTTY) throw new Error(`No value supplied for ${prompt.trim()}; use an environment variable in non-interactive shells.`);
5426
+ return new Promise((resolve, reject) => {
5427
+ const input = process.stdin;
5428
+ const previousRawMode = input.isRaw ?? false;
5429
+ let value = "";
5430
+ process.stderr.write(prompt);
5431
+ input.setRawMode(true);
5432
+ input.resume();
5433
+ const cleanup = () => {
5434
+ input.removeListener("data", onData);
5435
+ input.setRawMode(previousRawMode);
5436
+ process.stderr.write("\n");
5437
+ };
5438
+ const onData = (chunk) => {
5439
+ for (const byte of chunk) {
5440
+ if (byte === 3) {
5441
+ cleanup();
5442
+ reject(new Error("Input cancelled."));
5443
+ return;
5444
+ }
5445
+ if (byte === 13 || byte === 10) {
5446
+ cleanup();
5447
+ resolve(value);
5448
+ return;
5449
+ }
5450
+ if (byte === 127 || byte === 8) value = value.slice(0, -1);
5451
+ else if (byte >= 32) value += String.fromCharCode(byte);
5452
+ }
5453
+ };
5454
+ input.on("data", onData);
5455
+ });
4361
5456
  }
4362
5457
 
4363
5458
  // src/commands/background-analyze.ts
5459
+ import { createHash as createHash5 } from "crypto";
5460
+ function gitFingerprint2(ctx) {
5461
+ const hash = createHash5("sha256");
5462
+ hash.update(ctx.branch);
5463
+ hash.update(ctx.diff);
5464
+ return `git:${hash.digest("hex").slice(0, 14)}`;
5465
+ }
4364
5466
  async function cmdBackgroundAnalyze(dbPath, args) {
4365
5467
  const id = parseInt(args[0], 10);
4366
5468
  if (isNaN(id)) return;
@@ -4386,11 +5488,24 @@ async function cmdBackgroundAnalyze(dbPath, args) {
4386
5488
  const merged = mergeMetadata(existing, result, false);
4387
5489
  db.updateMetadata(id, JSON.stringify(merged));
4388
5490
  if (result.project && result.summary) {
5491
+ const gitCtx = captureGitContext(process.cwd());
5492
+ const gitFp = gitCtx ? gitFingerprint2(gitCtx) : void 0;
5493
+ if (gitFp) {
5494
+ const lastMem = db.getLastMemoryForProject(result.project);
5495
+ if (lastMem?.git_context_snapshot === gitFp) return;
5496
+ }
4389
5497
  db.upsertProjectMemory({
4390
5498
  project: result.project,
4391
5499
  prompt_id: id,
4392
5500
  summary: result.summary,
4393
5501
  key_insights: result.key_insights ?? [],
5502
+ technical_decisions: result.technical_decisions ?? [],
5503
+ git_context_snapshot: gitFp
5504
+ });
5505
+ db.upsertProjectSummary({
5506
+ project: result.project,
5507
+ summary: result.summary,
5508
+ key_insights: result.key_insights ?? [],
4394
5509
  technical_decisions: result.technical_decisions ?? []
4395
5510
  });
4396
5511
  }
@@ -4404,6 +5519,11 @@ async function cmdBackgroundAnalyze(dbPath, args) {
4404
5519
  async function cmdCleanup(db, cfg, args) {
4405
5520
  const { flags } = parseFlags(args);
4406
5521
  const dryRun = Boolean(flags["dry-run"]);
5522
+ const retention = Boolean(flags["retention"]);
5523
+ if (retention) {
5524
+ await runRetention(db, cfg, dryRun);
5525
+ return;
5526
+ }
4407
5527
  const minLength = flags["min-length"] ? Number(flags["min-length"]) : cfg.filterMinLength ?? 15;
4408
5528
  const minScore = flags["min-score"] ? Number(flags["min-score"]) : cfg.filterMinRelevance ?? 3;
4409
5529
  const days = flags["days"] ? Number(flags["days"]) : void 0;
@@ -4467,6 +5587,71 @@ Candidates for rule-based deletion: ${toDelete.length}`);
4467
5587
  console.log(`
4468
5588
  Deleted ${deleted} prompts.`);
4469
5589
  }
5590
+ async function runRetention(db, cfg, dryRun) {
5591
+ const retentionDays = cfg.retentionDays ?? 90;
5592
+ const minStarred = cfg.retentionMinStarred ?? true;
5593
+ const minAnalyzed = cfg.retentionMinAnalyzed ?? true;
5594
+ const minRelevance = cfg.retentionMinRelevance ?? 3;
5595
+ const cutoff = /* @__PURE__ */ new Date();
5596
+ cutoff.setDate(cutoff.getDate() - retentionDays);
5597
+ const cutoffStr = cutoff.toISOString();
5598
+ const allEntries = db.search({ limit: 1e5 });
5599
+ const stats = db.getArchiveStats();
5600
+ console.log(`Retention policy: ${retentionDays} days, minRelevance=${minRelevance}, keepStarred=${minStarred}, keepAnalyzed=${minAnalyzed}`);
5601
+ console.log(`Archive: ${stats.total} entries (${stats.oldest ? `oldest ${stats.oldest}` : "empty"})`);
5602
+ console.log(`Scanning ${allEntries.length} prompts...
5603
+ `);
5604
+ const toArchive = [];
5605
+ for (const entry of allEntries) {
5606
+ let meta = {};
5607
+ try {
5608
+ meta = JSON.parse(entry.metadata);
5609
+ } catch {
5610
+ }
5611
+ const age = entry.timestamp;
5612
+ const isOld = age < cutoffStr;
5613
+ const isStarred = meta.starred === true;
5614
+ const isAnalyzed = Boolean(meta.summary || meta.role);
5615
+ const relevance = meta.relevance ?? 5;
5616
+ if (!isOld) continue;
5617
+ if (relevance < minRelevance) {
5618
+ toArchive.push({ id: entry.id, prompt: entry.prompt, reason: `low_relevance(${relevance})`, meta });
5619
+ continue;
5620
+ }
5621
+ if (!(minStarred && isStarred) && !(minAnalyzed && isAnalyzed)) {
5622
+ toArchive.push({ id: entry.id, prompt: entry.prompt, reason: `old(>${retentionDays}d)`, meta });
5623
+ }
5624
+ }
5625
+ if (toArchive.length === 0) {
5626
+ console.log("No prompts match retention criteria.");
5627
+ return;
5628
+ }
5629
+ console.log(`Candidates for archiving: ${toArchive.length}`);
5630
+ for (const item of toArchive.slice(0, 20)) {
5631
+ const short = item.prompt.replace(/\n/g, " ").slice(0, 60);
5632
+ const starred = item.meta.starred ? " *" : "";
5633
+ const analyzed = item.meta.summary ? " [A]" : "";
5634
+ console.log(` #${String(item.id).padEnd(5)} [${item.reason}]${starred}${analyzed} "${short}"`);
5635
+ }
5636
+ if (toArchive.length > 20) {
5637
+ console.log(` ... and ${toArchive.length - 20} more`);
5638
+ }
5639
+ if (dryRun) {
5640
+ console.log(`
5641
+ (dry-run) Would archive ${toArchive.length} prompts. Run without --dry-run to apply.`);
5642
+ return;
5643
+ }
5644
+ const ids = toArchive.map((e) => e.id);
5645
+ const archived = db.archivePrompts(ids);
5646
+ console.log(`
5647
+ Archived ${archived} prompts.`);
5648
+ const purgeCutoff = /* @__PURE__ */ new Date();
5649
+ purgeCutoff.setDate(purgeCutoff.getDate() - retentionDays * 2);
5650
+ const purged = db.purgeArchive(purgeCutoff.toISOString());
5651
+ if (purged > 0) {
5652
+ console.log(`Purged ${purged} archived entries older than ${retentionDays * 2} days.`);
5653
+ }
5654
+ }
4470
5655
 
4471
5656
  // src/commands/vacuum.ts
4472
5657
  async function cmdVacuum(db) {
@@ -4490,75 +5675,20 @@ async function cmdContext(db, cfg, args) {
4490
5675
  process.stderr.write("ph: could not detect project. Use --project <name>\n");
4491
5676
  process.exit(1);
4492
5677
  }
4493
- const memories = !promptsOnly ? db.searchMemories(project, limit) : [];
4494
- let prompts = [];
4495
- if (!memoriesOnly) {
4496
- if (query) {
4497
- const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
4498
- const model = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
4499
- const [queryVec] = await getEmbeddings([query], ollamaUrl, model, 1);
4500
- if (queryVec) {
4501
- const semResults = db.searchSemantic(queryVec, limit * 3);
4502
- prompts = semResults.filter((e) => {
4503
- try {
4504
- const meta = JSON.parse(e.metadata);
4505
- return meta.project === project;
4506
- } catch {
4507
- return false;
4508
- }
4509
- }).slice(0, limit);
4510
- }
4511
- } else {
4512
- prompts = db.getProjectMemory(project, limit);
4513
- }
4514
- }
4515
- if (memories.length === 0 && prompts.length === 0) {
4516
- process.stdout.write(`No relevant context found for project "${project}".
4517
- `);
4518
- return;
4519
- }
4520
- const output = [];
4521
- if (memories.length > 0) {
4522
- output.push(`## Project Knowledge: ${project}
4523
- `);
4524
- for (const mem of memories) {
4525
- if (mem.summary) output.push(`${mem.summary}
4526
- `);
4527
- if (mem.key_insights.length > 0) {
4528
- output.push("Key Insights:");
4529
- for (const insight of mem.key_insights) output.push(` - ${insight}`);
4530
- output.push("");
4531
- }
4532
- if (mem.technical_decisions.length > 0) {
4533
- output.push("Technical Decisions:");
4534
- for (const dec of mem.technical_decisions) output.push(` - ${dec}`);
4535
- output.push("");
4536
- }
4537
- }
4538
- }
4539
- if (prompts.length > 0) {
4540
- output.push("---\n");
4541
- output.push(`## Recent Context
4542
- `);
4543
- for (const e of prompts) {
4544
- let meta = {};
4545
- try {
4546
- meta = JSON.parse(e.metadata);
4547
- } catch {
4548
- }
4549
- output.push(`### #${e.id}`);
4550
- if (meta.role) output.push(`Role: ${meta.role}`);
4551
- if (meta.summary) output.push(`${meta.summary}`);
4552
- if (meta.key_insights && meta.key_insights.length > 0) {
4553
- for (const i of meta.key_insights) output.push(` - ${i}`);
4554
- }
4555
- if (verbose) {
4556
- output.push("```\n" + e.prompt.slice(0, 1e3) + (e.prompt.length > 1e3 ? "\n..." : "") + "\n```");
4557
- }
4558
- output.push("");
4559
- }
4560
- }
4561
- process.stdout.write(output.join("\n"));
5678
+ const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
5679
+ const model = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
5680
+ const context = await getProjectContext(db, {
5681
+ project,
5682
+ query,
5683
+ limit,
5684
+ includeSummary: !promptsOnly,
5685
+ includeMemories: !promptsOnly,
5686
+ includePrompts: !memoriesOnly
5687
+ }, async (text) => {
5688
+ const [vector] = await getEmbeddings([text], ollamaUrl, model, 1);
5689
+ return vector;
5690
+ });
5691
+ process.stdout.write(formatProjectContext(context, { includePromptText: verbose }) + "\n");
4562
5692
  }
4563
5693
 
4564
5694
  // src/sessions/index.ts
@@ -4613,42 +5743,100 @@ function computeSessionCohesion(session, embeddings) {
4613
5743
  }
4614
5744
 
4615
5745
  // src/commands/sessions.ts
5746
+ function formatTimestamp4(ts) {
5747
+ const d = new Date(ts);
5748
+ const Y = d.getFullYear();
5749
+ const M = String(d.getMonth() + 1).padStart(2, "0");
5750
+ const D = String(d.getDate()).padStart(2, "0");
5751
+ const h = String(d.getHours()).padStart(2, "0");
5752
+ const m = String(d.getMinutes()).padStart(2, "0");
5753
+ return `${Y}-${M}-${D} ${h}:${m}`;
5754
+ }
5755
+ function fmtTime(ts) {
5756
+ const d = new Date(ts);
5757
+ const hh = String(d.getHours()).padStart(2, "0");
5758
+ const mm = String(d.getMinutes()).padStart(2, "0");
5759
+ return `${hh}:${mm}`;
5760
+ }
5761
+ function parseMeta3(raw) {
5762
+ try {
5763
+ return JSON.parse(raw);
5764
+ } catch {
5765
+ return {};
5766
+ }
5767
+ }
5768
+ function exportSessionMarkdown(session) {
5769
+ const lines = [];
5770
+ const dateStr = formatTimestamp4(session.startTime.toISOString());
5771
+ const endStr = fmtTime(session.endTime.toISOString());
5772
+ lines.push(`# Session ${session.index} \u2014 ${dateStr} \u2192 ${endStr}`);
5773
+ lines.push("");
5774
+ lines.push(`**${session.entries.length} prompts**`);
5775
+ lines.push("");
5776
+ lines.push("---");
5777
+ lines.push("");
5778
+ for (const entry of session.entries) {
5779
+ const meta = parseMeta3(entry.metadata);
5780
+ const ts = formatTimestamp4(entry.timestamp);
5781
+ const title = meta.title || entry.prompt.replace(/\n/g, " ").slice(0, 60);
5782
+ lines.push(`## #${entry.id} \u2014 ${entry.tool} \u2014 ${ts}`);
5783
+ if (title) lines.push(`**${title}**`);
5784
+ lines.push("");
5785
+ const metaLine = [];
5786
+ if (meta.role) metaLine.push(`Role: ${meta.role}`);
5787
+ if (meta.project) metaLine.push(`Project: ${meta.project}`);
5788
+ if (meta.language) metaLine.push(`Language: ${meta.language}`);
5789
+ if (meta.tags?.length) metaLine.push(`Tags: ${meta.tags.join(", ")}`);
5790
+ if (meta.relevance !== void 0) metaLine.push(`Relevance: ${meta.relevance}/10`);
5791
+ if (meta.quality !== void 0) metaLine.push(`Quality: ${meta.quality}/10`);
5792
+ if (metaLine.length > 0) {
5793
+ lines.push(metaLine.join(" \xB7 "));
5794
+ lines.push("");
5795
+ }
5796
+ if (meta.summary) {
5797
+ lines.push(`> ${meta.summary}`);
5798
+ lines.push("");
5799
+ }
5800
+ lines.push("### Prompt");
5801
+ lines.push("");
5802
+ lines.push("```");
5803
+ lines.push(entry.prompt);
5804
+ lines.push("```");
5805
+ lines.push("");
5806
+ if (entry.response) {
5807
+ lines.push("### Response");
5808
+ lines.push("");
5809
+ lines.push("```");
5810
+ lines.push(entry.response.slice(0, 2e3));
5811
+ lines.push("```");
5812
+ lines.push("");
5813
+ }
5814
+ if (meta.key_insights?.length) {
5815
+ lines.push("**Insights:**");
5816
+ for (const ins of meta.key_insights) lines.push(`- ${ins}`);
5817
+ lines.push("");
5818
+ }
5819
+ if (meta.git_context) {
5820
+ lines.push(`*Git: ${meta.git_context.branch}, ${meta.git_context.files?.length || 0} files modified*`);
5821
+ lines.push("");
5822
+ }
5823
+ lines.push("---");
5824
+ lines.push("");
5825
+ }
5826
+ return lines.join("\n");
5827
+ }
4616
5828
  async function cmdSessions(db, args) {
4617
5829
  const { flags } = parseFlags(args);
4618
5830
  const gapHours = Number(flags["gap-hours"] ?? 2);
4619
5831
  const limit = Number(flags["limit"] ?? 20);
4620
5832
  const minSize = Number(flags["min-size"] ?? 1);
4621
5833
  const noCohesion = Boolean(flags["no-cohesion"]);
4622
- const C2 = {
4623
- reset: "\x1B[0m",
4624
- cyan: "\x1B[36m",
4625
- yellow: "\x1B[33m",
4626
- green: "\x1B[32m",
4627
- gray: "\x1B[90m",
4628
- red: "\x1B[31m",
4629
- blue: "\x1B[34m",
4630
- magenta: "\x1B[35m"
4631
- };
4632
- function fmtDate(ts) {
4633
- const d = new Date(ts);
4634
- const yr = d.getFullYear();
4635
- const mo = String(d.getMonth() + 1).padStart(2, "0");
4636
- const dy = String(d.getDate()).padStart(2, "0");
4637
- const hh = String(d.getHours()).padStart(2, "0");
4638
- const mm = String(d.getMinutes()).padStart(2, "0");
4639
- return `${yr}-${mo}-${dy} ${hh}:${mm}`;
4640
- }
4641
- function fmtTime(ts) {
4642
- const d = new Date(ts);
4643
- const hh = String(d.getHours()).padStart(2, "0");
4644
- const mm = String(d.getMinutes()).padStart(2, "0");
4645
- return `${hh}:${mm}`;
4646
- }
5834
+ const exportIndex = flags["export"] !== void 0 ? Number(flags["export"]) : void 0;
4647
5835
  const allEntries = db.search({ limit: 1e5 });
4648
5836
  const entriesAsc = [...allEntries].reverse();
4649
5837
  const sessions = groupIntoSessions(entriesAsc, gapHours);
4650
5838
  let embeddings = null;
4651
- if (!noCohesion) {
5839
+ if (!noCohesion && exportIndex === void 0) {
4652
5840
  embeddings = db.getAllEmbeddings();
4653
5841
  }
4654
5842
  for (const session of sessions) {
@@ -4656,21 +5844,37 @@ async function cmdSessions(db, args) {
4656
5844
  session.cohesion = computeSessionCohesion(session, embeddings);
4657
5845
  }
4658
5846
  }
5847
+ if (exportIndex !== void 0) {
5848
+ const session = sessions.find((s) => s.index === exportIndex);
5849
+ if (!session) {
5850
+ process.stderr.write(`Session ${exportIndex} not found.
5851
+ `);
5852
+ return;
5853
+ }
5854
+ process.stdout.write(exportSessionMarkdown(session));
5855
+ return;
5856
+ }
4659
5857
  const filtered = sessions.filter((s) => s.entries.length >= minSize);
4660
5858
  const visible = filtered.slice(-limit);
4661
5859
  if (visible.length === 0) {
4662
5860
  process.stdout.write("No sessions found.\n");
4663
5861
  return;
4664
5862
  }
5863
+ const C2 = {
5864
+ reset: "\x1B[0m",
5865
+ cyan: "\x1B[36m",
5866
+ yellow: "\x1B[33m",
5867
+ green: "\x1B[32m",
5868
+ gray: "\x1B[90m",
5869
+ red: "\x1B[31m",
5870
+ blue: "\x1B[34m",
5871
+ magenta: "\x1B[35m"
5872
+ };
4665
5873
  for (const session of visible) {
4666
5874
  const projectCounts = /* @__PURE__ */ new Map();
4667
5875
  const languageCounts = /* @__PURE__ */ new Map();
4668
5876
  for (const entry of session.entries) {
4669
- let meta = {};
4670
- try {
4671
- meta = JSON.parse(entry.metadata);
4672
- } catch {
4673
- }
5877
+ const meta = parseMeta3(entry.metadata);
4674
5878
  if (meta.project) projectCounts.set(meta.project, (projectCounts.get(meta.project) ?? 0) + 1);
4675
5879
  if (meta.language) languageCounts.set(meta.language, (languageCounts.get(meta.language) ?? 0) + 1);
4676
5880
  }
@@ -4689,7 +5893,7 @@ async function cmdSessions(db, args) {
4689
5893
  break;
4690
5894
  }
4691
5895
  }
4692
- const startStr = fmtDate(session.startTime.toISOString());
5896
+ const startStr = formatTimestamp4(session.startTime.toISOString());
4693
5897
  const endStr = fmtTime(session.endTime.toISOString());
4694
5898
  const promptWord = session.entries.length === 1 ? "prompt" : "prompts";
4695
5899
  let header = `${C2.cyan}Session ${session.index}${C2.reset}`;
@@ -4707,11 +5911,7 @@ async function cmdSessions(db, args) {
4707
5911
  }
4708
5912
  process.stdout.write(header + "\n");
4709
5913
  for (const entry of session.entries) {
4710
- let meta = {};
4711
- try {
4712
- meta = JSON.parse(entry.metadata);
4713
- } catch {
4714
- }
5914
+ const meta = parseMeta3(entry.metadata);
4715
5915
  const idStr = `${C2.cyan}#${entry.id}${C2.reset}`;
4716
5916
  const toolStr = `${C2.yellow}${entry.tool.padEnd(8)}${C2.reset}`;
4717
5917
  const timeStr = `${C2.gray}${fmtTime(entry.timestamp)}${C2.reset}`;
@@ -4922,7 +6122,7 @@ ${C2.cyan}Cluster #${i + 1}${C2.reset} (${C2.yellow}${cluster.entryIds.length} p
4922
6122
  }
4923
6123
 
4924
6124
  // src/commands/analyze-reusability.ts
4925
- import fs8 from "fs";
6125
+ import fs9 from "fs";
4926
6126
 
4927
6127
  // src/analyzer/reusability.ts
4928
6128
  var ReusabilityAnalyzer = class _ReusabilityAnalyzer {
@@ -5107,7 +6307,7 @@ ${C2.bold}TOP 10 REMOVE (low score + duplicate):${C2.reset}`);
5107
6307
  console.log(` [${C2.red}${scoreStr}${C2.reset}] ${s.prompt.replace(/\n/g, " ").slice(0, 80)}...`);
5108
6308
  });
5109
6309
  if (exportCsv) {
5110
- fs8.writeFileSync(exportCsv, analyzer.toCSV(report));
6310
+ fs9.writeFileSync(exportCsv, analyzer.toCSV(report));
5111
6311
  console.log(`
5112
6312
  ${C2.green}\u2705 Report exported to ${exportCsv}${C2.reset}`);
5113
6313
  }
@@ -5115,7 +6315,7 @@ ${C2.green}\u2705 Report exported to ${exportCsv}${C2.reset}`);
5115
6315
  }
5116
6316
 
5117
6317
  // src/commands/cleanup-reusability.ts
5118
- import fs9 from "fs";
6318
+ import fs10 from "fs";
5119
6319
  async function cmdCleanupReusability(db, _cfg, args) {
5120
6320
  const { flags } = parseFlags(args);
5121
6321
  const threshold = flags["threshold"] ? parseFloat(flags["threshold"]) : 0.7;
@@ -5177,7 +6377,7 @@ Run with ${C2.bold}--force${C2.reset} or without ${C2.bold}--dry-run${C2.reset}
5177
6377
  process.stdout.write(`
5178
6378
  Continue and delete ${candidates.length} prompts? (y/N) `);
5179
6379
  const buffer = Buffer.alloc(16);
5180
- const bytesRead = fs9.readSync(0, buffer, 0, 16);
6380
+ const bytesRead = fs10.readSync(0, buffer, 0, 16, null);
5181
6381
  const answer = buffer.toString("utf8", 0, bytesRead).trim().toLowerCase();
5182
6382
  if (answer !== "y" && answer !== "yes") {
5183
6383
  console.log("Aborted.");
@@ -5292,8 +6492,40 @@ function formatMemoryEntry(m) {
5292
6492
  return lines.join("\n");
5293
6493
  }
5294
6494
 
6495
+ // src/commands/memory-migrate.ts
6496
+ function cmdMemoryMigrate(db) {
6497
+ const projects = db.getAllProjectsWithMemories();
6498
+ if (projects.length === 0) {
6499
+ console.log("No memories found. Nothing to migrate.");
6500
+ return;
6501
+ }
6502
+ let count = 0;
6503
+ for (const project of projects) {
6504
+ const memories = db.searchMemories(project, 1e3);
6505
+ const allInsights = /* @__PURE__ */ new Set();
6506
+ const allDecisions = /* @__PURE__ */ new Set();
6507
+ let lastSummary = "";
6508
+ let gitCtx;
6509
+ for (const mem of memories) {
6510
+ if (mem.summary) lastSummary = mem.summary;
6511
+ for (const i of mem.key_insights) allInsights.add(i);
6512
+ for (const d of mem.technical_decisions) allDecisions.add(d);
6513
+ if (mem.git_context_snapshot) gitCtx = mem.git_context_snapshot;
6514
+ }
6515
+ db.upsertProjectSummary({
6516
+ project,
6517
+ summary: lastSummary,
6518
+ key_insights: [...allInsights],
6519
+ technical_decisions: [...allDecisions],
6520
+ git_context_snapshot: gitCtx
6521
+ });
6522
+ count++;
6523
+ }
6524
+ console.log(`Migrated ${count} project(s) into project_summaries.`);
6525
+ }
6526
+
5295
6527
  // src/commands/wrap.ts
5296
- import path8 from "path";
6528
+ import path9 from "path";
5297
6529
  import os9 from "os";
5298
6530
  async function cmdWrap(dbPath, tool, args, cfg) {
5299
6531
  let debugLog;
@@ -5302,7 +6534,7 @@ async function cmdWrap(dbPath, tool, args, cfg) {
5302
6534
  const cleanArgs = [];
5303
6535
  for (let i = 0; i < args.length; i++) {
5304
6536
  if (args[i] === "--ph-debug") {
5305
- debugLog = path8.join(os9.homedir(), ".ph_debug.log");
6537
+ debugLog = path9.join(os9.homedir(), ".ph_debug.log");
5306
6538
  process.stderr.write(`ph: debug log \u2192 ${debugLog}
5307
6539
  `);
5308
6540
  } else if (args[i] === "--ph-tag" && i + 1 < args.length) {
@@ -5331,28 +6563,17 @@ async function cmdWrap(dbPath, tool, args, cfg) {
5331
6563
  const db = new PhDB(dbPath);
5332
6564
  if (interactive) {
5333
6565
  const workdir = process.cwd();
5334
- const { rootDir, projectName } = detectProject(workdir);
5335
- const language = detectLanguage(rootDir);
5336
6566
  const gitContext = captureGitContext(workdir);
5337
6567
  const onPrompt = (prompt, ts) => {
5338
- const metaObj = { $schema_version: 1 };
5339
- if (projectName) metaObj.project = projectName;
5340
- if (language) metaObj.language = language;
5341
- if (role) metaObj.role = role;
5342
- if (tags.length > 0) metaObj.tags = tags;
5343
- if (gitContext) metaObj.git_context = gitContext;
5344
- const metadata = Object.keys(metaObj).length > 0 ? JSON.stringify(metaObj) : "{}";
5345
- return db.insert({
6568
+ return db.insert(createCaptureRecord({
5346
6569
  timestamp: ts.toISOString(),
5347
6570
  tool,
5348
6571
  prompt,
5349
- response: "",
5350
6572
  args: prompt,
5351
6573
  workdir,
5352
- hostname: os9.hostname(),
5353
- exit_code: 0,
5354
- metadata
5355
- });
6574
+ gitContext,
6575
+ metadata: { ...role ? { role } : {}, ...tags.length > 0 ? { tags } : {} }
6576
+ }));
5356
6577
  };
5357
6578
  const onResponse = (id, response) => {
5358
6579
  if (!response.trim()) return;
@@ -5392,41 +6613,14 @@ async function cmdChat(dbPath, cfg, args) {
5392
6613
  process.exit(1);
5393
6614
  }
5394
6615
  const db = new PhDB(dbPath);
5395
- const memories = db.searchMemories(project, 3);
5396
- const recentPrompts = db.getProjectMemory(project, 5);
5397
- const contextParts = [];
5398
- if (memories.length > 0) {
5399
- contextParts.push(`## Project Knowledge: ${project}`);
5400
- for (const mem of memories) {
5401
- if (mem.summary) contextParts.push(`
5402
- ${mem.summary}`);
5403
- if (mem.key_insights.length > 0) {
5404
- contextParts.push("\nKey Insights:");
5405
- for (const i of mem.key_insights) contextParts.push(` - ${i}`);
5406
- }
5407
- if (mem.technical_decisions.length > 0) {
5408
- contextParts.push("\nTechnical Decisions:");
5409
- for (const d of mem.technical_decisions) contextParts.push(` - ${d}`);
5410
- }
5411
- }
5412
- }
5413
- if (recentPrompts.length > 0) {
5414
- contextParts.push(`
5415
- ---
5416
- ## Recent Context`);
5417
- for (const p of recentPrompts) {
5418
- let meta = {};
5419
- try {
5420
- meta = JSON.parse(p.metadata);
5421
- } catch {
5422
- }
5423
- const summary = meta.summary || p.prompt.slice(0, 100).replace(/\n/g, " ");
5424
- contextParts.push(`
5425
- - #${p.id}: ${summary}`);
5426
- }
5427
- }
6616
+ const ollamaUrl = cfg.ollamaUrl ?? "http://localhost:11434";
6617
+ const model = cfg.ollamaEmbedModel ?? "nomic-embed-text-v2-moe";
6618
+ const context = await getProjectContext(db, { project, limit: 5, memoryLimit: 3 }, async (query) => {
6619
+ const [vector] = await getEmbeddings([query], ollamaUrl, model, 1);
6620
+ return vector;
6621
+ });
5428
6622
  db.close();
5429
- const contextStr = contextParts.join("\n");
6623
+ const contextStr = formatProjectContext(context);
5430
6624
  const fullPrompt = `Context from project "${project}":
5431
6625
 
5432
6626
  ${contextStr}
@@ -5439,6 +6633,40 @@ ${userPrompt}`;
5439
6633
  process.exit(child.status ?? 0);
5440
6634
  }
5441
6635
 
6636
+ // src/commands/models.ts
6637
+ async function cmdModels(cfg, args) {
6638
+ const provider = args[0] ?? cfg.analyzeProvider;
6639
+ if (provider !== "openrouter" && provider !== "mlx-serve") {
6640
+ throw new Error("Choose a provider: `ph models openrouter` or `ph models mlx-serve`.");
6641
+ }
6642
+ const cfgKey = provider === "openrouter" ? cfg.openrouterApiKey : cfg.mlxServeApiKey;
6643
+ if (provider === "openrouter" && !(process.env.OPENROUTER_API_KEY || cfgKey)) {
6644
+ throw new Error("Configure a key with `ph config set openrouter-api-key` or set OPENROUTER_API_KEY.");
6645
+ }
6646
+ const apiKey = provider === "openrouter" ? process.env.OPENROUTER_API_KEY ?? cfg.openrouterApiKey : process.env.MLX_SERVE_API_KEY ?? process.env.GLB_OMLX_API_KEY ?? cfg.mlxServeApiKey ?? "local";
6647
+ const baseUrl = provider === "openrouter" ? process.env.OPENROUTER_BASE_URL ?? cfg.openrouterUrl ?? OPENROUTER_DEFAULT_URL : process.env.MLX_SERVE_BASE_URL ?? cfg.mlxServeUrl ?? MLX_SERVE_DEFAULT_URL;
6648
+ let models;
6649
+ try {
6650
+ models = await discoverModels(provider, apiKey, baseUrl);
6651
+ } catch (error) {
6652
+ const detail = error instanceof Error ? error.message : String(error);
6653
+ const tunnel = provider === "mlx-serve" && /127\.0\.0\.1|localhost/.test(baseUrl) ? "\nParmenide: apri in un altro terminale `ssh -N -L 11234:127.0.0.1:11234 parmenide`, poi riprova. Puoi impostare un URL raggiungibile con `ph config set mlx-serve-url <url>`." : "";
6654
+ throw new Error(`Cannot query ${provider} at ${baseUrl}: ${detail}${tunnel}`);
6655
+ }
6656
+ if (models.length === 0) {
6657
+ const tunnel = provider === "mlx-serve" ? "\nSe il CLI gira sul Mac e il server su Parmenide, apri: ssh -N -L 11234:127.0.0.1:11234 parmenide" : "";
6658
+ throw new Error(`No models returned by ${provider} at ${baseUrl}. Check connectivity and credentials.${tunnel}`);
6659
+ }
6660
+ console.log(`Models listed by ${provider} at ${baseUrl}:`);
6661
+ for (const model of models) {
6662
+ const status2 = model.loaded === void 0 ? "" : model.loaded ? " [loaded]" : ` [${model.state ?? "unloaded"}]`;
6663
+ console.log(` ${model.id}${model.name !== model.id ? ` \u2014 ${model.name}` : ""}${status2}`);
6664
+ }
6665
+ if (provider === "mlx-serve") {
6666
+ console.log("\nph can list installed models even when they are unloaded. Select a model marked [loaded] to run it immediately.");
6667
+ }
6668
+ }
6669
+
5442
6670
  // src/cli.ts
5443
6671
  var USAGE = `ph \u2014 prompt history tracker
5444
6672
 
@@ -5455,18 +6683,22 @@ USAGE:
5455
6683
  ph analyze-reusability [options] Analyze prompt reusability
5456
6684
  ph star <id> Toggle star on a prompt
5457
6685
  ph export <id> [--format txt|json|md] Export a single prompt
5458
- ph import gemini [--dry-run] [--analyze] [--filter] Import from Gemini CLI sessions
5459
- ph import claude [--dry-run] [--analyze] [--filter] Import from Claude CLI sessions
6686
+ ph import <source> [--dry-run] [--analyze] [--filter] [--file <path>]
6687
+ Import from Gemini, Claude, OpenCode, or Codex history
5460
6688
  ph analyze [--limit n] [--force] [--prune] [--dry-run] Analyze prompts with LLM
6689
+ ph models [openrouter|mlx-serve] List models from an AI provider
5461
6690
  ph mcp Start MCP server (Stdio)
5462
6691
  ph server [--port 3001] Start HTTP REST server for remote sync
5463
6692
  ph timeline [project] Show full project history with prompts and memories
6693
+ ph memory-migrate Merge existing memories into project_summaries
5464
6694
  ph remote push|pull|status Sync prompts with remote ph server
5465
6695
  ph cleanup [--dry-run] [--min-length N] [--min-score N] Remove useless prompts
6696
+ ph cleanup --retention [--dry-run] Archive prompts per retention policy
5466
6697
  ph cleanup-reusability [--dry-run] [--threshold 0.7] [--force] Cleanup based on reusability
5467
6698
  ph embed-all Generate embeddings for all prompts
5468
6699
  ph log --tool <name> --prompt <text> [--response <text>] Log a prompt+response directly
5469
- ph config set <key> <value> Save config value
6700
+ ph config show Show active AI provider configuration
6701
+ ph config set <key> [value] Save config value (secret keys prompt hidden)
5470
6702
  ph browse Interactive TUI browser
5471
6703
 
5472
6704
  WRAP FLAGS (placed before the tool name):
@@ -5476,7 +6708,7 @@ WRAP FLAGS (placed before the tool name):
5476
6708
 
5477
6709
  SEARCH OPTIONS:
5478
6710
  -i, --interactive Open results in interactive TUI browser
5479
- --tool <name> Filter by tool name (claude, gemini, \u2026)
6711
+ --tool <name> Filter by tool name (claude, gemini, opencode, codex, \u2026)
5480
6712
  --project <name> Filter by project name
5481
6713
  --language <lang> Filter by language (go, typescript, python, \u2026)
5482
6714
  --role <role> Filter by role (debug, refactor, explain, \u2026)
@@ -5486,6 +6718,7 @@ SEARCH OPTIONS:
5486
6718
  --min-relevance <n> Filter by min relevance (0-10)
5487
6719
  --top Show only top quality prompts (quality >= 8)
5488
6720
  --semantic Use semantic search (requires embeddings)
6721
+ --archive Search archived prompts
5489
6722
  --since YYYY-MM-DD
5490
6723
  --until YYYY-MM-DD
5491
6724
  --limit <n> Max results (default 50)
@@ -5503,6 +6736,7 @@ SESSIONS OPTIONS:
5503
6736
  --limit <n> Max sessions to show (default 20)
5504
6737
  --min-size <n> Minimum prompts per session (default 1)
5505
6738
  --no-cohesion Skip semantic cohesion computation
6739
+ --export <n> Export session N as markdown
5506
6740
 
5507
6741
  CLUSTER OPTIONS:
5508
6742
  -k <number> Number of clusters (default 5)
@@ -5538,7 +6772,46 @@ EXAMPLES:
5538
6772
  ph config set background-analysis true
5539
6773
  ph config set ollama-model llama3.2:latest
5540
6774
  ph ollama-models List models available on the local Ollama instance
6775
+ ph models openrouter Discover OpenRouter models
6776
+ ph models mlx-serve Discover models on MLX-Serve (default Parmenide tunnel)
5541
6777
  `;
6778
+ async function openBrowser(db, opts = {}) {
6779
+ let pendingRerun = null;
6780
+ process.stdout.write("\x1B[?1049h");
6781
+ try {
6782
+ const { waitUntilExit } = render(
6783
+ React8.createElement(BrowseApp, {
6784
+ db,
6785
+ ...opts,
6786
+ onRerun: (tool, prompt) => {
6787
+ pendingRerun = { tool, prompt };
6788
+ }
6789
+ })
6790
+ );
6791
+ await waitUntilExit();
6792
+ } finally {
6793
+ process.stdout.write("\x1B[?1049l");
6794
+ }
6795
+ return pendingRerun;
6796
+ }
6797
+ async function runRerun(rerun, dbPath, cfg) {
6798
+ if (!rerun) return;
6799
+ if (rerun.tool.startsWith("wise:")) {
6800
+ const providerId = rerun.tool.slice("wise:".length);
6801
+ const setupError = getProviderSetupError(cfg, providerId);
6802
+ if (setupError) throw new Error(setupError);
6803
+ const provider = getProvider(cfg, providerId);
6804
+ if (!provider) throw new Error(`Cannot create ${providerId} provider from the saved configuration.`);
6805
+ const response = await provider.generate(rerun.prompt);
6806
+ process.stdout.write(`${response}
6807
+ `);
6808
+ await cmdLog(dbPath, cfg, ["--tool", `wise:${providerId}`, "--prompt", rerun.prompt, "--response", response]);
6809
+ return;
6810
+ }
6811
+ const realBin = resolveRealBinary(rerun.tool);
6812
+ const child = spawnSync2(realBin, [rerun.prompt], { stdio: "inherit" });
6813
+ process.exit(child.status ?? 0);
6814
+ }
5542
6815
  async function main() {
5543
6816
  const argv = process.argv.slice(2);
5544
6817
  const cfg = load();
@@ -5549,28 +6822,9 @@ async function main() {
5549
6822
  if (argv.length === 0) {
5550
6823
  if (isTerminal()) {
5551
6824
  const db = new PhDB(dbPath);
5552
- process.stdout.write("\x1B[?1049h");
5553
- let pendingRerun = null;
5554
- try {
5555
- const { waitUntilExit } = render(
5556
- React7.createElement(BrowseApp, {
5557
- db,
5558
- onRerun: (tool, prompt) => {
5559
- pendingRerun = { tool, prompt };
5560
- }
5561
- })
5562
- );
5563
- await waitUntilExit();
5564
- } finally {
5565
- process.stdout.write("\x1B[?1049l");
5566
- db.close();
5567
- }
5568
- if (pendingRerun) {
5569
- const { tool, prompt } = pendingRerun;
5570
- const realBin = resolveRealBinary(tool);
5571
- const child = spawnSync2(realBin, [prompt], { stdio: "inherit" });
5572
- process.exit(child.status ?? 0);
5573
- }
6825
+ const rerun = await openBrowser(db);
6826
+ db.close();
6827
+ await runRerun(rerun, dbPath, cfg);
5574
6828
  process.exit(0);
5575
6829
  } else {
5576
6830
  process.stdout.write(USAGE);
@@ -5599,39 +6853,21 @@ async function main() {
5599
6853
  const query = positional.join(" ");
5600
6854
  if (flags["i"] || flags["interactive"]) {
5601
6855
  const db2 = new PhDB(dbPath);
5602
- process.stdout.write("\x1B[?1049h");
5603
- let pendingRerun = null;
5604
- try {
5605
- const { waitUntilExit } = render(
5606
- React7.createElement(BrowseApp, {
5607
- db: db2,
5608
- initialTextFilter: query,
5609
- initialFilters: {
5610
- tool: flags["tool"],
5611
- project: flags["project"],
5612
- language: flags["language"],
5613
- role: flags["role"],
5614
- tag: flags["tag"],
5615
- starred: Boolean(flags["starred"]),
5616
- minQuality: flags["top"] ? 8 : flags["min-quality"] ? Number(flags["min-quality"]) : void 0,
5617
- minRelevance: flags["min-relevance"] ? Number(flags["min-relevance"]) : void 0
5618
- },
5619
- onRerun: (tool, prompt) => {
5620
- pendingRerun = { tool, prompt };
5621
- }
5622
- })
5623
- );
5624
- await waitUntilExit();
5625
- } finally {
5626
- process.stdout.write("\x1B[?1049l");
5627
- db2.close();
5628
- }
5629
- if (pendingRerun) {
5630
- const { tool, prompt } = pendingRerun;
5631
- const realBin = resolveRealBinary(tool);
5632
- const child = spawnSync2(realBin, [prompt], { stdio: "inherit" });
5633
- process.exit(child.status ?? 0);
5634
- }
6856
+ const rerun = await openBrowser(db2, {
6857
+ initialTextFilter: query,
6858
+ initialFilters: {
6859
+ tool: flags["tool"],
6860
+ project: flags["project"],
6861
+ language: flags["language"],
6862
+ role: flags["role"],
6863
+ tag: flags["tag"],
6864
+ starred: Boolean(flags["starred"]),
6865
+ minQuality: flags["top"] ? 8 : flags["min-quality"] ? Number(flags["min-quality"]) : void 0,
6866
+ minRelevance: flags["min-relevance"] ? Number(flags["min-relevance"]) : void 0
6867
+ }
6868
+ });
6869
+ db2.close();
6870
+ await runRerun(rerun, dbPath, cfg);
5635
6871
  break;
5636
6872
  }
5637
6873
  const db = new PhDB(dbPath);
@@ -5705,6 +6941,16 @@ async function main() {
5705
6941
  db.close();
5706
6942
  break;
5707
6943
  }
6944
+ case "models": {
6945
+ await cmdModels(cfg, cmdArgs);
6946
+ break;
6947
+ }
6948
+ case "memory-migrate": {
6949
+ const db = new PhDB(dbPath);
6950
+ cmdMemoryMigrate(db);
6951
+ db.close();
6952
+ break;
6953
+ }
5708
6954
  case "mcp": {
5709
6955
  await runMCPServer();
5710
6956
  break;
@@ -5743,7 +6989,7 @@ async function main() {
5743
6989
  break;
5744
6990
  }
5745
6991
  case "config":
5746
- cmdConfig(cmdArgs);
6992
+ await cmdConfig(cmdArgs);
5747
6993
  break;
5748
6994
  case "_bg-analyze": {
5749
6995
  await cmdBackgroundAnalyze(dbPath, cmdArgs);
@@ -5751,28 +6997,9 @@ async function main() {
5751
6997
  }
5752
6998
  case "browse": {
5753
6999
  const db = new PhDB(dbPath);
5754
- process.stdout.write("\x1B[?1049h");
5755
- let pendingRerun = null;
5756
- try {
5757
- const { waitUntilExit } = render(
5758
- React7.createElement(BrowseApp, {
5759
- db,
5760
- onRerun: (tool, prompt) => {
5761
- pendingRerun = { tool, prompt };
5762
- }
5763
- })
5764
- );
5765
- await waitUntilExit();
5766
- } finally {
5767
- process.stdout.write("\x1B[?1049l");
5768
- db.close();
5769
- }
5770
- if (pendingRerun) {
5771
- const { tool, prompt } = pendingRerun;
5772
- const realBin = resolveRealBinary(tool);
5773
- const child = spawnSync2(realBin, [prompt], { stdio: "inherit" });
5774
- process.exit(child.status ?? 0);
5775
- }
7000
+ const rerun = await openBrowser(db);
7001
+ db.close();
7002
+ await runRerun(rerun, dbPath, cfg);
5776
7003
  break;
5777
7004
  }
5778
7005
  case "chat": {