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