@mnemahq/cli 0.14.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/catalogue.mjs +3 -0
- package/src/cli.mjs +177 -2
- package/src/codex-rollout.mjs +138 -0
- package/src/gemini-chat.mjs +132 -0
- package/src/prd.mjs +97 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mnemahq/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Mnema CLI — connect a repo to your Mnema workspace: install session capture, sweep past sessions, and search from the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"htm": "^3.1.1",
|
|
35
35
|
"ink": "^6.8.0",
|
|
36
36
|
"react": "^19.2.7",
|
|
37
|
-
"@mnemahq/sdk": "0.
|
|
37
|
+
"@mnemahq/sdk": "0.5.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "node -e \"process.exit(0)\"",
|
package/src/catalogue.mjs
CHANGED
|
@@ -41,6 +41,8 @@ export const GROUPS = [
|
|
|
41
41
|
{ name: 'doctor', blurb: 'Diagnose install, hooks, auth, connectivity' },
|
|
42
42
|
{ name: 'sessions', blurb: 'List recent sessions for this repo (local + server)' },
|
|
43
43
|
{ name: 'sweep', blurb: 'Backfill past local sessions (opt-in)' },
|
|
44
|
+
{ name: 'codex-sweep', blurb: 'Backfill OpenAI Codex CLI sessions from ~/.codex rollouts' },
|
|
45
|
+
{ name: 'gemini-sweep', blurb: 'Backfill Google Gemini CLI sessions from ~/.gemini chats' },
|
|
44
46
|
{
|
|
45
47
|
name: 'binding', blurb: 'Task-binding miss rate, measured locally',
|
|
46
48
|
detail: 'Reads the PreToolUse counters. Reports NO DATA as no data — "0% miss rate" and "the hook never ran" are the same number and mean opposite things.',
|
|
@@ -48,6 +50,7 @@ export const GROUPS = [
|
|
|
48
50
|
examples: ['mnema binding', 'mnema binding --limit 30'],
|
|
49
51
|
},
|
|
50
52
|
{ name: 'pull', blurb: 'Export repo-bound docs into .mnema/context (server-is-truth)' },
|
|
53
|
+
{ name: 'prd init', blurb: 'Write a docs/prd.md draft from what Mnema observed (never commits)' },
|
|
51
54
|
],
|
|
52
55
|
},
|
|
53
56
|
{
|
package/src/cli.mjs
CHANGED
|
@@ -10,7 +10,12 @@
|
|
|
10
10
|
* mnema uninstall cleanly reverse everything
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { applyWrites, chooseProject, describeResult, planWrites } from './prd.mjs';
|
|
17
|
+
import { parseCodexRollout } from './codex-rollout.mjs';
|
|
18
|
+
import { parseGeminiChat } from './gemini-chat.mjs';
|
|
14
19
|
import { cmdLogin, cmdLogout, accessToken } from './login.mjs';
|
|
15
20
|
import { makeClient, call, hasApiKey, canAuthenticate, renderError, mintHookToken } from './client.mjs';
|
|
16
21
|
import {
|
|
@@ -71,7 +76,7 @@ const VERSION = JSON.parse(
|
|
|
71
76
|
* flag followed by an argument" from "a flag and its value" — the parser has to
|
|
72
77
|
* be told which is which, so it is.
|
|
73
78
|
*/
|
|
74
|
-
const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget']);
|
|
79
|
+
const VALUE_FLAGS = new Set(['workspace', 'origin', 'limit', 'status', 'project', 'repo', 'budget', 'days']);
|
|
75
80
|
|
|
76
81
|
export function parseFlags(argv) {
|
|
77
82
|
const flags = {}; const rest = [];
|
|
@@ -355,6 +360,113 @@ function cmdSweep() {
|
|
|
355
360
|
}
|
|
356
361
|
}
|
|
357
362
|
|
|
363
|
+
// ── codex-sweep ────────────────────────────────────────────────────────────────
|
|
364
|
+
// Backfill OpenAI Codex CLI sessions: read the local rollout JSONL traces under
|
|
365
|
+
// CODEX_HOME/sessions, parse each into a normalised session (see codex-rollout.mjs),
|
|
366
|
+
// and POST to /api/hooks/codex. Cost is computed server-side from tokens × pricing.
|
|
367
|
+
|
|
368
|
+
function codexHome() {
|
|
369
|
+
return process.env.CODEX_HOME || join(homedir(), '.codex');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function findRollouts(dir, out = []) {
|
|
373
|
+
let entries;
|
|
374
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
|
|
375
|
+
for (const e of entries) {
|
|
376
|
+
const p = join(dir, e.name);
|
|
377
|
+
if (e.isDirectory()) findRollouts(p, out);
|
|
378
|
+
else if (e.isFile() && /^rollout-.*\.jsonl$/.test(e.name)) out.push(p);
|
|
379
|
+
}
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function cmdCodexSweep(flags) {
|
|
384
|
+
const { origin, workspaceId } = resolveContext(flags);
|
|
385
|
+
if (!workspaceId) { console.error(c.red('Not linked — run `mnema init` first.')); process.exit(1); }
|
|
386
|
+
const token = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
387
|
+
if (!token) { console.error(c.red('No hook token stored. Run `mnema init` first.')); process.exit(1); }
|
|
388
|
+
|
|
389
|
+
const sessionsDir = join(codexHome(), 'sessions');
|
|
390
|
+
const files = findRollouts(sessionsDir);
|
|
391
|
+
if (!files.length) {
|
|
392
|
+
console.log(c.dim(`No Codex rollouts found under ${sessionsDir}`));
|
|
393
|
+
console.log(c.dim(' (set CODEX_HOME if your Codex data lives elsewhere.)'));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const limit = Number(flags.limit) || files.length;
|
|
398
|
+
const developerId = defaultDeveloperId();
|
|
399
|
+
const chosen = files.slice(-limit); // newest by path (…/YYYY/MM/DD/rollout-<ISO>-…)
|
|
400
|
+
let sent = 0, skipped = 0;
|
|
401
|
+
process.stdout.write(`Sweeping ${chosen.length} Codex session(s)… `);
|
|
402
|
+
for (const file of chosen) {
|
|
403
|
+
let text;
|
|
404
|
+
try { text = readFileSync(file, 'utf8'); } catch { skipped++; continue; }
|
|
405
|
+
let payload;
|
|
406
|
+
try { payload = parseCodexRollout(text, { developerId }); } catch { payload = null; }
|
|
407
|
+
if (!payload || !payload.session_id) { skipped++; continue; }
|
|
408
|
+
try {
|
|
409
|
+
const r = await apiFetch(origin, '/api/hooks/codex', { method: 'POST', token, body: payload });
|
|
410
|
+
if (r.ok || r.status === 202) sent++; else skipped++;
|
|
411
|
+
} catch { skipped++; }
|
|
412
|
+
}
|
|
413
|
+
console.log(c.green('done'));
|
|
414
|
+
console.log(c.green(`✓ ${sent} session(s) sent`) + (skipped ? c.dim(`, ${skipped} skipped`) : ''));
|
|
415
|
+
console.log(c.dim(' They appear under Sessions shortly, with cost computed from tokens × model pricing.'));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ── gemini-sweep ─────────────────────────────────────────────────────────────
|
|
419
|
+
// Backfill Google Gemini CLI sessions: read the chat recordings under
|
|
420
|
+
// ~/.gemini/tmp/<project_hash>/chats/, parse each (see gemini-chat.mjs), and POST
|
|
421
|
+
// to /api/hooks/gemini. Gemini CLI records real per-turn tokens, so cost computes.
|
|
422
|
+
|
|
423
|
+
function geminiChatFiles(root, out = []) {
|
|
424
|
+
let entries;
|
|
425
|
+
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return out; }
|
|
426
|
+
for (const e of entries) {
|
|
427
|
+
const p = join(root, e.name);
|
|
428
|
+
// Recurse into tmp/<hash>/chats; collect the chat files (json/jsonl) within.
|
|
429
|
+
if (e.isDirectory()) geminiChatFiles(p, out);
|
|
430
|
+
else if (e.isFile() && /\.(jsonl?|json)$/i.test(e.name) && /chats?[\\/]/.test(p)) out.push(p);
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function cmdGeminiSweep(flags) {
|
|
436
|
+
const { origin, workspaceId } = resolveContext(flags);
|
|
437
|
+
if (!workspaceId) { console.error(c.red('Not linked — run `mnema init` first.')); process.exit(1); }
|
|
438
|
+
const token = process.env.MNEMA_HOOK_TOKEN || getSecret(workspaceId, 'hook-token');
|
|
439
|
+
if (!token) { console.error(c.red('No hook token stored. Run `mnema init` first.')); process.exit(1); }
|
|
440
|
+
|
|
441
|
+
const geminiHome = process.env.GEMINI_HOME || join(homedir(), '.gemini');
|
|
442
|
+
const files = geminiChatFiles(join(geminiHome, 'tmp'));
|
|
443
|
+
if (!files.length) {
|
|
444
|
+
console.log(c.dim(`No Gemini chats found under ${join(geminiHome, 'tmp')}`));
|
|
445
|
+
console.log(c.dim(' (set GEMINI_HOME if your Gemini CLI data lives elsewhere.)'));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const limit = Number(flags.limit) || files.length;
|
|
450
|
+
const developerId = defaultDeveloperId();
|
|
451
|
+
const chosen = files.slice(-limit);
|
|
452
|
+
let sent = 0, skipped = 0;
|
|
453
|
+
process.stdout.write(`Sweeping ${chosen.length} Gemini session(s)… `);
|
|
454
|
+
for (const file of chosen) {
|
|
455
|
+
let text;
|
|
456
|
+
try { text = readFileSync(file, 'utf8'); } catch { skipped++; continue; }
|
|
457
|
+
let payload;
|
|
458
|
+
try { payload = parseGeminiChat(text, { developerId }); } catch { payload = null; }
|
|
459
|
+
if (!payload || !payload.session_id) { skipped++; continue; }
|
|
460
|
+
try {
|
|
461
|
+
const r = await apiFetch(origin, '/api/hooks/gemini', { method: 'POST', token, body: payload });
|
|
462
|
+
if (r.ok || r.status === 202) sent++; else skipped++;
|
|
463
|
+
} catch { skipped++; }
|
|
464
|
+
}
|
|
465
|
+
console.log(c.green('done'));
|
|
466
|
+
console.log(c.green(`✓ ${sent} session(s) sent`) + (skipped ? c.dim(`, ${skipped} skipped`) : ''));
|
|
467
|
+
console.log(c.dim(' They appear under Sessions shortly, with cost computed from tokens × model pricing.'));
|
|
468
|
+
}
|
|
469
|
+
|
|
358
470
|
// ── search ───────────────────────────────────────────────────────────────────────
|
|
359
471
|
|
|
360
472
|
async function cmdSearch(flags, rest) {
|
|
@@ -748,6 +860,66 @@ function cmdBinding(flags) {
|
|
|
748
860
|
console.log(formatBindingStats(collectBindingStats(Number.isFinite(days) ? days : 7)));
|
|
749
861
|
}
|
|
750
862
|
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* `mnema prd init` — fetch the declaration draft and write it into this repo.
|
|
866
|
+
*
|
|
867
|
+
* ⛔ Writes into the WORKING TREE only. Never commits, never pushes. The
|
|
868
|
+
* declaration is authored by a person; that seam is the point.
|
|
869
|
+
*/
|
|
870
|
+
async function cmdPrd(flags, rest) {
|
|
871
|
+
const sub = (rest[0] || '').toLowerCase();
|
|
872
|
+
if (sub !== 'init') {
|
|
873
|
+
console.error(c.red(`Unknown prd subcommand: ${sub || '(none)'}`));
|
|
874
|
+
console.error(c.dim('Usage: mnema prd init [--project <name|id>] [--days 365] [--force]'));
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const { root, origin, workspaceId } = resolveContext(flags);
|
|
879
|
+
if (!workspaceId) {
|
|
880
|
+
console.error(c.red('Not linked — run `mnema init` first.'));
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
let projects = [];
|
|
885
|
+
try {
|
|
886
|
+
projects = (await call({ origin, workspaceId }, (m) => m.projects.list({ limit: 100 }).all())) ?? [];
|
|
887
|
+
} catch (e) { renderError(e, { context: 'prd init', usedApiKey: hasApiKey(workspaceId) }); return; }
|
|
888
|
+
|
|
889
|
+
const choice = chooseProject(projects, flags.project);
|
|
890
|
+
if (!choice.ok) {
|
|
891
|
+
// ⚠️ Never guess. Print what there is to choose from — a wrong project would
|
|
892
|
+
// write another project's features into this repo's PRD.
|
|
893
|
+
const why = choice.reason === 'none-given'
|
|
894
|
+
? 'Which project is this repo?'
|
|
895
|
+
: choice.reason === 'ambiguous' ? 'That matched more than one project.' : 'No project matched that.';
|
|
896
|
+
console.error(c.yellow(why));
|
|
897
|
+
console.error(c.dim(' mnema prd init --project <name or id>'));
|
|
898
|
+
console.error('');
|
|
899
|
+
for (const p of (choice.projects || []).slice(0, 25)) {
|
|
900
|
+
console.error(` ${c.dim(p.id)} ${p.name}`);
|
|
901
|
+
}
|
|
902
|
+
process.exit(1);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
let draft;
|
|
906
|
+
try {
|
|
907
|
+
draft = await call({ origin, workspaceId }, (m) =>
|
|
908
|
+
m.prd.draft(choice.project.id, flags.days ? Number(flags.days) : undefined));
|
|
909
|
+
} catch (e) { renderError(e, { context: 'prd init', usedApiKey: hasApiKey(workspaceId) }); return; }
|
|
910
|
+
|
|
911
|
+
if (!draft || draft.available === false) {
|
|
912
|
+
// A normal answer, not a failure: say why and stop.
|
|
913
|
+
console.error(c.yellow(draft?.reason || 'No draft available for that project.'));
|
|
914
|
+
process.exit(1);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const plan = planWrites(root, draft.files, { force: Boolean(flags.force) });
|
|
918
|
+
applyWrites(plan);
|
|
919
|
+
console.log('');
|
|
920
|
+
console.log(describeResult(draft, plan));
|
|
921
|
+
}
|
|
922
|
+
|
|
751
923
|
switch (cmd) {
|
|
752
924
|
case 'login': return cmdLogin(flags);
|
|
753
925
|
case 'logout': return cmdLogout();
|
|
@@ -755,7 +927,10 @@ function cmdBinding(flags) {
|
|
|
755
927
|
case 'status': return cmdStatus(flags);
|
|
756
928
|
case 'sessions': return cmdSessions(flags);
|
|
757
929
|
case 'sweep': return cmdSweep();
|
|
930
|
+
case 'codex-sweep': return cmdCodexSweep(flags);
|
|
931
|
+
case 'gemini-sweep': return cmdGeminiSweep(flags);
|
|
758
932
|
case 'pull': return cmdPull(flags);
|
|
933
|
+
case 'prd': return cmdPrd(flags, rest);
|
|
759
934
|
case 'search': return cmdSearch(flags, rest);
|
|
760
935
|
case 'doctor': return cmdDoctor(flags);
|
|
761
936
|
case 'binding': return cmdBinding(flags);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an OpenAI Codex CLI "rollout" JSONL trace into a normalised Mnema session
|
|
3
|
+
* payload (the same shape the Claude Code hook POSTs, so the server adapter + worker
|
|
4
|
+
* are shared).
|
|
5
|
+
*
|
|
6
|
+
* Rollout files live at CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ISO>-<UUID>.jsonl
|
|
7
|
+
* (CODEX_HOME defaults to ~/.codex). Each line is `{timestamp, type, payload}`.
|
|
8
|
+
* The fields we extract (verified against the documented format — PR openai/codex#1583
|
|
9
|
+
* added token counts, timestamps + model to rollouts; ccusage parses the same events):
|
|
10
|
+
* - session_meta → session id, cwd, git info, (sometimes) model
|
|
11
|
+
* - turn_context → the active model (latest wins)
|
|
12
|
+
* - token_count → total_token_usage {input, cached_input, output, reasoning_output}
|
|
13
|
+
* - function_call → tool count; apply_patch args → files touched
|
|
14
|
+
*
|
|
15
|
+
* Version-tolerant: the payload nesting has changed across Codex releases, so every
|
|
16
|
+
* lookup is defensive and unknown lines are skipped, never thrown on. Cost is NOT in
|
|
17
|
+
* the rollout — the server computes it from `model` + `usage` via model_pricing.
|
|
18
|
+
*
|
|
19
|
+
* NOTE: token_count has historically been absent from non-interactive `codex exec`
|
|
20
|
+
* sessions (openai/codex#9660); such a session yields no `usage` and the server records
|
|
21
|
+
* it with zero cost rather than a wrong one.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
function num(v) {
|
|
25
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Pull file paths out of an apply_patch tool call's argument string. */
|
|
29
|
+
export function extractPatchFiles(argsRaw) {
|
|
30
|
+
const out = [];
|
|
31
|
+
let text = argsRaw;
|
|
32
|
+
if (typeof text !== 'string') {
|
|
33
|
+
try { text = JSON.stringify(argsRaw ?? ''); } catch { return out; }
|
|
34
|
+
}
|
|
35
|
+
// apply_patch envelopes use "*** Add File: path" / "Update File" / "Delete File".
|
|
36
|
+
const re = /\*\*\*\s+(?:Add|Update|Delete)\s+File:\s+(.+)/g;
|
|
37
|
+
let m;
|
|
38
|
+
while ((m = re.exec(text)) !== null) {
|
|
39
|
+
const p = m[1].trim();
|
|
40
|
+
if (p) out.push(p);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Codex tags events on TWO levels, so we match on both:
|
|
47
|
+
* - session_meta / turn_context are top-level `type`, data in `payload` (or flat).
|
|
48
|
+
* - token_count / function_call are the INNER `payload.type`, wrapped by an outer
|
|
49
|
+
* `event_msg` / `response_item` type. Matching only `obj.type` misses them.
|
|
50
|
+
*/
|
|
51
|
+
function classify(obj) {
|
|
52
|
+
const inner = obj.payload && typeof obj.payload === 'object' ? obj.payload : obj;
|
|
53
|
+
const topType = typeof obj.type === 'string' ? obj.type : undefined;
|
|
54
|
+
const innerType = typeof inner.type === 'string' ? inner.type : topType;
|
|
55
|
+
return { inner, topType, innerType };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {string} jsonlText full contents of one rollout-*.jsonl file
|
|
60
|
+
* @param {{ sessionId?: string, developerId?: string }} [opts]
|
|
61
|
+
* @returns {object|null} normalised session payload, or null if no session id found
|
|
62
|
+
*/
|
|
63
|
+
export function parseCodexRollout(jsonlText, opts = {}) {
|
|
64
|
+
const lines = String(jsonlText).split(/\r?\n/);
|
|
65
|
+
let sessionId = opts.sessionId ?? null;
|
|
66
|
+
let model = null;
|
|
67
|
+
let cwd = null;
|
|
68
|
+
let gitRoot = null;
|
|
69
|
+
let gitBranch = null;
|
|
70
|
+
let gitRemote = null;
|
|
71
|
+
let usage = null;
|
|
72
|
+
let startTs = null;
|
|
73
|
+
let endTs = null;
|
|
74
|
+
let toolCount = 0;
|
|
75
|
+
const files = new Set();
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed) continue;
|
|
80
|
+
let obj;
|
|
81
|
+
try { obj = JSON.parse(trimmed); } catch { continue; }
|
|
82
|
+
|
|
83
|
+
if (typeof obj.timestamp === 'string') {
|
|
84
|
+
if (!startTs) startTs = obj.timestamp;
|
|
85
|
+
endTs = obj.timestamp;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { inner, topType, innerType } = classify(obj);
|
|
89
|
+
|
|
90
|
+
if (topType === 'session_meta' || innerType === 'session_meta') {
|
|
91
|
+
sessionId = sessionId ?? inner.id ?? inner.session_id ?? null;
|
|
92
|
+
cwd = cwd ?? inner.cwd ?? null;
|
|
93
|
+
model = model ?? inner.model ?? null;
|
|
94
|
+
const git = inner.git ?? inner.git_info ?? {};
|
|
95
|
+
gitRoot = gitRoot ?? git.repository_root ?? git.root ?? null;
|
|
96
|
+
gitBranch = gitBranch ?? git.branch ?? null;
|
|
97
|
+
gitRemote = gitRemote ?? git.remote_url ?? git.origin_url ?? git.remote ?? null;
|
|
98
|
+
} else if (topType === 'turn_context' || innerType === 'turn_context') {
|
|
99
|
+
if (typeof inner.model === 'string') model = inner.model; // latest wins
|
|
100
|
+
cwd = cwd ?? inner.cwd ?? null;
|
|
101
|
+
} else if (innerType === 'token_count') {
|
|
102
|
+
const tot = inner.total_token_usage ?? inner.info?.total_token_usage ?? inner.total ?? null;
|
|
103
|
+
if (tot && typeof tot === 'object') {
|
|
104
|
+
usage = {
|
|
105
|
+
input_tokens: num(tot.input_tokens),
|
|
106
|
+
output_tokens: num(tot.output_tokens) + num(tot.reasoning_output_tokens),
|
|
107
|
+
cache_read_tokens: num(tot.cached_input_tokens),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
} else if (innerType === 'function_call') {
|
|
111
|
+
toolCount += 1;
|
|
112
|
+
const name = inner.name ?? '';
|
|
113
|
+
if (name === 'apply_patch' || name === 'shell' || name === 'local_shell') {
|
|
114
|
+
for (const f of extractPatchFiles(inner.arguments)) files.add(f);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!sessionId) return null;
|
|
120
|
+
|
|
121
|
+
const payload = {
|
|
122
|
+
session_id: sessionId,
|
|
123
|
+
hook_event_name: 'SessionEnd',
|
|
124
|
+
usage_cumulative: true,
|
|
125
|
+
developer_id: opts.developerId,
|
|
126
|
+
files_changed: [...files].map((path) => ({ path, added: null, removed: null })),
|
|
127
|
+
};
|
|
128
|
+
if (model) payload.model = model;
|
|
129
|
+
if (usage) payload.usage = usage;
|
|
130
|
+
if (cwd) payload.cwd = cwd;
|
|
131
|
+
if (gitRoot) payload.git_root = gitRoot;
|
|
132
|
+
if (gitBranch) payload.git_branch = gitBranch;
|
|
133
|
+
if (gitRemote) payload.git_remote = gitRemote;
|
|
134
|
+
if (toolCount > 0) payload.tool_count = toolCount;
|
|
135
|
+
if (startTs) payload.started_at = startTs;
|
|
136
|
+
if (endTs) payload.ended_at = endTs;
|
|
137
|
+
return payload;
|
|
138
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a Google Gemini CLI chat-recording file into a normalised Mnema session
|
|
3
|
+
* payload (the same shape the Claude Code hook + Codex reader POST, so the server
|
|
4
|
+
* adapter + worker are shared).
|
|
5
|
+
*
|
|
6
|
+
* Gemini CLI's chatRecordingService writes conversations under
|
|
7
|
+
* ~/.gemini/tmp/<project_hash>/chats/
|
|
8
|
+
* as a ConversationRecord — { sessionId, projectHash, model, messages[] } — where
|
|
9
|
+
* each message carries per-turn token usage (Gemini's usageMetadata:
|
|
10
|
+
* promptTokenCount / candidatesTokenCount / cachedContentTokenCount / total).
|
|
11
|
+
* Unlike Codex's cumulative token_count, Gemini logs usage PER TURN, so the
|
|
12
|
+
* session total is the SUM across messages.
|
|
13
|
+
*
|
|
14
|
+
* Format-tolerant: the file may be one JSON object (the whole ConversationRecord)
|
|
15
|
+
* or JSONL (a header line + one message per line), and field names have drifted
|
|
16
|
+
* (usageMetadata vs tokens; camelCase vs snake_case). Every lookup is defensive and
|
|
17
|
+
* malformed input is skipped, never thrown on.
|
|
18
|
+
*
|
|
19
|
+
* Gemini CLI records real tokens (this is why it was chosen over Antigravity CLI,
|
|
20
|
+
* which does not) — but no USD; the server computes cost from model + usage.
|
|
21
|
+
*
|
|
22
|
+
* NOTE: verified against the DOCUMENTED shape (chatRecordingTypes.ts + Google's
|
|
23
|
+
* session docs), not yet a live ~/.gemini file — validate against a real chat
|
|
24
|
+
* before trusting cost numbers in prod.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
function num(v) {
|
|
28
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a usageMetadata-ish object off a message, tolerating field-name variants. */
|
|
32
|
+
function readUsage(m) {
|
|
33
|
+
const u = m.usageMetadata ?? m.usage ?? m.tokens ?? m.tokenUsage ?? null;
|
|
34
|
+
if (!u || typeof u !== 'object') return null;
|
|
35
|
+
const input = num(u.promptTokenCount ?? u.inputTokens ?? u.input_tokens ?? u.prompt_tokens);
|
|
36
|
+
const output = num(u.candidatesTokenCount ?? u.outputTokens ?? u.output_tokens ?? u.candidates_tokens);
|
|
37
|
+
const cached = num(u.cachedContentTokenCount ?? u.cachedTokens ?? u.cache_read_tokens ?? u.cached_tokens);
|
|
38
|
+
if (input === 0 && output === 0 && cached === 0) return null;
|
|
39
|
+
return { input, output, cached };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Pull edited file paths out of a message's tool calls. */
|
|
43
|
+
function filesFromMessage(m, into) {
|
|
44
|
+
const calls = m.toolCalls ?? m.tool_calls ?? m.functionCalls ?? [];
|
|
45
|
+
if (!Array.isArray(calls)) return;
|
|
46
|
+
for (const call of calls) {
|
|
47
|
+
const name = call.name ?? call.tool ?? call.functionName ?? '';
|
|
48
|
+
if (!/write_file|replace|edit|create_file|apply/i.test(String(name))) continue;
|
|
49
|
+
const args = call.args ?? call.arguments ?? call.input ?? {};
|
|
50
|
+
const path = args.file_path ?? args.absolute_path ?? args.path ?? args.filePath ?? args.filename;
|
|
51
|
+
if (typeof path === 'string' && path.trim()) into.add(path.trim());
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Coerce a file's contents into an array of records (whole-object or JSONL). */
|
|
56
|
+
function toRecords(text) {
|
|
57
|
+
const trimmed = String(text).trim();
|
|
58
|
+
if (!trimmed) return [];
|
|
59
|
+
// Try the whole file as one JSON value first (the common ConversationRecord case).
|
|
60
|
+
try {
|
|
61
|
+
const whole = JSON.parse(trimmed);
|
|
62
|
+
return Array.isArray(whole) ? whole : [whole];
|
|
63
|
+
} catch { /* fall through to JSONL */ }
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
66
|
+
const t = line.trim();
|
|
67
|
+
if (!t) continue;
|
|
68
|
+
try { out.push(JSON.parse(t)); } catch { /* skip malformed line */ }
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} text contents of one Gemini chat file
|
|
75
|
+
* @param {{ sessionId?: string, developerId?: string }} [opts]
|
|
76
|
+
* @returns {object|null} normalised session payload, or null if no session id
|
|
77
|
+
*/
|
|
78
|
+
export function parseGeminiChat(text, opts = {}) {
|
|
79
|
+
const records = toRecords(text);
|
|
80
|
+
if (records.length === 0) return null;
|
|
81
|
+
|
|
82
|
+
let sessionId = opts.sessionId ?? null;
|
|
83
|
+
let model = null;
|
|
84
|
+
let startTs = null;
|
|
85
|
+
let endTs = null;
|
|
86
|
+
const usage = { input: 0, output: 0, cached: 0 };
|
|
87
|
+
let sawUsage = false;
|
|
88
|
+
const files = new Set();
|
|
89
|
+
let turns = 0;
|
|
90
|
+
|
|
91
|
+
// A record is either a header (sessionId/projectHash/model + maybe messages[])
|
|
92
|
+
// or a single message. Handle both, and recurse into an embedded messages[].
|
|
93
|
+
const messages = [];
|
|
94
|
+
for (const rec of records) {
|
|
95
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
96
|
+
sessionId = sessionId ?? rec.sessionId ?? rec.session_id ?? rec.id ?? null;
|
|
97
|
+
if (typeof rec.model === 'string') model = rec.model;
|
|
98
|
+
if (rec.startTime ?? rec.start_time) startTs = startTs ?? (rec.startTime ?? rec.start_time);
|
|
99
|
+
if (rec.lastUpdated ?? rec.last_updated) endTs = rec.lastUpdated ?? rec.last_updated;
|
|
100
|
+
if (Array.isArray(rec.messages)) messages.push(...rec.messages);
|
|
101
|
+
else if (rec.role || rec.content || rec.usageMetadata || rec.tokens) messages.push(rec);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const m of messages) {
|
|
105
|
+
if (!m || typeof m !== 'object') continue;
|
|
106
|
+
turns += 1;
|
|
107
|
+
if (typeof m.model === 'string' && !model) model = m.model;
|
|
108
|
+
const ts = m.timestamp ?? m.time ?? m.created_at ?? m.createdAt;
|
|
109
|
+
if (ts) { startTs = startTs ?? ts; endTs = ts; }
|
|
110
|
+
const u = readUsage(m);
|
|
111
|
+
if (u) { usage.input += u.input; usage.output += u.output; usage.cached += u.cached; sawUsage = true; }
|
|
112
|
+
filesFromMessage(m, files);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!sessionId) return null;
|
|
116
|
+
|
|
117
|
+
const payload = {
|
|
118
|
+
session_id: sessionId,
|
|
119
|
+
hook_event_name: 'SessionEnd',
|
|
120
|
+
usage_cumulative: true, // we already summed to the session total
|
|
121
|
+
developer_id: opts.developerId,
|
|
122
|
+
files_changed: [...files].map((path) => ({ path, added: null, removed: null })),
|
|
123
|
+
};
|
|
124
|
+
if (model) payload.model = model;
|
|
125
|
+
if (sawUsage) {
|
|
126
|
+
payload.usage = { input_tokens: usage.input, output_tokens: usage.output, cache_read_tokens: usage.cached };
|
|
127
|
+
}
|
|
128
|
+
if (turns > 0) payload.tool_count = turns;
|
|
129
|
+
if (startTs) payload.started_at = String(startTs);
|
|
130
|
+
if (endTs) payload.ended_at = String(endTs);
|
|
131
|
+
return payload;
|
|
132
|
+
}
|
package/src/prd.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mnema prd init` — write the declaration bootstrap into this repo (t-934).
|
|
3
|
+
*
|
|
4
|
+
* ⭐ THIS COMMAND WAS ADVERTISED BEFORE IT EXISTED. The app's bootstrap prompt
|
|
5
|
+
* has told people to run `npx mnema prd init` since it shipped. There was no
|
|
6
|
+
* `prd` command in any published version, so every reader who followed the
|
|
7
|
+
* instruction got "Unknown command: prd". The generator behind the promise was
|
|
8
|
+
* real — it had no route a CLI could reach, and no CLI caller.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ MNEMA WRITES INTO A WORKING TREE, NEVER INTO HISTORY. It does not commit,
|
|
11
|
+
* and it does not push. The declaration is authored in the repo BY A PERSON;
|
|
12
|
+
* Mnema reading its own output back as though a human had written it is the seam
|
|
13
|
+
* the whole declaration build rests on. So: write the files, tell the reader to
|
|
14
|
+
* review them, stop.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pick the project to draft for.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ NO GUESSING FROM THE GIT REMOTE. The projects API returns `repoId`, not a
|
|
23
|
+
* repo name, so matching a remote would mean a second lookup per project and a
|
|
24
|
+
* confident wrong answer when two projects share a repo. An explicit choice, or
|
|
25
|
+
* a list to choose from, is the honest interface.
|
|
26
|
+
*/
|
|
27
|
+
export function chooseProject(projects, wanted) {
|
|
28
|
+
if (!wanted) return { ok: false, reason: 'none-given', projects };
|
|
29
|
+
const want = String(wanted).trim().toLowerCase();
|
|
30
|
+
const byId = projects.find((p) => p.id.toLowerCase() === want);
|
|
31
|
+
if (byId) return { ok: true, project: byId };
|
|
32
|
+
const byName = projects.filter((p) => p.name.toLowerCase() === want);
|
|
33
|
+
if (byName.length === 1) return { ok: true, project: byName[0] };
|
|
34
|
+
if (byName.length > 1) return { ok: false, reason: 'ambiguous', projects: byName };
|
|
35
|
+
const partial = projects.filter((p) => p.name.toLowerCase().includes(want));
|
|
36
|
+
if (partial.length === 1) return { ok: true, project: partial[0] };
|
|
37
|
+
if (partial.length > 1) return { ok: false, reason: 'ambiguous', projects: partial };
|
|
38
|
+
return { ok: false, reason: 'no-match', projects };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Decide what to do with each file the server returned.
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ NEVER OVERWRITE SILENTLY. `docs/prd.md` is the declaration — a human wrote
|
|
45
|
+
* it, and it is the source of truth for what every feature claims. Replacing one
|
|
46
|
+
* without asking would destroy exactly the authorship this design protects.
|
|
47
|
+
* Identical content is a no-op, not a write, so re-running is safe.
|
|
48
|
+
*/
|
|
49
|
+
export function planWrites(root, files, { force = false } = {}) {
|
|
50
|
+
return Object.entries(files).map(([rel, content]) => {
|
|
51
|
+
const abs = join(root, rel);
|
|
52
|
+
if (!existsSync(abs)) return { rel, abs, content, action: 'create' };
|
|
53
|
+
const current = readFileSync(abs, 'utf8');
|
|
54
|
+
if (current === content) return { rel, abs, content, action: 'unchanged' };
|
|
55
|
+
return { rel, abs, content, action: force ? 'overwrite' : 'skip-exists' };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Apply a plan. Returns the plan, annotated with what happened. */
|
|
60
|
+
export function applyWrites(plan) {
|
|
61
|
+
for (const item of plan) {
|
|
62
|
+
if (item.action === 'create' || item.action === 'overwrite') {
|
|
63
|
+
mkdirSync(dirname(item.abs), { recursive: true });
|
|
64
|
+
writeFileSync(item.abs, item.content, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return plan;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The report a reader needs after a draft is written. */
|
|
71
|
+
export function describeResult(draft, plan) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const wrote = plan.filter((p) => p.action === 'create' || p.action === 'overwrite');
|
|
74
|
+
const skipped = plan.filter((p) => p.action === 'skip-exists');
|
|
75
|
+
const same = plan.filter((p) => p.action === 'unchanged');
|
|
76
|
+
|
|
77
|
+
for (const p of wrote) lines.push(` wrote ${p.rel}`);
|
|
78
|
+
for (const p of same) lines.push(` unchanged ${p.rel}`);
|
|
79
|
+
for (const p of skipped) lines.push(` kept ${p.rel} (already exists — pass --force to replace)`);
|
|
80
|
+
|
|
81
|
+
lines.push('');
|
|
82
|
+
lines.push(` ${draft.kept.length} feature${draft.kept.length === 1 ? '' : 's'} drafted from ` +
|
|
83
|
+
`${draft.observedScopes} observed scope${draft.observedScopes === 1 ? '' : 's'} ` +
|
|
84
|
+
`over ${draft.windowDays} days`);
|
|
85
|
+
|
|
86
|
+
const rejected = Array.isArray(draft.rejected) ? draft.rejected.length : 0;
|
|
87
|
+
if (rejected > 0) {
|
|
88
|
+
// ⚠️ Reported, not hidden. A scope screened out is a feature the draft does
|
|
89
|
+
// NOT claim — leaving that silent would let someone believe the file covers
|
|
90
|
+
// everything.
|
|
91
|
+
lines.push(` ${rejected} scope${rejected === 1 ? '' : 's'} screened out (too little evidence to declare)`);
|
|
92
|
+
}
|
|
93
|
+
lines.push('');
|
|
94
|
+
lines.push(' Review it, correct it, and commit it. Mnema does not commit — the');
|
|
95
|
+
lines.push(' declaration is yours, and that is what makes it worth trusting.');
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|