@lumoai/cli 1.40.0 → 1.41.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/assets/skill/references/memory.md +28 -7
- package/assets/skill/references/sessions.md +4 -0
- package/dist/cli/src/commands/session-attach.js +29 -0
- package/dist/cli/src/lib/apply-sync.js +2 -0
- package/dist/cli/src/lib/hook-runner.js +28 -0
- package/dist/cli/src/lib/memory-auto.js +114 -0
- package/dist/cli/src/lib/sync-throttle.js +71 -0
- package/package.json +1 -1
|
@@ -97,13 +97,18 @@ during the transition both paths are active and overlap is expected, not a bug.
|
|
|
97
97
|
- **Only touches what it owns**: a file is owned only if its frontmatter carries
|
|
98
98
|
`metadata.lumo.source: team`. Your own hand-written memory files and any
|
|
99
99
|
`MEMORY.md` lines outside the managed block are **never** read or written.
|
|
100
|
-
- **
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
100
|
+
- **Mirrors the whole project (LUM-552)**: the bundle is the project's **full
|
|
101
|
+
ACTIVE memory set** — every dev in the project gets the same corpus, regardless
|
|
102
|
+
of how many active tasks they hold. There is **no** task-level routing/relevance
|
|
103
|
+
filter and **no** token-budget cap on the corpus at sync time: sync only writes
|
|
104
|
+
local files (no tokens, no conversation), so "which memory is relevant" is left
|
|
105
|
+
to Claude Code's native recall, not decided at sync. judge-used 履历 (LUM-539)
|
|
106
|
+
still orders the resident index (so the most-proven memories list first) but
|
|
107
|
+
never drops anything. Relevance/budget gating remains only on the **injection**
|
|
108
|
+
path (`lumo task context` / session-start), which does spend tokens.
|
|
109
|
+
- **Project-level routing only**: you still don't sync a project you're not a
|
|
110
|
+
member of — that's the API route's workspace authorization. Within a project,
|
|
111
|
+
everyone gets the full set.
|
|
107
112
|
- **Reconcile / drift**: re-running is idempotent. A team file you edited locally
|
|
108
113
|
is detected (its on-disk hash diverged from the recorded `contentHash`) and
|
|
109
114
|
**skipped, never overwritten** — your edit is preserved and flagged as an
|
|
@@ -149,6 +154,22 @@ successful push removes the file from the outbox.
|
|
|
149
154
|
shared with the team — drop a `{category, content}` JSON in the memory `outbox/`
|
|
150
155
|
and run `lumo memory push` (or just use `lumo project memory add` for a one-off).
|
|
151
156
|
|
|
157
|
+
### Automatic sync/push triggers (LUM-551)
|
|
158
|
+
|
|
159
|
+
Both directions also fire automatically, best-effort — a failure never blocks the
|
|
160
|
+
session or command:
|
|
161
|
+
|
|
162
|
+
- **Downsync** runs on `lumo session attach` (throttle-gated, default 12h; see
|
|
163
|
+
[sessions.md](sessions.md)). Manual `lumo memory sync` stays unthrottled.
|
|
164
|
+
- **Upsync** runs on the `session-end` / `stop` / `task-completed` lifecycle hooks:
|
|
165
|
+
a non-empty `<memory-dir>/outbox/*.json` is drained via the same
|
|
166
|
+
create-project-memory pipeline as `lumo memory push`. An **empty outbox does zero
|
|
167
|
+
network** (the fast path checked first), so the high-frequency `stop` hook stays
|
|
168
|
+
cheap. This fills the gap left by the deleted `lumo session wrap` (LUM-544).
|
|
169
|
+
- **Env vars**: `LUMO_SYNC_THROTTLE_HOURS` (downsync throttle window, default 12),
|
|
170
|
+
`LUMO_DISABLE_MEMORY_AUTO=1` (disable **both** auto-paths). The `--no-anchor-check`
|
|
171
|
+
flag on `lumo memory sync` is unchanged.
|
|
172
|
+
|
|
152
173
|
### Reconcile-on-write & deduplication
|
|
153
174
|
|
|
154
175
|
`memory add` does **not** unconditionally insert a new row. Before writing it:
|
|
@@ -99,6 +99,10 @@ What it does:
|
|
|
99
99
|
|
|
100
100
|
**After attaching, always run `lumo task context <identifier>` to load the task background.**
|
|
101
101
|
|
|
102
|
+
#### Auto-downsync on attach (LUM-551)
|
|
103
|
+
|
|
104
|
+
A successful `session attach` also runs a **best-effort team-memory downsync** for the bound task's project — the same work as `lumo memory sync` (including the P4b code-anchor staleness check), so the team's memory lands in your local Claude Code memory store without a separate command. It is **throttle-gated**: the network is skipped entirely if this repo already synced within `LUMO_SYNC_THROTTLE_HOURS` (default **12h**), so re-attaches and same-day sessions are cheap. A sync failure is swallowed — it **never** changes the bind outcome. On a non-trivial sync the CLI prints one extra line (`memory sync → +N ~M -K`). Manual `lumo memory sync` stays unthrottled. Set `LUMO_DISABLE_MEMORY_AUTO=1` to turn the auto-downsync (and the hook auto-upsync — see [memory.md](memory.md)) off entirely; `--no-anchor-check` on `lumo memory sync` is unchanged.
|
|
105
|
+
|
|
102
106
|
#### Lifetime lock (LUM-459)
|
|
103
107
|
|
|
104
108
|
`Session.taskId` is **write-once**. Re-attaching to the **same** task is always a no-op re-bind (idempotent, re-emits context). Attaching to a **different** task is refused with HTTP 409 — the server returns `{ error, currentTaskIdentifier, currentTaskTitle }` and the CLI prints:
|
|
@@ -4,6 +4,8 @@ exports.sessionAttach = sessionAttach;
|
|
|
4
4
|
const config_1 = require("../lib/config");
|
|
5
5
|
const api_1 = require("../lib/api");
|
|
6
6
|
const sanitize_1 = require("../lib/sanitize");
|
|
7
|
+
const resolve_project_1 = require("../lib/resolve-project");
|
|
8
|
+
const memory_auto_1 = require("../lib/memory-auto");
|
|
7
9
|
/**
|
|
8
10
|
* `lumo session attach <identifier>` — bind the currently-running
|
|
9
11
|
* Claude Code session to a task.
|
|
@@ -109,4 +111,31 @@ async function sessionAttach(identifier) {
|
|
|
109
111
|
console.log('');
|
|
110
112
|
console.log((0, sanitize_1.sanitizeField)(body.memorySection));
|
|
111
113
|
}
|
|
114
|
+
// LUM-551: attach-triggered best-effort downsync. Resolve the bound task's
|
|
115
|
+
// project and refresh team memory (throttle-gated inside autoDownsyncOnAttach).
|
|
116
|
+
// Wrapped + swallowed: a sync failure must never change the attach outcome.
|
|
117
|
+
try {
|
|
118
|
+
const base = (0, api_1.trimTrailingSlash)(apiUrl);
|
|
119
|
+
const proj = await (0, resolve_project_1.resolveBoundProjectId)(apiUrl, creds.token, body.taskIdentifier);
|
|
120
|
+
if (proj.ok) {
|
|
121
|
+
const sync = await (0, memory_auto_1.autoDownsyncOnAttach)({
|
|
122
|
+
cwd: process.cwd(),
|
|
123
|
+
base,
|
|
124
|
+
token: creds.token,
|
|
125
|
+
projectId: proj.id,
|
|
126
|
+
now: new Date(),
|
|
127
|
+
});
|
|
128
|
+
const touched = (sync.added ?? 0) + (sync.updated ?? 0) + (sync.removed ?? 0);
|
|
129
|
+
if (!sync.skipped && !sync.error && touched > 0) {
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log((0, sanitize_1.sanitizeField)(`memory sync → +${sync.added} ~${sync.updated} -${sync.removed}` +
|
|
132
|
+
(sync.anchorCandidates
|
|
133
|
+
? ` · ${sync.anchorCandidates} stale-anchor candidate(s)`
|
|
134
|
+
: '')));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// best-effort — the bind already succeeded; never surface a sync error here
|
|
140
|
+
}
|
|
112
141
|
}
|
|
@@ -4,6 +4,7 @@ exports.applySync = applySync;
|
|
|
4
4
|
const local_memory_store_1 = require("./local-memory-store");
|
|
5
5
|
const memory_reconcile_1 = require("./memory-reconcile");
|
|
6
6
|
const managed_block_1 = require("./managed-block");
|
|
7
|
+
const sync_throttle_1 = require("./sync-throttle");
|
|
7
8
|
/**
|
|
8
9
|
* Apply a downsync bundle to the local memory dir. Owns only `root/team/*.md`
|
|
9
10
|
* and the MEMORY.md managed block; dev-authored files and out-of-block index
|
|
@@ -17,6 +18,7 @@ function applySync(root, bundle, opts = {}) {
|
|
|
17
18
|
for (const o of owned)
|
|
18
19
|
(0, local_memory_store_1.removeEntry)(root, o.id);
|
|
19
20
|
(0, local_memory_store_1.writeIndex)(root, (0, managed_block_1.removeBlock)((0, local_memory_store_1.readIndex)(root)));
|
|
21
|
+
(0, sync_throttle_1.clearThrottleState)(root);
|
|
20
22
|
}
|
|
21
23
|
return {
|
|
22
24
|
added: [],
|
|
@@ -13,6 +13,9 @@ const sanitize_1 = require("./sanitize");
|
|
|
13
13
|
const agent_1 = require("./agent");
|
|
14
14
|
const git_task_1 = require("./git-task");
|
|
15
15
|
const transcript_usage_1 = require("./transcript-usage");
|
|
16
|
+
const resolve_project_1 = require("./resolve-project");
|
|
17
|
+
const resolve_bound_task_1 = require("./resolve-bound-task");
|
|
18
|
+
const memory_auto_1 = require("./memory-auto");
|
|
16
19
|
/**
|
|
17
20
|
* Hard timeout for the hook POST. On timeout the request is aborted,
|
|
18
21
|
* logged, and `runHook` exits 0 — Claude Code is never blocked beyond
|
|
@@ -329,6 +332,31 @@ async function runHookWithBody(path, body, agentToken) {
|
|
|
329
332
|
finally {
|
|
330
333
|
clearTimeout(timer);
|
|
331
334
|
}
|
|
335
|
+
// LUM-551: upsync drain on session-wrap-equivalent hooks. The outbox-empty
|
|
336
|
+
// fast path inside autoUpsyncOnDrain means most `stop` events do zero
|
|
337
|
+
// network (and never resolve the project). Best-effort: a failure is
|
|
338
|
+
// logged, never thrown — the hook still exits 0.
|
|
339
|
+
if (path === 'session-end' ||
|
|
340
|
+
path === 'stop' ||
|
|
341
|
+
path === 'task-completed') {
|
|
342
|
+
try {
|
|
343
|
+
await (0, memory_auto_1.autoUpsyncOnDrain)({
|
|
344
|
+
cwd: process.cwd(),
|
|
345
|
+
base: (0, api_1.trimTrailingSlash)(apiUrl),
|
|
346
|
+
token: creds.token,
|
|
347
|
+
resolveProjectId: async () => {
|
|
348
|
+
const bound = await (0, resolve_bound_task_1.resolveBoundTaskIdentifier)(apiUrl, creds.token);
|
|
349
|
+
if (!bound)
|
|
350
|
+
return null;
|
|
351
|
+
const r = await (0, resolve_project_1.resolveBoundProjectId)(apiUrl, creds.token, bound);
|
|
352
|
+
return r.ok ? r.id : null;
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
(0, hook_log_1.logHookError)(`[${path}] upsync`, err);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
332
360
|
}
|
|
333
361
|
catch (err) {
|
|
334
362
|
(0, hook_log_1.logHookError)(`[${path}] runner`, err);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.autoDownsyncOnAttach = autoDownsyncOnAttach;
|
|
4
|
+
exports.autoUpsyncOnDrain = autoUpsyncOnDrain;
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const claude_memory_dir_1 = require("./claude-memory-dir");
|
|
7
|
+
const apply_sync_1 = require("./apply-sync");
|
|
8
|
+
const anchor_staleness_1 = require("./anchor-staleness");
|
|
9
|
+
const upsync_1 = require("./upsync");
|
|
10
|
+
const sync_throttle_1 = require("./sync-throttle");
|
|
11
|
+
/**
|
|
12
|
+
* LUM-551 — best-effort auto-triggers for team-memory sync/push.
|
|
13
|
+
*
|
|
14
|
+
* Both orchestrators are CLI-local, total `try/catch` (never throw), and quiet.
|
|
15
|
+
* They reuse the existing sync/push/anchor primitives rather than adding a
|
|
16
|
+
* parallel path. `LUMO_DISABLE_MEMORY_AUTO` turns both off wholesale.
|
|
17
|
+
*/
|
|
18
|
+
function autoDisabled() {
|
|
19
|
+
return !!process.env.LUMO_DISABLE_MEMORY_AUTO;
|
|
20
|
+
}
|
|
21
|
+
/** Fetch the team-memory sync bundle for a project. Throws on a non-2xx. */
|
|
22
|
+
async function fetchSyncBundle(base, token, projectId) {
|
|
23
|
+
const res = await fetch(`${base}/api/projects/${encodeURIComponent(projectId)}/memories/sync-bundle`, { headers: { Authorization: `Bearer ${token}` } });
|
|
24
|
+
if (!res.ok)
|
|
25
|
+
throw new Error(`sync-bundle HTTP ${res.status}`);
|
|
26
|
+
const { bundle } = (await res.json());
|
|
27
|
+
return bundle;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Attach-triggered best-effort downsync (LUM-551). Throttle-gated; swallows all
|
|
31
|
+
* errors into `{ error }`; NEVER throws. On a real sync it also runs the P4b
|
|
32
|
+
* anchor check (advisory) and records the throttle timestamp.
|
|
33
|
+
*/
|
|
34
|
+
async function autoDownsyncOnAttach(input) {
|
|
35
|
+
if (autoDisabled())
|
|
36
|
+
return { skipped: 'disabled' };
|
|
37
|
+
try {
|
|
38
|
+
const dir = (0, claude_memory_dir_1.resolveMemoryDir)(input.cwd, undefined, input.dir);
|
|
39
|
+
const state = (0, sync_throttle_1.readThrottleState)(dir);
|
|
40
|
+
if ((0, sync_throttle_1.isThrottled)(state, input.now, (0, sync_throttle_1.throttleHours)(), input.projectId)) {
|
|
41
|
+
return { skipped: 'throttled' };
|
|
42
|
+
}
|
|
43
|
+
const bundle = await fetchSyncBundle(input.base, input.token, input.projectId);
|
|
44
|
+
const summary = (0, apply_sync_1.applySync)(dir, bundle);
|
|
45
|
+
let anchorCandidates = 0;
|
|
46
|
+
try {
|
|
47
|
+
const { candidates, report } = await (0, anchor_staleness_1.runAnchorCheck)({
|
|
48
|
+
cwd: input.cwd,
|
|
49
|
+
base: input.base,
|
|
50
|
+
token: input.token,
|
|
51
|
+
projectId: input.projectId,
|
|
52
|
+
entries: bundle.entries,
|
|
53
|
+
});
|
|
54
|
+
if (report.ok)
|
|
55
|
+
anchorCandidates = candidates.length;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// anchor detection is advisory — never fail the sync over it
|
|
59
|
+
}
|
|
60
|
+
(0, sync_throttle_1.writeThrottleState)(dir, { lastSyncAt: input.now.toISOString(), projectId: input.projectId }, input.now);
|
|
61
|
+
return {
|
|
62
|
+
added: summary.added.length,
|
|
63
|
+
updated: summary.updated.length,
|
|
64
|
+
removed: summary.removed.length,
|
|
65
|
+
anchorCandidates,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Lifecycle-hook upsync drain (LUM-551). Outbox-empty fast path FIRST (no
|
|
74
|
+
* network); only a non-empty outbox resolves the project and pushes. Reuses the
|
|
75
|
+
* `memory-push` semantics (POST → unlink on 2xx). NEVER throws.
|
|
76
|
+
*/
|
|
77
|
+
async function autoUpsyncOnDrain(input) {
|
|
78
|
+
if (autoDisabled())
|
|
79
|
+
return { skipped: 'disabled' };
|
|
80
|
+
try {
|
|
81
|
+
const dir = (0, claude_memory_dir_1.resolveMemoryDir)(input.cwd, undefined, input.dir);
|
|
82
|
+
const candidates = (0, upsync_1.collectUpsyncCandidates)(dir);
|
|
83
|
+
if (candidates.length === 0)
|
|
84
|
+
return { skipped: 'empty' };
|
|
85
|
+
const projectId = await input.resolveProjectId();
|
|
86
|
+
if (!projectId)
|
|
87
|
+
return { error: 'no resolvable project for upsync' };
|
|
88
|
+
const url = (0, upsync_1.projectMemoriesEndpoint)(input.base, projectId);
|
|
89
|
+
let pushed = 0;
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
const res = await fetch(url, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
Authorization: `Bearer ${input.token}`,
|
|
95
|
+
'Content-Type': 'application/json',
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ category: c.category, content: c.content }),
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok)
|
|
100
|
+
continue;
|
|
101
|
+
try {
|
|
102
|
+
(0, node_fs_1.unlinkSync)(c.file);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// best-effort cleanup; a re-push is idempotent via reconcile-on-write
|
|
106
|
+
}
|
|
107
|
+
pushed++;
|
|
108
|
+
}
|
|
109
|
+
return { pushed };
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readThrottleState = readThrottleState;
|
|
4
|
+
exports.writeThrottleState = writeThrottleState;
|
|
5
|
+
exports.clearThrottleState = clearThrottleState;
|
|
6
|
+
exports.throttleHours = throttleHours;
|
|
7
|
+
exports.isThrottled = isThrottled;
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const STATE_FILE = '.lumo-sync.json';
|
|
11
|
+
const DEFAULT_HOURS = 12;
|
|
12
|
+
function statePath(dir) {
|
|
13
|
+
return (0, node_path_1.join)(dir, STATE_FILE);
|
|
14
|
+
}
|
|
15
|
+
/** Read the throttle state; null on missing / unreadable / malformed file. */
|
|
16
|
+
function readThrottleState(dir) {
|
|
17
|
+
const p = statePath(dir);
|
|
18
|
+
if (!(0, node_fs_1.existsSync)(p))
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse((0, node_fs_1.readFileSync)(p, 'utf8'));
|
|
22
|
+
if (typeof parsed.lastSyncAt === 'string' &&
|
|
23
|
+
typeof parsed.projectId === 'string') {
|
|
24
|
+
return { lastSyncAt: parsed.lastSyncAt, projectId: parsed.projectId };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Best-effort write of the throttle state. Never throws. */
|
|
33
|
+
function writeThrottleState(dir, state,
|
|
34
|
+
// accepted for signature symmetry with the caller; not otherwise needed.
|
|
35
|
+
_now = new Date()) {
|
|
36
|
+
try {
|
|
37
|
+
(0, node_fs_1.writeFileSync)(statePath(dir), JSON.stringify(state) + '\n');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// best-effort — a missing dir or read-only fs just means no throttle next time
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Remove the throttle state file (used by `memory sync --clean`). Never throws. */
|
|
44
|
+
function clearThrottleState(dir) {
|
|
45
|
+
try {
|
|
46
|
+
(0, node_fs_1.rmSync)(statePath(dir), { force: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// best-effort
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** The throttle window in hours: LUMO_SYNC_THROTTLE_HOURS or the 12h default. */
|
|
53
|
+
function throttleHours() {
|
|
54
|
+
const raw = process.env.LUMO_SYNC_THROTTLE_HOURS;
|
|
55
|
+
if (!raw)
|
|
56
|
+
return DEFAULT_HOURS;
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_HOURS;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* True when a fresh sync for `projectId` exists within `hours` of `now`. A null
|
|
62
|
+
* state, a different project, or a stale timestamp all return false (sync runs).
|
|
63
|
+
*/
|
|
64
|
+
function isThrottled(state, now, hours, projectId) {
|
|
65
|
+
if (!state || state.projectId !== projectId)
|
|
66
|
+
return false;
|
|
67
|
+
const last = Date.parse(state.lastSyncAt);
|
|
68
|
+
if (!Number.isFinite(last))
|
|
69
|
+
return false;
|
|
70
|
+
return now.getTime() - last < hours * 3600_000;
|
|
71
|
+
}
|