@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.
@@ -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
- - **Selection**: judge-used 履历 (LUM-539) ranks first, with relevance/cold-start
101
- grace as backfill, capped to the resident-index budget so the local index
102
- stays compact.
103
- - **Routing (per recipient)**: the bundle is scoped to the calling dev — memories
104
- are filtered by relevance to that dev's active (assigned, not-DONE) tasks in the
105
- project, so different devs get differentiated sets rather than the whole corpus.
106
- A dev with no active tasks falls back to the full budget-capped set.
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumoai/cli",
3
- "version": "1.40.0",
3
+ "version": "1.41.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",