@lumoai/cli 1.20.1 → 1.20.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.
@@ -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
  }
@@ -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.20.2",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",