@lumoai/cli 1.20.1 → 1.21.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.
|
@@ -34,6 +34,28 @@ When the session is bound (`lumo session attach <LUM-N>`), omit the identifier:
|
|
|
34
34
|
`lumo task memory add --category trap --trigger ... --outcome ...` records onto
|
|
35
35
|
the bound task; `lumo project memory add ...` records onto its project.
|
|
36
36
|
|
|
37
|
+
### Reconcile-on-write & deduplication
|
|
38
|
+
|
|
39
|
+
`memory add` does **not** unconditionally insert a new row. Before writing it:
|
|
40
|
+
|
|
41
|
+
1. Retrieves the nearest existing memories in the store (embedding similarity).
|
|
42
|
+
2. If a near-duplicate is found — including a **cross-language match** (e.g. you
|
|
43
|
+
supply content in Chinese but an equivalent English memory already exists) —
|
|
44
|
+
an LLM decides the outcome; otherwise the new memory is inserted directly.
|
|
45
|
+
|
|
46
|
+
The command prints **one** of these outcome lines:
|
|
47
|
+
|
|
48
|
+
| Output line | Meaning |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `Added <CATEGORY> <SCOPE> memory …` | No near-duplicate found; stored as a new row. |
|
|
51
|
+
| `Merged into existing memory <id> (near-duplicate) …` | Near-duplicate found; the existing row was refined/updated in-place (UPDATE). |
|
|
52
|
+
| `Superseded an existing memory; new version <id> …` | New content contradicts an old memory; old row invalidated, new row created (SUPERSEDE). |
|
|
53
|
+
| `Skipped — duplicate of existing memory <id>, nothing written …` | Exact or near-exact duplicate; no write performed (NOOP). |
|
|
54
|
+
|
|
55
|
+
Content is **always normalized to English** before storing — the memory store has
|
|
56
|
+
a single canonical language. If you supply text in another language the CLI
|
|
57
|
+
translates it automatically; the stored memory will be in English.
|
|
58
|
+
|
|
37
59
|
### When to record a memory (worthiness)
|
|
38
60
|
|
|
39
61
|
Record only knowledge that is **invisible in the codebase** — the _why_ behind a
|
|
@@ -82,5 +82,27 @@ async function memoryProjectAdd(refArg, options) {
|
|
|
82
82
|
console.error(m ? `Error: ${(0, sanitize_1.sanitizeField)(m)}` : `Error: memory add failed (HTTP ${res.status})`);
|
|
83
83
|
return 1;
|
|
84
84
|
}
|
|
85
|
-
|
|
85
|
+
let outcome = 'ADD';
|
|
86
|
+
let memId = '';
|
|
87
|
+
try {
|
|
88
|
+
const b = (await res.json());
|
|
89
|
+
if (typeof b.outcome === 'string')
|
|
90
|
+
outcome = b.outcome;
|
|
91
|
+
if (b.memory && typeof b.memory.id === 'string')
|
|
92
|
+
memId = b.memory.id;
|
|
93
|
+
}
|
|
94
|
+
catch { /* keep defaults */ }
|
|
95
|
+
switch (outcome) {
|
|
96
|
+
case 'NOOP':
|
|
97
|
+
process.stdout.write(`Skipped — duplicate of existing memory ${memId}, nothing written${echoSuffix}\n`);
|
|
98
|
+
break;
|
|
99
|
+
case 'UPDATE':
|
|
100
|
+
process.stdout.write(`Merged into existing memory ${memId} (near-duplicate)${echoSuffix}\n`);
|
|
101
|
+
break;
|
|
102
|
+
case 'SUPERSEDE':
|
|
103
|
+
process.stdout.write(`Superseded an existing memory; new version ${memId}${echoSuffix}\n`);
|
|
104
|
+
break;
|
|
105
|
+
default:
|
|
106
|
+
process.stdout.write(`Added ${built.category} PROJECT memory${echoSuffix}\n`);
|
|
107
|
+
}
|
|
86
108
|
}
|
|
@@ -93,7 +93,29 @@ async function memoryTaskAdd(identifierArg, options) {
|
|
|
93
93
|
console.error(m ? `Error: ${(0, sanitize_1.sanitizeField)(m)}` : `Error: memory add failed (HTTP ${res.status})`);
|
|
94
94
|
return 1;
|
|
95
95
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
const boundSuffix = identifierArg ? '' : ' (from bound session)';
|
|
97
|
+
let outcome = 'ADD';
|
|
98
|
+
let memId = '';
|
|
99
|
+
try {
|
|
100
|
+
const b = (await res.json());
|
|
101
|
+
if (typeof b.outcome === 'string')
|
|
102
|
+
outcome = b.outcome;
|
|
103
|
+
if (b.memory && typeof b.memory.id === 'string')
|
|
104
|
+
memId = b.memory.id;
|
|
105
|
+
}
|
|
106
|
+
catch { /* keep defaults */ }
|
|
107
|
+
switch (outcome) {
|
|
108
|
+
case 'NOOP':
|
|
109
|
+
process.stdout.write(`Skipped — duplicate of existing memory ${memId}, nothing written${boundSuffix}\n`);
|
|
110
|
+
break;
|
|
111
|
+
case 'UPDATE':
|
|
112
|
+
process.stdout.write(`Merged into existing memory ${memId} (near-duplicate)${boundSuffix}\n`);
|
|
113
|
+
break;
|
|
114
|
+
case 'SUPERSEDE':
|
|
115
|
+
process.stdout.write(`Superseded an existing memory; new version ${memId}${boundSuffix}\n`);
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
process.stdout.write(`Added ${built.category} memory to ${identifier}${boundSuffix}\n` +
|
|
119
|
+
'Tip: only useful for this task? If it generalizes, use `lumo project memory add` or `lumo memory promote` later.\n');
|
|
120
|
+
}
|
|
99
121
|
}
|
|
@@ -216,14 +216,17 @@ function resolveSessionStartStdout(responseBody, deps, now = new Date()) {
|
|
|
216
216
|
return [formatSuggestLine(sessionId, match)];
|
|
217
217
|
}
|
|
218
218
|
/**
|
|
219
|
-
* For stop/stop-failure hooks, read the transcript named in
|
|
220
|
-
* fold cumulative token totals into the body under
|
|
221
|
-
* can persist them on the Session.
|
|
222
|
-
*
|
|
223
|
-
*
|
|
219
|
+
* For stop/stop-failure/post-tool-batch hooks, read the transcript named in
|
|
220
|
+
* the payload and fold cumulative token totals into the body under
|
|
221
|
+
* `_lumo_usage` so the server can persist them on the Session. post-tool-batch
|
|
222
|
+
* carries usage so long single turns surface live burn before the first STOP
|
|
223
|
+
* (LUM-345); the server treats it as a flat-columns-only overwrite. Best-
|
|
224
|
+
* effort: returns the body unchanged on any failure (no transcript_path,
|
|
225
|
+
* unreadable, unparseable, no usage). The hook is never blocked or failed by
|
|
226
|
+
* this.
|
|
224
227
|
*/
|
|
225
228
|
function augmentBodyWithUsage(path, body) {
|
|
226
|
-
if (path !== 'stop' && path !== 'stop-failure')
|
|
229
|
+
if (path !== 'stop' && path !== 'stop-failure' && path !== 'post-tool-batch')
|
|
227
230
|
return body;
|
|
228
231
|
let parsed;
|
|
229
232
|
try {
|
|
@@ -2,31 +2,23 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.sumTranscriptUsage = sumTranscriptUsage;
|
|
4
4
|
const node_fs_1 = require("node:fs");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
5
6
|
function asNumber(v) {
|
|
6
7
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
|
7
8
|
}
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* Entries with no model string bucket under "unknown". Best-effort: returns
|
|
13
|
-
* null if the file can't be read or has no real assistant usage. Never throws.
|
|
10
|
+
* Sum `message.usage` across all (non-synthetic) assistant entries of one
|
|
11
|
+
* transcript JSONL file into the shared accumulators. Returns true if at
|
|
12
|
+
* least one real usage entry was seen. Never throws.
|
|
14
13
|
*/
|
|
15
|
-
function
|
|
14
|
+
function accumulateFileUsage(path, total, byModel) {
|
|
16
15
|
let text;
|
|
17
16
|
try {
|
|
18
|
-
text = (0, node_fs_1.readFileSync)(
|
|
17
|
+
text = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
19
18
|
}
|
|
20
19
|
catch {
|
|
21
|
-
return
|
|
20
|
+
return false;
|
|
22
21
|
}
|
|
23
|
-
const byModel = {};
|
|
24
|
-
const total = {
|
|
25
|
-
inputTokens: 0,
|
|
26
|
-
outputTokens: 0,
|
|
27
|
-
cacheReadTokens: 0,
|
|
28
|
-
cacheCreationTokens: 0,
|
|
29
|
-
};
|
|
30
22
|
let seen = false;
|
|
31
23
|
for (const raw of text.split('\n')) {
|
|
32
24
|
const line = raw.trim();
|
|
@@ -73,5 +65,46 @@ function sumTranscriptUsage(transcriptPath) {
|
|
|
73
65
|
m.cacheCreation += cacheCreation;
|
|
74
66
|
byModel[model] = m;
|
|
75
67
|
}
|
|
68
|
+
return seen;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Subagent (Task tool) turns are NOT in the main transcript — Claude Code
|
|
72
|
+
* writes them to sibling files at `<dir>/<sessionId>/subagents/agent-*.jsonl`.
|
|
73
|
+
* Resolve those for a main transcript path. Best-effort: returns [] when the
|
|
74
|
+
* directory is missing or unreadable. Never throws.
|
|
75
|
+
*/
|
|
76
|
+
function listSubagentTranscripts(transcriptPath) {
|
|
77
|
+
if (!transcriptPath.endsWith('.jsonl'))
|
|
78
|
+
return [];
|
|
79
|
+
const subagentsDir = (0, node_path_1.join)(transcriptPath.slice(0, -'.jsonl'.length), 'subagents');
|
|
80
|
+
try {
|
|
81
|
+
return (0, node_fs_1.readdirSync)(subagentsDir)
|
|
82
|
+
.filter(name => name.startsWith('agent-') && name.endsWith('.jsonl'))
|
|
83
|
+
.map(name => (0, node_path_1.join)(subagentsDir, name));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Read a Claude Code transcript JSONL — plus any subagent transcripts under
|
|
91
|
+
* `<dir>/<sessionId>/subagents/agent-*.jsonl` — and sum `message.usage`
|
|
92
|
+
* across all (non-synthetic) assistant entries, grouped by `message.model`
|
|
93
|
+
* -> cumulative session totals. Entries with no model string bucket under
|
|
94
|
+
* "unknown". Best-effort: returns null if nothing readable has real
|
|
95
|
+
* assistant usage. Never throws.
|
|
96
|
+
*/
|
|
97
|
+
function sumTranscriptUsage(transcriptPath) {
|
|
98
|
+
const byModel = {};
|
|
99
|
+
const total = {
|
|
100
|
+
inputTokens: 0,
|
|
101
|
+
outputTokens: 0,
|
|
102
|
+
cacheReadTokens: 0,
|
|
103
|
+
cacheCreationTokens: 0,
|
|
104
|
+
};
|
|
105
|
+
let seen = accumulateFileUsage(transcriptPath, total, byModel);
|
|
106
|
+
for (const path of listSubagentTranscripts(transcriptPath)) {
|
|
107
|
+
seen = accumulateFileUsage(path, total, byModel) || seen;
|
|
108
|
+
}
|
|
76
109
|
return seen ? { total, byModel } : null;
|
|
77
110
|
}
|