@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
- process.stdout.write(`Added ${built.category} PROJECT memory${echoSuffix}\n`);
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
- process.stdout.write(`Added ${built.category} memory to ${identifier}` +
97
- `${identifierArg ? '' : ' (from bound session)'}\n` +
98
- 'Tip: only useful for this task? If it generalizes, use `lumo project memory add` or `lumo memory promote` later.\n');
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 the payload and
220
- * fold cumulative token totals into the body under `_lumo_usage` so the server
221
- * can persist them on the Session. Best-effort: returns the body unchanged on
222
- * any failure (no transcript_path, unreadable, unparseable, no usage). The
223
- * hook is never blocked or failed by this.
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
- * Read a Claude Code transcript JSONL and sum `message.usage` across all
10
- * assistant entries, grouped by `message.model` -> cumulative session totals.
11
- * `<synthetic>` entries (local injections with no API cost) are skipped.
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 sumTranscriptUsage(transcriptPath) {
14
+ function accumulateFileUsage(path, total, byModel) {
16
15
  let text;
17
16
  try {
18
- text = (0, node_fs_1.readFileSync)(transcriptPath, 'utf8');
17
+ text = (0, node_fs_1.readFileSync)(path, 'utf8');
19
18
  }
20
19
  catch {
21
- return null;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.20.1",
3
+ "version": "1.21.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",