@lumoai/cli 1.40.0 → 1.42.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.
@@ -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
  }
@@ -70,6 +70,7 @@ const memory_rm_1 = require("./commands/memory-rm");
70
70
  const memory_show_1 = require("./commands/memory-show");
71
71
  const memory_sync_1 = require("./commands/memory-sync");
72
72
  const memory_push_1 = require("./commands/memory-push");
73
+ const memory_fold_1 = require("./commands/memory-fold");
73
74
  const task_artifact_add_1 = require("./commands/task-artifact-add");
74
75
  const task_criteria_set_1 = require("./commands/task-criteria-set");
75
76
  const task_criteria_list_1 = require("./commands/task-criteria-list");
@@ -533,6 +534,11 @@ memoryCmd
533
534
  .option('--dir <path>', 'Memory dir (default: ~/.claude/projects/<cwd>/memory)')
534
535
  .option('--dry-run', 'List what would be pushed without sending')
535
536
  .action(wrap((opts) => (0, memory_push_1.memoryPush)(opts)));
537
+ memoryCmd
538
+ .command('fold [project-ref]')
539
+ .description('Preview the autonomous topic-fold pass (folding itself runs daily, automatically). Requires --dry-run.')
540
+ .option('--dry-run', 'preview proposed subsystem cards + which fine cards they fold; writes nothing')
541
+ .action(wrap((ref, opts) => (0, memory_fold_1.memoryFold)(ref, opts)));
536
542
  const milestoneCmd = program
537
543
  .command('milestone')
538
544
  .description('Inspect milestones from the terminal');
@@ -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.42.0",
4
4
  "description": "Lumo CLI — manage tasks and sessions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "cli@uselumo.ai",