@openclawbrain/openclaw 0.2.2 → 0.2.3

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/dist/src/cli.js CHANGED
@@ -1,21 +1,168 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, readSync, openSync, closeSync, realpathSync, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, readSync, openSync, closeSync, realpathSync, rmSync, statSync, writeFileSync, appendFileSync, symlinkSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  const __filename = fileURLToPath(import.meta.url);
7
7
  const __dirname = path.dirname(__filename);
8
8
  import { parseDaemonArgs, runDaemonCommand } from "./daemon.js";
9
9
  import { exportBrain, importBrain } from "./import-export.js";
10
- import { advanceAlwaysOnLearningRuntime, createAlwaysOnLearningRuntimeState, materializeAlwaysOnLearningCandidatePack } from "@openclawbrain/learner";
11
- import { inspectActivationState, promoteCandidatePack, stageCandidatePack, } from "@openclawbrain/pack-format";
10
+ import { buildNormalizedEventExport } from "@openclawbrain/contracts";
11
+ import { buildTeacherSupervisionArtifactsFromNormalizedEventExport, createAlwaysOnLearningRuntimeState, describeAlwaysOnLearningRuntimeState, drainAlwaysOnLearningRuntime, loadOrInitBaseline, materializeAlwaysOnLearningCandidatePack, persistBaseline } from "@openclawbrain/learner";
12
+ import { inspectActivationState, promoteCandidatePack, readLearningSpineLogEntries, stageCandidatePack } from "@openclawbrain/pack-format";
12
13
  import { resolveActivationRoot } from "./resolve-activation-root.js";
13
- import { bootstrapRuntimeAttach, buildOperatorSurfaceReport, compileRuntimeContext, createAsyncTeacherLiveLoop, createRuntimeEventExportScanner, describeCurrentProfileBrainStatus, formatBootstrapRuntimeAttachReport, formatOperatorRollbackReport, loadRuntimeEventExportBundle, rollbackRuntimeAttach, scanLiveEventExport, scanRecordedSession } from "./index.js";
14
+ import { buildNormalizedEventExportFromScannedEvents, bootstrapRuntimeAttach, buildOperatorSurfaceReport, compileRuntimeContext, createAsyncTeacherLiveLoop, createOpenClawLocalSessionTail, createRuntimeEventExportScanner, describeCurrentProfileBrainStatus, formatBootstrapRuntimeAttachReport, formatOperatorRollbackReport, loadRuntimeEventExportBundle, rollbackRuntimeAttach, resolveAsyncTeacherLiveLoopSnapshotPath, scanLiveEventExport, scanRecordedSession, summarizeLearningPathFromMaterialization, summarizeNormalizedEventExportLabelFlow, writeScannedEventExportBundle } from "./index.js";
14
15
  import { buildPassiveLearningSessionExportFromOpenClawSessionStore } from "./local-session-passive-learning.js";
15
- import { discoverOpenClawMainSessionStores, loadOpenClawSessionIndex, readOpenClawSessionFile } from "./session-store.js";
16
+ import { discoverOpenClawSessionStores, loadOpenClawSessionIndex, readOpenClawSessionFile } from "./session-store.js";
16
17
  function quoteShellArg(value) {
17
18
  return `'${value.replace(/'/g, `"'"'`)}'`;
18
19
  }
20
+ function normalizeOptionalCliString(value) {
21
+ if (typeof value !== "string") {
22
+ return null;
23
+ }
24
+ const trimmed = value.trim();
25
+ return trimmed.length > 0 ? trimmed : null;
26
+ }
27
+ function getCliHomeDir() {
28
+ return process.env.HOME ?? process.env.USERPROFILE ?? "~";
29
+ }
30
+ function discoverSetupCandidateOpenClawHomes(homeDir = getCliHomeDir()) {
31
+ const resolvedHomeDir = path.resolve(homeDir);
32
+ let entries;
33
+ try {
34
+ entries = readdirSync(resolvedHomeDir, { withFileTypes: true });
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ return entries
40
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(".openclaw-"))
41
+ .map((entry) => path.join(resolvedHomeDir, entry.name))
42
+ .filter((candidate) => existsSync(path.join(candidate, "openclaw.json")))
43
+ .sort((left, right) => left.localeCompare(right));
44
+ }
45
+ function formatSetupOpenClawHomeSource(source) {
46
+ switch (source) {
47
+ case "explicit":
48
+ return "--openclaw-home";
49
+ case "env":
50
+ return "OPENCLAW_HOME";
51
+ case "discovered_single_profile":
52
+ return "single discovered live profile";
53
+ default:
54
+ return source;
55
+ }
56
+ }
57
+ function resolveSetupOpenClawHome(explicitOpenclawHome) {
58
+ const normalizedExplicitHome = normalizeOptionalCliString(explicitOpenclawHome);
59
+ if (normalizedExplicitHome !== null) {
60
+ return {
61
+ openclawHome: path.resolve(normalizedExplicitHome),
62
+ openclawHomeSource: "explicit"
63
+ };
64
+ }
65
+ const envOpenClawHome = normalizeOptionalCliString(process.env.OPENCLAW_HOME);
66
+ if (envOpenClawHome !== null) {
67
+ return {
68
+ openclawHome: path.resolve(envOpenClawHome),
69
+ openclawHomeSource: "env"
70
+ };
71
+ }
72
+ const discoveredHomes = discoverSetupCandidateOpenClawHomes();
73
+ if (discoveredHomes.length === 1) {
74
+ return {
75
+ openclawHome: path.resolve(discoveredHomes[0]),
76
+ openclawHomeSource: "discovered_single_profile"
77
+ };
78
+ }
79
+ if (discoveredHomes.length > 1) {
80
+ const installPrefix = detectConsumerSafeOperatorCliPrefix();
81
+ const targetChoices = discoveredHomes
82
+ .map((candidate) => {
83
+ const resolvedCandidate = path.resolve(candidate);
84
+ return ` - ${resolvedCandidate}\n ${installPrefix} install --openclaw-home ${quoteShellArg(resolvedCandidate)}`;
85
+ })
86
+ .join("\n");
87
+ throw new Error([
88
+ "Refusing ambiguous live OpenClaw targets for install/setup.",
89
+ targetChoices,
90
+ "Pass --openclaw-home <path> or set OPENCLAW_HOME to pin one profile."
91
+ ].join("\n"));
92
+ }
93
+ throw new Error("No OpenClaw profile home found. Pass --openclaw-home <path> or set OPENCLAW_HOME.");
94
+ }
95
+ function resolveSetupActivationRoot(openclawHome, explicitActivationRoot) {
96
+ const normalizedExplicitActivationRoot = normalizeOptionalCliString(explicitActivationRoot);
97
+ if (normalizedExplicitActivationRoot !== null) {
98
+ return {
99
+ activationRoot: path.resolve(normalizedExplicitActivationRoot),
100
+ source: "explicit"
101
+ };
102
+ }
103
+ return {
104
+ activationRoot: path.resolve(path.dirname(openclawHome), ".openclawbrain", "activation"),
105
+ source: "default_from_openclaw_home"
106
+ };
107
+ }
108
+ function resolveSetupWorkspaceId(openclawHome, explicitWorkspaceId) {
109
+ const normalizedExplicitWorkspaceId = normalizeOptionalCliString(explicitWorkspaceId);
110
+ if (normalizedExplicitWorkspaceId !== null) {
111
+ return {
112
+ workspaceId: normalizedExplicitWorkspaceId,
113
+ source: "explicit"
114
+ };
115
+ }
116
+ try {
117
+ const openclawConfigPath = path.join(openclawHome, "openclaw.json");
118
+ const openclawConfig = JSON.parse(readFileSync(openclawConfigPath, "utf8"));
119
+ if (typeof openclawConfig.profile === "string" && openclawConfig.profile.trim().length > 0) {
120
+ return {
121
+ workspaceId: openclawConfig.profile.trim(),
122
+ source: "openclaw_json_profile"
123
+ };
124
+ }
125
+ }
126
+ catch {
127
+ // Fall back to the profile-home name when setup is pointed at an incomplete or not-yet-readable profile.
128
+ }
129
+ const dirName = path.basename(openclawHome);
130
+ if (dirName === ".openclaw") {
131
+ return {
132
+ workspaceId: "default",
133
+ source: "openclaw_home_dir"
134
+ };
135
+ }
136
+ const derivedWorkspaceId = dirName.startsWith(".openclaw-") ? dirName.slice(".openclaw-".length) : dirName;
137
+ if (derivedWorkspaceId.trim().length > 0) {
138
+ return {
139
+ workspaceId: derivedWorkspaceId,
140
+ source: "openclaw_home_dir"
141
+ };
142
+ }
143
+ return {
144
+ workspaceId: "workspace",
145
+ source: "fallback"
146
+ };
147
+ }
148
+ function formatSetupActivationRootSource(source) {
149
+ if (source === "explicit") {
150
+ return "explicit --activation-root";
151
+ }
152
+ return "default beside --openclaw-home";
153
+ }
154
+ function formatSetupWorkspaceIdSource(source) {
155
+ switch (source) {
156
+ case "explicit":
157
+ return "explicit --workspace-id";
158
+ case "openclaw_json_profile":
159
+ return "from openclaw.json profile";
160
+ case "openclaw_home_dir":
161
+ return "from OpenClaw home dir";
162
+ default:
163
+ return "fallback default";
164
+ }
165
+ }
19
166
  function detectConsumerSafeOperatorCliPrefix() {
20
167
  const npmExecPath = (process.env.npm_execpath ?? "").toLowerCase();
21
168
  const userAgent = process.env.npm_config_user_agent ?? "";
@@ -132,7 +279,10 @@ function buildDoctorDeletedMessage(args) {
132
279
  function operatorCliHelp() {
133
280
  return [
134
281
  "Usage:",
135
- " openclawbrain setup --openclaw-home <path> [options]",
282
+ " openclawbrain install [--openclaw-home <path>] [options]",
283
+ " openclawbrain setup [--openclaw-home <path>] [options] # compatibility alias",
284
+ " openclawbrain detach --openclaw-home <path> [options]",
285
+ " openclawbrain uninstall --openclaw-home <path> [--keep-data|--purge-data] [options]",
136
286
  " openclawbrain attach --activation-root <path> [options]",
137
287
  " openclawbrain <status|rollback> --activation-root <path> [options]",
138
288
  " openclawbrain context \"message\" [--activation-root <path>]",
@@ -146,11 +296,14 @@ function operatorCliHelp() {
146
296
  " openclawbrain-ops scan --session <trace.json> --root <path> [options] # compatibility alias",
147
297
  "",
148
298
  "Options:",
149
- " --openclaw-home <path> OpenClaw profile home dir for setup (e.g. ~/.openclaw-Tern).",
150
- " --shared Set brain-attachment-policy to shared instead of dedicated (setup only).",
151
- " --activation-root <path> Activation root to bootstrap or inspect.",
299
+ " --openclaw-home <path> OpenClaw profile home dir for install/setup/detach/uninstall (e.g. ~/.openclaw-Tern). Auto-selects for install/setup when OPENCLAW_HOME is set or exactly one live profile home exists.",
300
+ " --shared Set brain-attachment-policy to shared instead of dedicated (install/setup only).",
301
+ " --activation-root <path> Activation root for attach/detach/uninstall; install/setup defaults to sibling .openclawbrain/activation next to the selected OpenClaw home.",
302
+ " --keep-data Preserve activation data on uninstall; detach always behaves this way.",
303
+ " --purge-data Remove activation data on uninstall; requires the installed profile hook or --activation-root.",
304
+ " --restart <never|safe|external> Restart guidance mode for detach/uninstall. 'safe' is conservative; 'never' leaves restart entirely to the operator.",
152
305
  " --pack-root <path> Initial pack root directory (attach only; defaults to <activation-root>/packs/initial).",
153
- " --workspace-id <id> Workspace identifier for attach provenance (attach only; defaults to 'workspace').",
306
+ " --workspace-id <id> Workspace identifier for attach/install/setup provenance; install/setup defaults to openclaw.json.profile or the profile name, attach defaults to 'workspace'.",
154
307
  " --event-export <path> Event-export bundle root or normalized export JSON payload.",
155
308
  " --teacher-snapshot <path> Async teacher snapshot JSON from teacherLoop.snapshot()/flush(); keeps live-first, principal-priority, and passive-backfill learner truth explicit.",
156
309
  " --updated-at <iso> Observation time to use for freshness checks.",
@@ -171,6 +324,9 @@ function operatorCliHelp() {
171
324
  " --help Show this help.",
172
325
  "",
173
326
  "Common flow:",
327
+ " 0. install openclawbrain install — attach the brain with sane defaults; pass --openclaw-home for explicit targeting on many-profile hosts",
328
+ " 0. detach openclawbrain detach --openclaw-home <path> — remove the profile hook only; activation data stays in place",
329
+ " 0. uninstall openclawbrain uninstall --openclaw-home <path> --keep-data|--purge-data — remove the profile hook and choose the data outcome explicitly",
174
330
  " 0. context openclawbrain context \"hello\" — preview the brain context that would be injected for a message",
175
331
  " 0. attach openclawbrain attach --activation-root <path>",
176
332
  " 1. status answer \"How's the brain?\" for the current profile on that activation root",
@@ -180,10 +336,13 @@ function operatorCliHelp() {
180
336
  " 5. scan --session replay one sanitized session trace across no_brain, seed_pack, and learned_replay",
181
337
  " 6. scan --live scan one live event export into teacher/learner state without claiming a daemon is running",
182
338
  " status --teacher-snapshot keeps the current live-first / principal-priority / passive-backfill learner order visible when that snapshot exists",
339
+ " watch/daemon persist that snapshot at <activation-root>/async-teacher-live-loop.snapshot.json; --teacher-snapshot overrides the default path",
183
340
  "",
184
341
  "Exit codes:",
185
342
  " status: 0 on successful inspection, 1 on input/read failure.",
186
343
  " rollback: 0 when ready/applied, 1 when blocked or on input/read failure.",
344
+ " detach: 0 on successful unhook, 1 on input/read failure.",
345
+ " uninstall: 0 on successful unhook/cleanup, 1 on input/read failure.",
187
346
  " scan: 0 on successful replay/scan, 1 on input/read failure."
188
347
  ].join("\n");
189
348
  }
@@ -225,6 +384,12 @@ function formatLearningBuckets(report) {
225
384
  function formatLearningWarnings(report) {
226
385
  return report.learning.warningStates.length === 0 ? "none" : report.learning.warningStates.join("|");
227
386
  }
387
+ function formatLabelFlowSummary(labelFlow) {
388
+ return `source=${labelFlow.source} human=${labelFlow.humanLabelCount ?? "none"} self=${labelFlow.selfLabelCount ?? "none"} implicitPositive=${labelFlow.implicitPositiveCount ?? "none"} teacherArtifacts=${labelFlow.asyncTeacherArtifactCount ?? "none"}`;
389
+ }
390
+ function formatLearningPathSummary(learningPath) {
391
+ return `source=${learningPath.source} pg=${learningPath.policyGradientVersion} method=${learningPath.policyGradientMethod ?? "none"} target=${learningPath.targetConstruction ?? "none"} connect=${learningPath.connectOpsFired ?? "none"} trajectories=${learningPath.reconstructedTrajectoryCount ?? "none"}`;
392
+ }
228
393
  function formatCurrentProfileStatusSummary(status, report) {
229
394
  const profileIdSuffix = status.profile.profileId === null ? "" : ` id=${status.profile.profileId}`;
230
395
  return [
@@ -240,8 +405,11 @@ function formatCurrentProfileStatusSummary(status, report) {
240
405
  `decision ${status.brainStatus.structuralDecision.detail}`,
241
406
  `principal latest=${formatPrincipalLatest(report)} pending=${report.principal.pendingCount ?? report.learning.pendingPrincipalCount ?? "none"} checkpoint=${formatPrincipalCheckpointFrontier(report)} downstream=${yesNo(report.principal.servingDownstreamOfLatestCorrection)} lag=${report.learning.principalLagToPromotion.sequenceLag ?? "none"}`,
242
407
  `scanner flowing=${yesNo(report.supervision.flowing)} scan=${report.supervision.scanPolicy ?? "none"} surfaces=${formatScannerSurfaces(report)} labels=${report.supervision.humanLabelCount ?? "none"}/${report.supervision.selfLabelCount ?? "none"} attributable=${report.supervision.attributedEventCount ?? "none"}/${report.supervision.totalEventCount ?? "none"} digests=${report.supervision.selectionDigestCount ?? "none"}`,
408
+ `labels ${formatLabelFlowSummary(report.labelFlow)}`,
243
409
  `graph source=${report.graph.runtimePlasticitySource ?? "none"} ops=${formatStructuralOps(report)} changed=${yesNo(report.graph.changed)} pruned=${report.graph.prunedBlockCount ?? "none"} strongest=${report.graph.strongestBlockId ?? "none"} summary=${report.graph.operatorSummary ?? report.graph.detail}`,
410
+ `path ${formatLearningPathSummary(report.learningPath)}`,
244
411
  `learning state=${report.learning.backlogState} bootstrapped=${yesNo(report.learning.bootstrapped)} mode=${report.learning.mode} next=${report.learning.nextPriorityLane} priority=${report.learning.nextPriorityBucket} pending=${report.learning.pendingLive ?? "none"}/${report.learning.pendingBackfill ?? "none"} buckets=${formatLearningBuckets(report)} warn=${formatLearningWarnings(report)} lastPack=${report.learning.lastMaterializedPackId ?? "none"} detail=${report.learning.detail}`,
412
+ `teacher snapshot=${report.teacherLoop.sourcePath ?? "none"} started=${report.teacherLoop.startedAt ?? "none"} heartbeat=${report.teacherLoop.lastHeartbeatAt ?? "none"} scan=${report.teacherLoop.lastScanAt ?? report.teacherLoop.lastProcessedAt ?? "none"} queue=${report.teacherLoop.queueDepth ?? "none"}/${report.teacherLoop.queueCapacity ?? "none"} running=${yesNo(report.teacherLoop.running)} lastJob=${report.teacherLoop.lastAppliedMaterializationJobId ?? "none"} lastPack=${report.teacherLoop.lastMaterializedPackId ?? "none"}`,
245
413
  `rollback ready=${yesNo(report.rollback.allowed)} state=${report.rollback.state} previous=${report.rollback.previousPackId ?? "none"}`,
246
414
  `proof lastExport=${status.brain.lastExportAt ?? "none"} lastLearningUpdate=${status.brain.lastLearningUpdateAt ?? "none"} lastPromotion=${status.brain.lastPromotionAt ?? "none"}`,
247
415
  `logs root=${status.brain.logRoot ?? "none"}`,
@@ -258,6 +426,40 @@ function shortenPath(fullPath) {
258
426
  }
259
427
  return fullPath;
260
428
  }
429
+ function buildInstallStatusCommand(activationRoot) {
430
+ return `openclawbrain status --activation-root ${quoteShellArg(activationRoot)}`;
431
+ }
432
+ function buildInstallCommand(openclawHome) {
433
+ return `openclawbrain install --openclaw-home ${quoteShellArg(openclawHome)}`;
434
+ }
435
+ function buildInstallReloadGuidance() {
436
+ return "If this OpenClaw profile is currently running, restart it before expecting the new brain hook to load. If it is stopped, the next launch will pick it up.";
437
+ }
438
+ function buildCleanupRestartGuidance(restart) {
439
+ if (restart === "never") {
440
+ return "No restart requested. If this OpenClaw profile is currently running, it may keep the previous hook state until the next restart.";
441
+ }
442
+ if (restart === "external") {
443
+ return "Restart this OpenClaw profile externally if it is currently running. If it is stopped, the next launch will pick up the new hook state.";
444
+ }
445
+ return "If this OpenClaw profile is currently running, restart it before expecting the new hook state to take effect. If it is stopped, the next launch will pick it up.";
446
+ }
447
+ function buildStatusNextStep(status, report) {
448
+ if (status.brainStatus.status === "fail") {
449
+ return "Run `openclawbrain status --detailed` before changing lifecycle state so the serve-path failure is explicit.";
450
+ }
451
+ if (status.brainStatus.awaitingFirstExport) {
452
+ return "Let the attached OpenClaw profile emit a real export, then rerun `openclawbrain status`.";
453
+ }
454
+ if (report.learning.warningStates.includes("principal_live_backlog") ||
455
+ report.learning.warningStates.includes("active_pack_behind_latest_principal")) {
456
+ return "A newer principal correction is still pending promotion; keep the current pack conservative until learner promotion lands.";
457
+ }
458
+ if (report.rollback.allowed) {
459
+ return "Use `openclawbrain rollback --dry-run` before restoring the previous pack.";
460
+ }
461
+ return "Use `openclawbrain status --detailed` when you need the full lifecycle, serve-path, and backlog proof.";
462
+ }
261
463
  function formatHumanFriendlyStatus(status, report) {
262
464
  // Brain status line
263
465
  const brainActive = status.brainStatus.status === "ok" || status.brainStatus.serveState === "serving_active_pack";
@@ -270,11 +472,21 @@ function formatHumanFriendlyStatus(status, report) {
270
472
  const activationPath = shortenPath(status.host.activationRoot);
271
473
  // Policy
272
474
  const policy = status.attachment.policyMode ?? report.manyProfile.declaredAttachmentPolicy ?? "undeclared";
475
+ const principalFrontier = formatPrincipalCheckpointFrontier(report);
476
+ const pendingLive = String(report.learning.pendingLive ?? "none");
477
+ const pendingBackfill = String(report.learning.pendingBackfill ?? "none");
478
+ const nextLane = report.learning.nextPriorityLane ?? "none";
479
+ const nextBucket = report.learning.nextPriorityBucket ?? "none";
273
480
  const lines = [
274
481
  `Brain: ${brainIcon}`,
275
482
  `Pack: ${packShort} (${state})`,
276
483
  `Activation: ${activationPath}`,
277
- `Policy: ${policy}`
484
+ `Policy: ${policy}`,
485
+ `Lifecycle: attachment=${status.attachment.state} serve=${status.brainStatus.serveState} awaitingFirstExport=${yesNo(status.brainStatus.awaitingFirstExport)}`,
486
+ `Rollback: state=${report.rollback.state} ready=${yesNo(report.rollback.allowed)} previous=${report.rollback.previousPackId ?? "none"}`,
487
+ `Backlog: principal=${principalFrontier} live=${pendingLive} backfill=${pendingBackfill} next=${nextLane}/${nextBucket}`,
488
+ `Labels: ${formatLabelFlowSummary(report.labelFlow)}`,
489
+ `Learning: ${formatLearningPathSummary(report.learningPath)}`
278
490
  ];
279
491
  // Add learning/serve warnings if relevant
280
492
  if (report.learning.warningStates.length > 0) {
@@ -283,6 +495,7 @@ function formatHumanFriendlyStatus(status, report) {
283
495
  if (status.brainStatus.awaitingFirstExport) {
284
496
  lines.push(`Note: Awaiting first event export`);
285
497
  }
498
+ lines.push(`Next: ${buildStatusNextStep(status, report)}`);
286
499
  return lines.join("\n");
287
500
  }
288
501
  function requireActivationRoot(input, _command) {
@@ -321,6 +534,8 @@ function formatScanLiveSummary(result, snapshotOutPath) {
321
534
  "SCAN live ok",
322
535
  `source digest=${result.supervision.exportDigest} session=${result.supervision.sessionId ?? "none"} channel=${result.supervision.channel ?? "none"} range=${result.supervision.eventRange.start}-${result.supervision.eventRange.end}/${result.supervision.eventRange.count}`,
323
536
  `teacher artifacts=${result.snapshot.teacher.artifactCount} freshness=${result.snapshot.teacher.latestFreshness} humanLabels=${result.supervision.humanLabelCount} noop=${result.snapshot.diagnostics.lastNoOpReason}`,
537
+ `labels source=${result.labelFlow.source} human=${result.labelFlow.humanLabelCount ?? "none"} self=${result.labelFlow.selfLabelCount ?? "none"} implicitPositive=${result.labelFlow.implicitPositiveCount ?? "none"} teacherArtifacts=${result.labelFlow.asyncTeacherArtifactCount ?? "none"}`,
538
+ `path source=${result.learningPath.source} pg=${result.learningPath.policyGradientVersion} method=${result.learningPath.policyGradientMethod ?? "none"} target=${result.learningPath.targetConstruction ?? "none"} connect=${result.learningPath.connectOpsFired ?? "none"} trajectories=${result.learningPath.reconstructedTrajectoryCount ?? "none"}`,
324
539
  `learner packLabel=${result.packLabel} materialized=${materializedPackId} reason=${materializationReason}`,
325
540
  `observed ${result.observedAt}`,
326
541
  `snapshot ${snapshotOutPath ?? "none"}`
@@ -344,6 +559,10 @@ export function parseOperatorCliArgs(argv) {
344
559
  let snapshotOutPath = null;
345
560
  let openclawHome = null;
346
561
  let shared = false;
562
+ let keepData = false;
563
+ let purgeData = false;
564
+ let restart = "safe";
565
+ let restartExplicitlySet = false;
347
566
  let json = false;
348
567
  let help = false;
349
568
  let dryRun = false;
@@ -356,7 +575,7 @@ export function parseOperatorCliArgs(argv) {
356
575
  args.shift();
357
576
  return parseDaemonArgs(args);
358
577
  }
359
- if (args[0] === "status" || args[0] === "rollback" || args[0] === "scan" || args[0] === "attach" || args[0] === "setup" || args[0] === "context" || args[0] === "history" || args[0] === "learn" || args[0] === "watch" || args[0] === "export" || args[0] === "import" || args[0] === "reset") {
578
+ if (args[0] === "status" || args[0] === "rollback" || args[0] === "scan" || args[0] === "attach" || args[0] === "install" || args[0] === "setup" || args[0] === "detach" || args[0] === "uninstall" || args[0] === "context" || args[0] === "history" || args[0] === "learn" || args[0] === "watch" || args[0] === "export" || args[0] === "import" || args[0] === "reset") {
360
579
  command = args.shift();
361
580
  }
362
581
  if (command === "learn") {
@@ -686,6 +905,27 @@ export function parseOperatorCliArgs(argv) {
686
905
  shared = true;
687
906
  continue;
688
907
  }
908
+ if (arg === "--keep-data") {
909
+ keepData = true;
910
+ continue;
911
+ }
912
+ if (arg === "--purge-data") {
913
+ purgeData = true;
914
+ continue;
915
+ }
916
+ if (arg === "--restart") {
917
+ const next = args[index + 1];
918
+ if (next === undefined) {
919
+ throw new Error("--restart requires a value");
920
+ }
921
+ if (next !== "never" && next !== "safe" && next !== "external") {
922
+ throw new Error(`invalid --restart value: ${next}`);
923
+ }
924
+ restart = next;
925
+ restartExplicitlySet = true;
926
+ index += 1;
927
+ continue;
928
+ }
689
929
  if (arg === "--detailed") {
690
930
  detailed = true;
691
931
  continue;
@@ -816,25 +1056,99 @@ export function parseOperatorCliArgs(argv) {
816
1056
  }
817
1057
  throw new Error(`unknown argument: ${arg}`);
818
1058
  }
819
- if (command === "setup") {
1059
+ if (command !== "detach" && command !== "uninstall" && restartExplicitlySet) {
1060
+ throw new Error("--restart only applies to detach/uninstall");
1061
+ }
1062
+ if (command !== "uninstall" && keepData) {
1063
+ throw new Error("--keep-data only applies to uninstall; use detach to preserve activation data");
1064
+ }
1065
+ if (command !== "uninstall" && purgeData) {
1066
+ throw new Error("--purge-data only applies to uninstall");
1067
+ }
1068
+ if (command === "install" || command === "setup") {
820
1069
  if (help) {
821
- return { command, openclawHome: "", activationRoot: "", shared: false, workspaceId: "", json, help };
1070
+ return {
1071
+ command,
1072
+ openclawHome: "",
1073
+ openclawHomeSource: "explicit",
1074
+ activationRoot: "",
1075
+ activationRootSource: "explicit",
1076
+ shared: false,
1077
+ workspaceId: "",
1078
+ workspaceIdSource: "explicit",
1079
+ json,
1080
+ help
1081
+ };
1082
+ }
1083
+ const resolvedOpenclawHome = resolveSetupOpenClawHome(openclawHome);
1084
+ const resolvedActivationRoot = resolveSetupActivationRoot(resolvedOpenclawHome.openclawHome, activationRoot);
1085
+ const resolvedWorkspaceId = resolveSetupWorkspaceId(resolvedOpenclawHome.openclawHome, workspaceId);
1086
+ return {
1087
+ command,
1088
+ openclawHome: resolvedOpenclawHome.openclawHome,
1089
+ openclawHomeSource: resolvedOpenclawHome.openclawHomeSource,
1090
+ activationRoot: resolvedActivationRoot.activationRoot,
1091
+ activationRootSource: resolvedActivationRoot.source,
1092
+ shared,
1093
+ workspaceId: resolvedWorkspaceId.workspaceId,
1094
+ workspaceIdSource: resolvedWorkspaceId.source,
1095
+ json,
1096
+ help
1097
+ };
1098
+ }
1099
+ if (command === "detach") {
1100
+ if (help) {
1101
+ return { command, openclawHome: "", activationRoot: null, restart: "safe", json, help };
822
1102
  }
823
1103
  if (openclawHome === null || openclawHome.trim().length === 0) {
824
- throw new Error("--openclaw-home is required for setup");
1104
+ throw new Error("--openclaw-home is required for detach");
1105
+ }
1106
+ if (purgeData) {
1107
+ throw new Error("detach always preserves activation data; use uninstall --purge-data to remove it");
825
1108
  }
826
1109
  const resolvedOpenclawHome = path.resolve(openclawHome);
827
- const defaultActivationRoot = path.resolve(process.env.HOME ?? "~", ".openclawbrain", "activation");
828
- const resolvedActivationRoot = activationRoot !== null ? path.resolve(activationRoot) : defaultActivationRoot;
829
- const dirName = path.basename(resolvedOpenclawHome);
830
- const derivedWorkspaceId = dirName.startsWith(".openclaw-") ? dirName.slice(".openclaw-".length) : dirName;
831
- const resolvedWorkspaceId = workspaceId ?? derivedWorkspaceId;
1110
+ const resolvedActivationRoot = resolveActivationRoot({
1111
+ explicit: activationRoot,
1112
+ openclawHome: resolvedOpenclawHome,
1113
+ quiet: true
1114
+ });
832
1115
  return {
833
1116
  command,
834
1117
  openclawHome: resolvedOpenclawHome,
835
- activationRoot: resolvedActivationRoot,
836
- shared,
837
- workspaceId: resolvedWorkspaceId,
1118
+ activationRoot: resolvedActivationRoot.trim().length === 0 ? null : path.resolve(resolvedActivationRoot),
1119
+ restart,
1120
+ json,
1121
+ help
1122
+ };
1123
+ }
1124
+ if (command === "uninstall") {
1125
+ if (help) {
1126
+ return { command, openclawHome: "", activationRoot: null, dataMode: "keep", restart: "safe", json, help };
1127
+ }
1128
+ if (openclawHome === null || openclawHome.trim().length === 0) {
1129
+ throw new Error("--openclaw-home is required for uninstall");
1130
+ }
1131
+ if (!keepData && !purgeData) {
1132
+ throw new Error("uninstall requires exactly one of --keep-data or --purge-data");
1133
+ }
1134
+ if (keepData && purgeData) {
1135
+ throw new Error("--keep-data and --purge-data are mutually exclusive");
1136
+ }
1137
+ const resolvedOpenclawHome = path.resolve(openclawHome);
1138
+ const resolvedActivationRoot = resolveActivationRoot({
1139
+ explicit: activationRoot,
1140
+ openclawHome: resolvedOpenclawHome,
1141
+ quiet: true
1142
+ });
1143
+ if (purgeData && resolvedActivationRoot.trim().length === 0) {
1144
+ throw new Error("--purge-data requires a resolvable activation root from the installed profile hook or --activation-root <path>");
1145
+ }
1146
+ return {
1147
+ command,
1148
+ openclawHome: resolvedOpenclawHome,
1149
+ activationRoot: resolvedActivationRoot.trim().length === 0 ? null : path.resolve(resolvedActivationRoot),
1150
+ dataMode: purgeData ? "purge" : "keep",
1151
+ restart,
838
1152
  json,
839
1153
  help
840
1154
  };
@@ -939,28 +1253,132 @@ function resolveExtensionTemplatePath() {
939
1253
  throw new Error("Pre-built extension template not found. Searched:\n" +
940
1254
  candidates.map((c) => ` - ${c}`).join("\n"));
941
1255
  }
1256
+ function resolveExtensionRuntimeGuardPath() {
1257
+ const tsCandidates = [
1258
+ path.resolve(__dirname, "..", "extension", "runtime-guard.ts"),
1259
+ path.resolve(__dirname, "..", "..", "extension", "runtime-guard.ts"),
1260
+ ];
1261
+ const jsCandidates = [
1262
+ path.resolve(__dirname, "extension", "runtime-guard.js"),
1263
+ path.resolve(__dirname, "..", "extension", "runtime-guard.js"),
1264
+ ];
1265
+ const tsPath = tsCandidates.find((c) => existsSync(c)) ?? null;
1266
+ const jsPath = jsCandidates.find((c) => existsSync(c));
1267
+ if (!jsPath) {
1268
+ throw new Error("Pre-built extension runtime-guard.js not found. Searched:\n" +
1269
+ jsCandidates.map((c) => ` - ${c}`).join("\n"));
1270
+ }
1271
+ return { ts: tsPath, js: jsPath };
1272
+ }
1273
+ const LOCAL_WORKSPACE_EXTENSION_PACKAGES = [
1274
+ "activation",
1275
+ "compiler",
1276
+ "contracts",
1277
+ "event-export",
1278
+ "events",
1279
+ "learner",
1280
+ "openclaw",
1281
+ "pack-format",
1282
+ "provenance",
1283
+ "workspace-metadata"
1284
+ ];
1285
+ function resolveLocalWorkspaceRootForExtensionInstall() {
1286
+ const candidates = [
1287
+ path.resolve(__dirname, "..", "..", "..", ".."),
1288
+ path.resolve(__dirname, "..", "..", "..")
1289
+ ];
1290
+ for (const candidate of candidates) {
1291
+ const packageRoot = path.join(candidate, "packages", "openclaw");
1292
+ const distEntry = path.join(packageRoot, "dist", "src", "index.js");
1293
+ if (existsSync(packageRoot) && existsSync(distEntry)) {
1294
+ return candidate;
1295
+ }
1296
+ }
1297
+ return null;
1298
+ }
1299
+ function installExtensionFromLocalWorkspaceBuild(extensionDir) {
1300
+ const workspaceRoot = resolveLocalWorkspaceRootForExtensionInstall();
1301
+ if (workspaceRoot === null) {
1302
+ return null;
1303
+ }
1304
+ const nodeModulesRoot = path.join(extensionDir, "node_modules", "@openclawbrain");
1305
+ mkdirSync(nodeModulesRoot, { recursive: true });
1306
+ for (const packageName of LOCAL_WORKSPACE_EXTENSION_PACKAGES) {
1307
+ const packageDir = path.join(workspaceRoot, "packages", packageName);
1308
+ const packageDistEntry = path.join(packageDir, "dist", "src", "index.js");
1309
+ if (!existsSync(packageDir) || !existsSync(packageDistEntry)) {
1310
+ return null;
1311
+ }
1312
+ }
1313
+ for (const packageName of LOCAL_WORKSPACE_EXTENSION_PACKAGES) {
1314
+ const packageDir = path.join(workspaceRoot, "packages", packageName);
1315
+ const linkPath = path.join(nodeModulesRoot, packageName);
1316
+ rmSync(linkPath, { recursive: true, force: true });
1317
+ symlinkSync(packageDir, linkPath, "dir");
1318
+ }
1319
+ return [...LOCAL_WORKSPACE_EXTENSION_PACKAGES];
1320
+ }
1321
+ let cachedOpenClawPackageMetadata = null;
1322
+ function resolveOpenClawPackageManifestPath() {
1323
+ const candidates = [
1324
+ path.resolve(__dirname, "..", "package.json"),
1325
+ path.resolve(__dirname, "..", "..", "package.json"),
1326
+ ];
1327
+ for (const candidate of candidates) {
1328
+ if (existsSync(candidate)) {
1329
+ return candidate;
1330
+ }
1331
+ }
1332
+ throw new Error("OpenClawBrain package manifest not found. Searched:\n" +
1333
+ candidates.map((candidate) => ` - ${candidate}`).join("\n"));
1334
+ }
1335
+ function readOpenClawPackageMetadata() {
1336
+ if (cachedOpenClawPackageMetadata !== null) {
1337
+ return cachedOpenClawPackageMetadata;
1338
+ }
1339
+ const manifestPath = resolveOpenClawPackageManifestPath();
1340
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1341
+ const name = typeof manifest.name === "string" && manifest.name.trim().length > 0
1342
+ ? manifest.name.trim()
1343
+ : "@openclawbrain/openclaw";
1344
+ const version = typeof manifest.version === "string" && manifest.version.trim().length > 0
1345
+ ? manifest.version.trim()
1346
+ : "0.0.0";
1347
+ cachedOpenClawPackageMetadata = { name, version };
1348
+ return cachedOpenClawPackageMetadata;
1349
+ }
942
1350
  function buildExtensionIndexTs(activationRoot) {
943
1351
  const templatePath = resolveExtensionTemplatePath();
944
1352
  const template = readFileSync(templatePath, "utf8");
945
1353
  return template.replace(/const ACTIVATION_ROOT = "__ACTIVATION_ROOT__";/, `const ACTIVATION_ROOT = ${JSON.stringify(activationRoot)};`);
946
1354
  }
947
1355
  function buildExtensionPackageJson() {
1356
+ const packageMetadata = readOpenClawPackageMetadata();
948
1357
  return JSON.stringify({
949
1358
  name: "openclawbrain-extension",
950
- version: "0.1.0",
1359
+ version: packageMetadata.version,
951
1360
  private: true,
952
1361
  type: "module",
1362
+ openclaw: {
1363
+ extensions: ["index.ts"]
1364
+ },
953
1365
  dependencies: {
954
- "@openclawbrain/openclaw": ">=0.2.0"
1366
+ [packageMetadata.name]: packageMetadata.version
955
1367
  }
956
1368
  }, null, 2) + "\n";
957
1369
  }
958
1370
  function buildExtensionPluginManifest() {
1371
+ const packageMetadata = readOpenClawPackageMetadata();
959
1372
  return JSON.stringify({
960
1373
  id: "openclawbrain",
961
1374
  name: "OpenClawBrain",
962
1375
  description: "Learned memory and context from OpenClawBrain",
963
- version: "0.2.0"
1376
+ version: packageMetadata.version,
1377
+ configSchema: {
1378
+ type: "object",
1379
+ additionalProperties: false,
1380
+ properties: {}
1381
+ }
964
1382
  }, null, 2) + "\n";
965
1383
  }
966
1384
  function formatContextForHuman(result) {
@@ -1045,10 +1463,10 @@ function runHistoryCommand(parsed) {
1045
1463
  const pointersPath = path.join(activationRoot, "activation-pointers.json");
1046
1464
  if (!existsSync(pointersPath)) {
1047
1465
  if (parsed.json) {
1048
- console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain setup" }, null, 2));
1466
+ console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain install" }, null, 2));
1049
1467
  }
1050
1468
  else {
1051
- console.log("No history yet. Run: openclawbrain setup");
1469
+ console.log("No history yet. Run: openclawbrain install");
1052
1470
  }
1053
1471
  return 0;
1054
1472
  }
@@ -1081,10 +1499,10 @@ function runHistoryCommand(parsed) {
1081
1499
  }
1082
1500
  if (entries.length === 0) {
1083
1501
  if (parsed.json) {
1084
- console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain setup" }, null, 2));
1502
+ console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain install" }, null, 2));
1085
1503
  }
1086
1504
  else {
1087
- console.log("No history yet. Run: openclawbrain setup");
1505
+ console.log("No history yet. Run: openclawbrain install");
1088
1506
  }
1089
1507
  return 0;
1090
1508
  }
@@ -1123,16 +1541,14 @@ function runHistoryCommand(parsed) {
1123
1541
  }
1124
1542
  function runSetupCommand(parsed) {
1125
1543
  const steps = [];
1544
+ const commandLabel = parsed.command.toUpperCase();
1545
+ const activationRootCreated = !existsSync(parsed.activationRoot);
1546
+ let bootstrapped = false;
1547
+ steps.push(`Target OpenClaw profile home: ${parsed.openclawHome} (${formatSetupOpenClawHomeSource(parsed.openclawHomeSource)})`);
1126
1548
  // 1. Validate --openclaw-home exists and has openclaw.json
1127
- if (!existsSync(parsed.openclawHome)) {
1128
- throw new Error(`--openclaw-home directory does not exist: ${parsed.openclawHome}`);
1129
- }
1130
- const openclawJsonPath = path.join(parsed.openclawHome, "openclaw.json");
1131
- if (!existsSync(openclawJsonPath)) {
1132
- throw new Error(`openclaw.json not found in ${parsed.openclawHome}`);
1133
- }
1549
+ validateOpenClawHome(parsed.openclawHome);
1134
1550
  // 2. Create activation root if needed
1135
- if (!existsSync(parsed.activationRoot)) {
1551
+ if (activationRootCreated) {
1136
1552
  mkdirSync(parsed.activationRoot, { recursive: true });
1137
1553
  steps.push(`Created activation root: ${parsed.activationRoot}`);
1138
1554
  }
@@ -1145,6 +1561,7 @@ function runSetupCommand(parsed) {
1145
1561
  steps.push("Brain already attached (activation-pointers.json exists), skipping bootstrap.");
1146
1562
  }
1147
1563
  else {
1564
+ bootstrapped = true;
1148
1565
  const packRoot = path.resolve(parsed.activationRoot, "packs", "initial");
1149
1566
  mkdirSync(packRoot, { recursive: true });
1150
1567
  const brainAttachmentPolicy = parsed.shared ? "shared" : "dedicated";
@@ -1173,6 +1590,16 @@ function runSetupCommand(parsed) {
1173
1590
  const indexTsPath = path.join(extensionDir, "index.ts");
1174
1591
  writeFileSync(indexTsPath, buildExtensionIndexTs(parsed.activationRoot), "utf8");
1175
1592
  steps.push(`Wrote extension: ${indexTsPath}`);
1593
+ // 4b. Write runtime-guard files (imported by index.ts as ./runtime-guard.js)
1594
+ const runtimeGuardPaths = resolveExtensionRuntimeGuardPath();
1595
+ if (runtimeGuardPaths.ts !== null) {
1596
+ const runtimeGuardTsPath = path.join(extensionDir, "runtime-guard.ts");
1597
+ writeFileSync(runtimeGuardTsPath, readFileSync(runtimeGuardPaths.ts, "utf8"), "utf8");
1598
+ steps.push(`Wrote extension runtime-guard source: ${runtimeGuardTsPath}`);
1599
+ }
1600
+ const runtimeGuardJsPath = path.join(extensionDir, "runtime-guard.js");
1601
+ writeFileSync(runtimeGuardJsPath, readFileSync(runtimeGuardPaths.js, "utf8"), "utf8");
1602
+ steps.push(`Wrote extension runtime-guard: ${runtimeGuardJsPath}`);
1176
1603
  // 5. Write package.json
1177
1604
  const packageJsonPath = path.join(extensionDir, "package.json");
1178
1605
  writeFileSync(packageJsonPath, buildExtensionPackageJson(), "utf8");
@@ -1184,7 +1611,13 @@ function runSetupCommand(parsed) {
1184
1611
  }
1185
1612
  catch (err) {
1186
1613
  const message = err instanceof Error ? err.message : String(err);
1187
- steps.push(`npm install failed (non-fatal): ${message}`);
1614
+ const linkedPackages = installExtensionFromLocalWorkspaceBuild(extensionDir);
1615
+ if (linkedPackages !== null) {
1616
+ steps.push(`Linked coherent local workspace packages: ${linkedPackages.join(", ")}`);
1617
+ }
1618
+ else {
1619
+ steps.push(`npm install failed (non-fatal): ${message}`);
1620
+ }
1188
1621
  }
1189
1622
  // 7. Write plugin manifest
1190
1623
  const manifestPath = path.join(extensionDir, "openclaw.plugin.json");
@@ -1266,46 +1699,341 @@ function runSetupCommand(parsed) {
1266
1699
  const message = err instanceof Error ? err.message : String(err);
1267
1700
  steps.push(`BRAIN.md generation failed (non-fatal): ${message}`);
1268
1701
  }
1702
+ const restartGuidance = buildInstallReloadGuidance();
1703
+ const nextSteps = [
1704
+ restartGuidance,
1705
+ `Check status: ${buildInstallStatusCommand(parsed.activationRoot)}`
1706
+ ];
1707
+ const lifecycleSummary = [
1708
+ `OpenClaw profile: ${shortenPath(parsed.openclawHome)} (${formatSetupOpenClawHomeSource(parsed.openclawHomeSource)})`,
1709
+ `Activation root: ${shortenPath(parsed.activationRoot)} (${formatSetupActivationRootSource(parsed.activationRootSource)})`,
1710
+ `Workspace ID: ${parsed.workspaceId} (${formatSetupWorkspaceIdSource(parsed.workspaceIdSource)})`,
1711
+ `Profile hook: installed at ${shortenPath(extensionDir)}`,
1712
+ activationRootCreated
1713
+ ? `Activation data: initialized at ${shortenPath(parsed.activationRoot)}`
1714
+ : `Activation data: reused at ${shortenPath(parsed.activationRoot)}`,
1715
+ bootstrapped
1716
+ ? "Brain attach: bootstrapped a seed/current-profile attach"
1717
+ : "Brain attach: existing activation state kept in place"
1718
+ ];
1269
1719
  // 9. Print summary
1270
1720
  if (parsed.json) {
1271
1721
  console.log(JSON.stringify({
1272
- command: "setup",
1722
+ command: parsed.command,
1273
1723
  openclawHome: parsed.openclawHome,
1724
+ openclawHomeSource: parsed.openclawHomeSource,
1274
1725
  activationRoot: parsed.activationRoot,
1726
+ resolvedInputs: {
1727
+ activationRoot: {
1728
+ value: parsed.activationRoot,
1729
+ source: parsed.activationRootSource
1730
+ },
1731
+ workspaceId: {
1732
+ value: parsed.workspaceId,
1733
+ source: parsed.workspaceIdSource
1734
+ }
1735
+ },
1275
1736
  workspaceId: parsed.workspaceId,
1276
1737
  shared: parsed.shared,
1277
1738
  extensionDir,
1739
+ lifecycleSummary,
1740
+ restartGuidance,
1741
+ nextSteps,
1742
+ steps
1743
+ }, null, 2));
1744
+ }
1745
+ else {
1746
+ console.log(`${commandLabel} complete\n`);
1747
+ for (const step of steps) {
1748
+ console.log(` ✓ ${step}`);
1749
+ }
1750
+ console.log("");
1751
+ console.log("Lifecycle:");
1752
+ for (const line of lifecycleSummary) {
1753
+ console.log(` ${line}`);
1754
+ }
1755
+ console.log(`Next: ${restartGuidance}`);
1756
+ console.log(`Check: ${buildInstallStatusCommand(parsed.activationRoot)}`);
1757
+ }
1758
+ return 0;
1759
+ }
1760
+ function validateOpenClawHome(openclawHome) {
1761
+ if (!existsSync(openclawHome)) {
1762
+ throw new Error(`--openclaw-home directory does not exist: ${openclawHome}`);
1763
+ }
1764
+ const openclawJsonPath = path.join(openclawHome, "openclaw.json");
1765
+ if (!existsSync(openclawJsonPath)) {
1766
+ throw new Error(`openclaw.json not found in ${openclawHome}`);
1767
+ }
1768
+ }
1769
+ function resolveCleanupActivationRoot(openclawHome, explicitActivationRoot) {
1770
+ if (explicitActivationRoot !== null) {
1771
+ return path.resolve(explicitActivationRoot);
1772
+ }
1773
+ const resolved = resolveActivationRoot({
1774
+ openclawHome,
1775
+ quiet: true
1776
+ });
1777
+ return resolved.trim().length === 0 ? null : path.resolve(resolved);
1778
+ }
1779
+ function removeProfileHookup(openclawHome, steps) {
1780
+ const extensionDir = path.join(openclawHome, "extensions", "openclawbrain");
1781
+ if (!existsSync(extensionDir)) {
1782
+ steps.push(`Profile hookup already absent: ${extensionDir}`);
1783
+ return extensionDir;
1784
+ }
1785
+ rmSync(extensionDir, { recursive: true, force: true });
1786
+ steps.push(`Removed profile hookup: ${extensionDir}`);
1787
+ return extensionDir;
1788
+ }
1789
+ function summarizeKeptActivationData(activationRoot) {
1790
+ if (activationRoot === null) {
1791
+ return {
1792
+ activationRoot: null,
1793
+ activationDataState: "unresolved",
1794
+ activationDataDetail: "Activation data preserved, but the activation root could not be resolved from the profile hook."
1795
+ };
1796
+ }
1797
+ return {
1798
+ activationRoot,
1799
+ activationDataState: "kept",
1800
+ activationDataDetail: `Activation data preserved at ${activationRoot}`
1801
+ };
1802
+ }
1803
+ function buildRestartGuidance(restart) {
1804
+ return buildCleanupRestartGuidance(restart);
1805
+ }
1806
+ function runDetachCommand(parsed) {
1807
+ const steps = [];
1808
+ validateOpenClawHome(parsed.openclawHome);
1809
+ const activationRoot = resolveCleanupActivationRoot(parsed.openclawHome, parsed.activationRoot);
1810
+ const extensionDir = removeProfileHookup(parsed.openclawHome, steps);
1811
+ const activationData = summarizeKeptActivationData(activationRoot);
1812
+ const restartGuidance = buildRestartGuidance(parsed.restart);
1813
+ const nextSteps = [
1814
+ restartGuidance,
1815
+ activationRoot === null ? null : `Inspect preserved data: ${buildInstallStatusCommand(activationRoot)}`,
1816
+ `Reattach later: ${buildInstallCommand(parsed.openclawHome)}`
1817
+ ].filter((step) => step !== null);
1818
+ steps.push(activationData.activationDataDetail);
1819
+ steps.push("Detach only removes the OpenClaw profile hook; it does not delete OpenClawBrain data.");
1820
+ if (parsed.json) {
1821
+ console.log(JSON.stringify({
1822
+ command: "detach",
1823
+ openclawHome: parsed.openclawHome,
1824
+ extensionDir,
1825
+ activationRoot,
1826
+ dataAction: "kept",
1827
+ activationDataState: activationData.activationDataState,
1828
+ restartMode: parsed.restart,
1829
+ restartGuidance,
1830
+ nextSteps,
1278
1831
  steps
1279
1832
  }, null, 2));
1280
1833
  }
1281
1834
  else {
1282
- console.log("SETUP complete\n");
1835
+ console.log("DETACH complete\n");
1283
1836
  for (const step of steps) {
1284
1837
  console.log(` ✓ ${step}`);
1285
1838
  }
1286
1839
  console.log("");
1287
- console.log(`Check status: openclawbrain status --activation-root ${quoteShellArg(parsed.activationRoot)}`);
1288
- console.log("Next step: Restart your OpenClaw gateway to activate the extension.");
1840
+ console.log(`Lifecycle: OpenClaw profile ${shortenPath(parsed.openclawHome)} is detached from the brain hook.`);
1841
+ if (activationRoot !== null) {
1842
+ console.log(`Brain data: ${shortenPath(activationRoot)} remains available for inspection or reattach.`);
1843
+ }
1844
+ else {
1845
+ console.log("Brain data: preserved, but the activation root could not be resolved from the removed hook.");
1846
+ }
1847
+ console.log(`Next: ${restartGuidance}`);
1848
+ if (activationRoot !== null) {
1849
+ console.log(`Check: ${buildInstallStatusCommand(activationRoot)}`);
1850
+ }
1851
+ console.log(`Reattach: ${buildInstallCommand(parsed.openclawHome)}`);
1289
1852
  }
1290
1853
  return 0;
1291
1854
  }
1855
+ function runUninstallCommand(parsed) {
1856
+ const steps = [];
1857
+ validateOpenClawHome(parsed.openclawHome);
1858
+ const activationRoot = resolveCleanupActivationRoot(parsed.openclawHome, parsed.activationRoot);
1859
+ const extensionDir = removeProfileHookup(parsed.openclawHome, steps);
1860
+ let activationData;
1861
+ if (parsed.dataMode === "purge") {
1862
+ if (activationRoot === null) {
1863
+ throw new Error("--purge-data requires a resolvable activation root from the installed profile hook or --activation-root <path>");
1864
+ }
1865
+ if (existsSync(activationRoot)) {
1866
+ rmSync(activationRoot, { recursive: true, force: true });
1867
+ activationData = {
1868
+ activationRoot,
1869
+ activationDataState: "removed",
1870
+ activationDataDetail: `Activation data removed at ${activationRoot}`
1871
+ };
1872
+ }
1873
+ else {
1874
+ activationData = {
1875
+ activationRoot,
1876
+ activationDataState: "already_absent",
1877
+ activationDataDetail: `Activation data already absent at ${activationRoot}`
1878
+ };
1879
+ }
1880
+ }
1881
+ else {
1882
+ activationData = summarizeKeptActivationData(activationRoot);
1883
+ }
1884
+ const restartGuidance = buildRestartGuidance(parsed.restart);
1885
+ const nextSteps = [
1886
+ restartGuidance,
1887
+ parsed.dataMode === "keep" && activationRoot !== null ? `Inspect preserved data: ${buildInstallStatusCommand(activationRoot)}` : null,
1888
+ `Reinstall later: ${buildInstallCommand(parsed.openclawHome)}`
1889
+ ].filter((step) => step !== null);
1890
+ steps.push(activationData.activationDataDetail);
1891
+ steps.push(parsed.dataMode === "purge"
1892
+ ? "Uninstall removed the OpenClaw profile hook and activation data."
1893
+ : "Uninstall removed the OpenClaw profile hook and kept activation data explicitly.");
1894
+ if (parsed.json) {
1895
+ console.log(JSON.stringify({
1896
+ command: "uninstall",
1897
+ openclawHome: parsed.openclawHome,
1898
+ extensionDir,
1899
+ activationRoot,
1900
+ dataAction: parsed.dataMode,
1901
+ activationDataState: activationData.activationDataState,
1902
+ restartMode: parsed.restart,
1903
+ restartGuidance,
1904
+ nextSteps,
1905
+ steps
1906
+ }, null, 2));
1907
+ }
1908
+ else {
1909
+ console.log("UNINSTALL complete\n");
1910
+ for (const step of steps) {
1911
+ console.log(` ✓ ${step}`);
1912
+ }
1913
+ console.log("");
1914
+ console.log(`Lifecycle: OpenClaw profile ${shortenPath(parsed.openclawHome)} no longer has the brain hook installed.`);
1915
+ console.log(`Data mode: ${parsed.dataMode === "purge" ? "purged" : "kept"}`);
1916
+ if (activationRoot !== null) {
1917
+ console.log(`Activation: ${parsed.dataMode === "purge" ? shortenPath(activationRoot) : `${shortenPath(activationRoot)} preserved`}`);
1918
+ }
1919
+ console.log(`Next: ${restartGuidance}`);
1920
+ if (parsed.dataMode === "keep" && activationRoot !== null) {
1921
+ console.log(`Check: ${buildInstallStatusCommand(activationRoot)}`);
1922
+ }
1923
+ console.log(`Reinstall: ${buildInstallCommand(parsed.openclawHome)}`);
1924
+ }
1925
+ return 0;
1926
+ }
1927
+ function resolveServeTimeLearningRuntimeInput(activationRoot) {
1928
+ let serveTimeDecisions = [];
1929
+ let fallbackReason = null;
1930
+ try {
1931
+ serveTimeDecisions = readLearningSpineLogEntries(activationRoot, "serveTimeRouteDecisions");
1932
+ }
1933
+ catch {
1934
+ fallbackReason = "serve_time_decision_log_read_failed";
1935
+ }
1936
+ const decisionLogCount = serveTimeDecisions.length;
1937
+ const pgVersion = decisionLogCount > 0 ? "v2" : "v1";
1938
+ return {
1939
+ pgVersion,
1940
+ serveTimeDecisions,
1941
+ decisionLogCount,
1942
+ baselineState: pgVersion === "v2" ? loadOrInitBaseline(activationRoot) : undefined,
1943
+ fallbackReason
1944
+ };
1945
+ }
1292
1946
  function runLearnCommand(parsed) {
1947
+ const learnStatePath = path.join(parsed.activationRoot, "learn-cli-state.json");
1948
+ const teacherSnapshotPath = resolveAsyncTeacherLiveLoopSnapshotPath(parsed.activationRoot);
1949
+ function isLearnRuntimeStateLike(value) {
1950
+ if (typeof value !== "object" || value === null) {
1951
+ return false;
1952
+ }
1953
+ const candidate = value;
1954
+ return (candidate.runtimeOwner === "openclaw" &&
1955
+ typeof candidate.cursor === "object" &&
1956
+ candidate.cursor !== null &&
1957
+ typeof candidate.pending === "object" &&
1958
+ candidate.pending !== null &&
1959
+ Array.isArray(candidate.pending.live) &&
1960
+ Array.isArray(candidate.pending.backfill) &&
1961
+ typeof candidate.materializationCount === "number" &&
1962
+ typeof candidate.sparseFeedback === "object" &&
1963
+ candidate.sparseFeedback !== null);
1964
+ }
1965
+ function loadPersistedLearnCliState() {
1966
+ if (!existsSync(learnStatePath)) {
1967
+ return {
1968
+ state: createAlwaysOnLearningRuntimeState(),
1969
+ loaded: false,
1970
+ resetReason: null
1971
+ };
1972
+ }
1973
+ try {
1974
+ const persisted = readJsonFile(learnStatePath);
1975
+ if (persisted.contract !== "openclaw.learn_cli_state.v1" || !isLearnRuntimeStateLike(persisted.state)) {
1976
+ throw new Error("persisted learn state shape is invalid");
1977
+ }
1978
+ return {
1979
+ state: persisted.state,
1980
+ loaded: true,
1981
+ resetReason: null
1982
+ };
1983
+ }
1984
+ catch (error) {
1985
+ return {
1986
+ state: createAlwaysOnLearningRuntimeState(),
1987
+ loaded: false,
1988
+ resetReason: error instanceof Error ? error.message : "persisted learn state could not be parsed"
1989
+ };
1990
+ }
1991
+ }
1992
+ function persistLearnCliState(state, updatedAt) {
1993
+ const payload = {
1994
+ contract: "openclaw.learn_cli_state.v1",
1995
+ updatedAt,
1996
+ state
1997
+ };
1998
+ mkdirSync(path.dirname(learnStatePath), { recursive: true });
1999
+ writeFileSync(learnStatePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
2000
+ }
1293
2001
  const activationRoot = parsed.activationRoot;
1294
- // 1. Discover local session stores
1295
- const stores = discoverOpenClawMainSessionStores();
2002
+ const persistedState = loadPersistedLearnCliState();
2003
+ const stores = discoverOpenClawSessionStores();
1296
2004
  if (stores.length === 0) {
2005
+ const labelFlow = {
2006
+ source: "missing",
2007
+ humanLabelCount: 0,
2008
+ selfLabelCount: 0,
2009
+ asyncTeacherArtifactCount: 0,
2010
+ implicitPositiveCount: 0,
2011
+ detail: "no local session stores were found"
2012
+ };
2013
+ const learningPath = summarizeLearningPathFromMaterialization(null);
1297
2014
  if (parsed.json) {
1298
- console.log(JSON.stringify({ command: "learn", activationRoot, scannedSessions: 0, newEvents: 0, materialized: null, promoted: false, message: "No local session stores found." }));
2015
+ console.log(JSON.stringify({
2016
+ command: "learn",
2017
+ activationRoot,
2018
+ scannedSessions: 0,
2019
+ newEvents: 0,
2020
+ loadedState: persistedState.loaded,
2021
+ statePath: learnStatePath,
2022
+ stateResetReason: persistedState.resetReason,
2023
+ materialized: null,
2024
+ promoted: false,
2025
+ graph: null,
2026
+ labelFlow,
2027
+ learningPath,
2028
+ message: "No local session stores found."
2029
+ }));
1299
2030
  }
1300
2031
  else {
1301
2032
  console.log("No new session data. Brain is up to date.");
1302
2033
  }
1303
2034
  return 0;
1304
2035
  }
1305
- // 2. Build passive learning export from ALL discovered sessions in one monotonic sequence space
1306
2036
  let totalSessions = 0;
1307
- let totalInteractionEvents = 0;
1308
- let totalFeedbackEvents = 0;
1309
2037
  const allInteractionEvents = [];
1310
2038
  const allFeedbackEvents = [];
1311
2039
  let nextSequence = 1;
@@ -1326,7 +2054,20 @@ function runLearnCommand(parsed) {
1326
2054
  return left.store.indexPath.localeCompare(right.store.indexPath);
1327
2055
  }
1328
2056
  return left.sessionKey.localeCompare(right.sessionKey);
1329
- });
2057
+ })
2058
+ .filter((() => {
2059
+ const seenSessionIds = new Set();
2060
+ return (session) => {
2061
+ const sessionId = session.entry.sessionId;
2062
+ if (sessionId !== undefined && seenSessionIds.has(sessionId)) {
2063
+ return false;
2064
+ }
2065
+ if (sessionId !== undefined) {
2066
+ seenSessionIds.add(sessionId);
2067
+ }
2068
+ return true;
2069
+ };
2070
+ })());
1330
2071
  for (const session of discoveredSessions) {
1331
2072
  const sessionFile = session.entry.sessionFile;
1332
2073
  const records = typeof sessionFile !== "string" || sessionFile.trim().length === 0
@@ -1348,24 +2089,75 @@ function runLearnCommand(parsed) {
1348
2089
  });
1349
2090
  nextSequence = sessionExport.nextSequence;
1350
2091
  totalSessions += 1;
1351
- totalInteractionEvents += sessionExport.interactionEvents.length;
1352
- totalFeedbackEvents += sessionExport.feedbackEvents.length;
1353
2092
  allInteractionEvents.push(...sessionExport.interactionEvents);
1354
2093
  allFeedbackEvents.push(...sessionExport.feedbackEvents);
1355
2094
  }
1356
- const totalEvents = totalInteractionEvents + totalFeedbackEvents;
2095
+ const seenInteractionIds = new Set();
2096
+ const dedupedInteractionEvents = [];
2097
+ for (const event of allInteractionEvents) {
2098
+ if (!seenInteractionIds.has(event.eventId)) {
2099
+ seenInteractionIds.add(event.eventId);
2100
+ dedupedInteractionEvents.push(event);
2101
+ }
2102
+ }
2103
+ const seenFeedbackIds = new Set();
2104
+ const dedupedFeedbackEvents = [];
2105
+ for (const event of allFeedbackEvents) {
2106
+ if (!seenFeedbackIds.has(event.eventId)) {
2107
+ seenFeedbackIds.add(event.eventId);
2108
+ dedupedFeedbackEvents.push(event);
2109
+ }
2110
+ }
2111
+ const totalEvents = dedupedInteractionEvents.length + dedupedFeedbackEvents.length;
2112
+ const now = new Date().toISOString();
2113
+ const normalizedEventExport = totalEvents === 0
2114
+ ? null
2115
+ : buildNormalizedEventExport({
2116
+ interactionEvents: dedupedInteractionEvents,
2117
+ feedbackEvents: dedupedFeedbackEvents
2118
+ });
2119
+ const teacherArtifacts = normalizedEventExport === null
2120
+ ? []
2121
+ : buildTeacherSupervisionArtifactsFromNormalizedEventExport({
2122
+ normalizedEventExport,
2123
+ observedAt: now
2124
+ });
2125
+ const labelFlow = normalizedEventExport === null
2126
+ ? {
2127
+ source: "missing",
2128
+ humanLabelCount: 0,
2129
+ selfLabelCount: 0,
2130
+ asyncTeacherArtifactCount: 0,
2131
+ implicitPositiveCount: 0,
2132
+ detail: "no normalized learning export was built"
2133
+ }
2134
+ : summarizeNormalizedEventExportLabelFlow(normalizedEventExport, teacherArtifacts.length);
1357
2135
  if (totalEvents === 0) {
1358
2136
  if (parsed.json) {
1359
- console.log(JSON.stringify({ command: "learn", activationRoot, scannedSessions: totalSessions, newEvents: 0, materialized: null, promoted: false, message: "No new session data. Brain is up to date." }));
2137
+ console.log(JSON.stringify({
2138
+ command: "learn",
2139
+ activationRoot,
2140
+ scannedSessions: totalSessions,
2141
+ newEvents: 0,
2142
+ loadedState: persistedState.loaded,
2143
+ statePath: learnStatePath,
2144
+ stateResetReason: persistedState.resetReason,
2145
+ materialized: null,
2146
+ promoted: false,
2147
+ graph: null,
2148
+ labelFlow,
2149
+ learningPath: summarizeLearningPathFromMaterialization(null),
2150
+ message: "No new session data. Brain is up to date."
2151
+ }));
1360
2152
  }
1361
2153
  else {
1362
2154
  console.log("No new session data. Brain is up to date.");
1363
2155
  }
1364
2156
  return 0;
1365
2157
  }
1366
- // 3. Run single learning cycle
1367
- const now = new Date().toISOString();
1368
- const learnerResult = advanceAlwaysOnLearningRuntime({
2158
+ const learningExport = normalizedEventExport;
2159
+ const serveTimeLearning = resolveServeTimeLearningRuntimeInput(activationRoot);
2160
+ const learnerResult = drainAlwaysOnLearningRuntime({
1369
2161
  packLabel: "learn-cli",
1370
2162
  workspace: {
1371
2163
  workspaceId: "learn-cli",
@@ -1374,17 +2166,58 @@ function runLearnCommand(parsed) {
1374
2166
  rootDir: activationRoot,
1375
2167
  revision: "learn-cli-v1"
1376
2168
  },
1377
- interactionEvents: allInteractionEvents,
1378
- feedbackEvents: allFeedbackEvents,
2169
+ interactionEvents: dedupedInteractionEvents,
2170
+ feedbackEvents: dedupedFeedbackEvents,
2171
+ teacherSupervisionArtifacts: teacherArtifacts,
1379
2172
  learnedRouting: true,
1380
- state: createAlwaysOnLearningRuntimeState(),
1381
- builtAt: now
2173
+ state: persistedState.state,
2174
+ builtAt: now,
2175
+ maxCycles: 16,
2176
+ pgVersion: serveTimeLearning.pgVersion,
2177
+ ...(serveTimeLearning.decisionLogCount > 0 ? { serveTimeDecisions: serveTimeLearning.serveTimeDecisions } : {}),
2178
+ ...(serveTimeLearning.baselineState !== undefined ? { baselineState: serveTimeLearning.baselineState } : {})
1382
2179
  });
1383
- // 4. If materialization produced, materialize → stage → promote
1384
- if (learnerResult.materialization !== null) {
2180
+ const lastMaterialization = learnerResult.materializations.at(-1) ?? null;
2181
+ const plan = describeAlwaysOnLearningRuntimeState(learnerResult.state, lastMaterialization);
2182
+ const learningPath = summarizeLearningPathFromMaterialization(lastMaterialization);
2183
+ const supervisionCount = lastMaterialization?.candidate.summary.learnedRouter.supervisionCount ?? 0;
2184
+ const routerUpdateCount = lastMaterialization?.candidate.summary.learnedRouter.updateCount ?? 0;
2185
+ const routerNoOpReason = lastMaterialization?.candidate.summary.learnedRouter.noOpReason ?? null;
2186
+ const graphEvolution = lastMaterialization?.candidate.payloads.graph.evolution;
2187
+ const graphSummary = graphEvolution === undefined
2188
+ ? null
2189
+ : {
2190
+ structuralOps: graphEvolution.structuralOps,
2191
+ connectDiagnostics: graphEvolution.connectDiagnostics ?? null
2192
+ };
2193
+ const connectSummary = graphSummary?.connectDiagnostics === null || graphSummary?.connectDiagnostics === undefined
2194
+ ? ""
2195
+ : ` connect candidates=${graphSummary.connectDiagnostics.candidatePairCount} applied=${graphSummary.connectDiagnostics.appliedPairCount} edges=${graphSummary.connectDiagnostics.createdEdgeCount}.`;
2196
+ const routingBuild = lastMaterialization?.candidate.routingBuild ?? {
2197
+ learnedRoutingPath: serveTimeLearning.pgVersion === "v2" ? "policy_gradient_v2" : "policy_gradient_v1",
2198
+ pgVersionRequested: serveTimeLearning.pgVersion,
2199
+ pgVersionUsed: serveTimeLearning.pgVersion,
2200
+ decisionLogCount: serveTimeLearning.decisionLogCount,
2201
+ fallbackReason: serveTimeLearning.pgVersion === "v1" ? serveTimeLearning.fallbackReason ?? "no_serve_time_decisions" : null,
2202
+ updatedBaseline: null
2203
+ };
2204
+ const learnPathReport = {
2205
+ ...routingBuild,
2206
+ fallbackReason: routingBuild.fallbackReason ??
2207
+ (routingBuild.pgVersionUsed === "v1" ? serveTimeLearning.fallbackReason ?? "no_serve_time_decisions" : null)
2208
+ };
2209
+ let promoted = false;
2210
+ let materializedPackId = null;
2211
+ let baselinePersisted = false;
2212
+ const latestTeacherFreshness = teacherArtifacts.length === 0
2213
+ ? "none"
2214
+ : teacherArtifacts.some((artifact) => artifact.freshness.status === "fresh")
2215
+ ? "fresh"
2216
+ : "stale";
2217
+ if (lastMaterialization !== null) {
1385
2218
  const candidatePackRoot = path.join(activationRoot, "packs", `learn-cli-${Date.now()}`);
1386
2219
  mkdirSync(candidatePackRoot, { recursive: true });
1387
- const candidateDescriptor = materializeAlwaysOnLearningCandidatePack(candidatePackRoot, learnerResult.materialization);
2220
+ const candidateDescriptor = materializeAlwaysOnLearningCandidatePack(candidatePackRoot, lastMaterialization);
1388
2221
  stageCandidatePack(activationRoot, candidatePackRoot, {
1389
2222
  updatedAt: now,
1390
2223
  reason: "learn_cli_stage"
@@ -1393,37 +2226,111 @@ function runLearnCommand(parsed) {
1393
2226
  updatedAt: now,
1394
2227
  reason: "learn_cli_promote"
1395
2228
  });
1396
- const packId = candidateDescriptor.manifest.packId;
1397
- if (parsed.json) {
1398
- console.log(JSON.stringify({
1399
- command: "learn",
1400
- activationRoot,
1401
- scannedSessions: totalSessions,
1402
- newEvents: totalEvents,
1403
- materialized: packId,
1404
- promoted: true,
1405
- message: `Scanned ${totalSessions} sessions, ${totalEvents} new events, materialized ${packId}, promoted.`
1406
- }, null, 2));
1407
- }
1408
- else {
1409
- console.log(`Scanned ${totalSessions} sessions, ${totalEvents} new events, materialized ${packId}, promoted.`);
2229
+ if (learnPathReport.pgVersionUsed === "v2" && learnPathReport.updatedBaseline !== null) {
2230
+ persistBaseline(activationRoot, learnPathReport.updatedBaseline);
2231
+ baselinePersisted = true;
2232
+ }
2233
+ materializedPackId = candidateDescriptor.manifest.packId;
2234
+ promoted = true;
2235
+ }
2236
+ persistLearnCliState(learnerResult.state, now);
2237
+ writeJsonFile(teacherSnapshotPath, {
2238
+ runtimeOwner: "openclaw",
2239
+ queue: {
2240
+ capacity: 1,
2241
+ depth: 0,
2242
+ running: false
2243
+ },
2244
+ teacher: {
2245
+ artifactCount: teacherArtifacts.length,
2246
+ artifacts: teacherArtifacts,
2247
+ latestFreshness: latestTeacherFreshness
2248
+ },
2249
+ learner: {
2250
+ state: learnerResult.state,
2251
+ lastMaterialization
2252
+ },
2253
+ diagnostics: {
2254
+ acceptedExportCount: 1,
2255
+ processedExportCount: 1,
2256
+ duplicateExportCount: 0,
2257
+ droppedExportCount: 0,
2258
+ emittedArtifactCount: teacherArtifacts.length,
2259
+ dedupedArtifactCount: 0,
2260
+ lastProcessedAt: now,
2261
+ latestFreshness: latestTeacherFreshness,
2262
+ lastNoOpReason: teacherArtifacts.length === 0 ? "no_teacher_artifacts" : "none",
2263
+ notes: [
2264
+ `learn-cli export=${learningExport.provenance.exportDigest} range=${learningExport.range.start}-${learningExport.range.end}/${learningExport.range.count}`,
2265
+ `teacher artifacts=${teacherArtifacts.length} freshness=${latestTeacherFreshness}`,
2266
+ `last materialized pack=${materializedPackId ?? "none"}`
2267
+ ]
2268
+ },
2269
+ state: {
2270
+ interactionEvents: learningExport.interactionEvents,
2271
+ feedbackEvents: learningExport.feedbackEvents,
2272
+ seenExportDigests: [learningExport.provenance.exportDigest]
2273
+ },
2274
+ runtime: {
2275
+ startedAt: now,
2276
+ lastHeartbeatAt: now,
2277
+ lastScanAt: now,
2278
+ scanRoot: null,
2279
+ lastAppliedMaterializationJobId: lastMaterialization?.jobId ?? null
1410
2280
  }
2281
+ });
2282
+ const summaryMessage = materializedPackId === null
2283
+ ? `Scanned ${totalSessions} sessions, ${totalEvents} new events, no candidate materialized, no promotion.`
2284
+ : `Scanned ${totalSessions} sessions, ${totalEvents} new events, materialized ${materializedPackId}, promoted.${connectSummary}`;
2285
+ if (parsed.json) {
2286
+ console.log(JSON.stringify({
2287
+ command: "learn",
2288
+ activationRoot,
2289
+ scannedSessions: totalSessions,
2290
+ newEvents: totalEvents,
2291
+ loadedState: persistedState.loaded,
2292
+ statePath: learnStatePath,
2293
+ stateResetReason: persistedState.resetReason,
2294
+ drain: {
2295
+ cyclesRun: learnerResult.cycles.length,
2296
+ stopReason: learnerResult.stopReason,
2297
+ drained: learnerResult.drained,
2298
+ materializationCount: learnerResult.materializations.length
2299
+ },
2300
+ learner: {
2301
+ teacherBudget: learnerResult.state.sparseFeedback.teacherBudget,
2302
+ eligibleFeedbackCount: learnerResult.state.sparseFeedback.eligibleFeedbackCount,
2303
+ budgetedOutFeedbackCount: learnerResult.state.sparseFeedback.budgetedOutFeedbackCount,
2304
+ supervisionCount,
2305
+ routerUpdateCount,
2306
+ routerNoOpReason,
2307
+ pending: plan.pending,
2308
+ learnedRange: plan.learnedRange
2309
+ },
2310
+ materialized: materializedPackId,
2311
+ promoted,
2312
+ graph: graphSummary,
2313
+ labelFlow,
2314
+ learningPath,
2315
+ learnedRoutingPath: learnPathReport.learnedRoutingPath,
2316
+ pgVersionRequested: learnPathReport.pgVersionRequested,
2317
+ pgVersionUsed: learnPathReport.pgVersionUsed,
2318
+ decisionLogCount: learnPathReport.decisionLogCount,
2319
+ fallbackReason: learnPathReport.fallbackReason,
2320
+ baselinePersisted,
2321
+ message: summaryMessage
2322
+ }, null, 2));
1411
2323
  }
1412
2324
  else {
1413
- if (parsed.json) {
1414
- console.log(JSON.stringify({
1415
- command: "learn",
1416
- activationRoot,
1417
- scannedSessions: totalSessions,
1418
- newEvents: totalEvents,
1419
- materialized: null,
1420
- promoted: false,
1421
- message: "No new session data. Brain is up to date."
1422
- }, null, 2));
1423
- }
1424
- else {
1425
- console.log("No new session data. Brain is up to date.");
1426
- }
2325
+ const text = materializedPackId === null
2326
+ ? `Scanned ${totalSessions} sessions, ${totalEvents} new events, no promotion. cycles=${learnerResult.cycles.length} stop=${learnerResult.stopReason} supervision=${supervisionCount}.`
2327
+ : `Scanned ${totalSessions} sessions, ${totalEvents} new events, materialized ${materializedPackId}, promoted.${connectSummary} cycles=${learnerResult.cycles.length} supervision=${supervisionCount}.`;
2328
+ console.log(text);
2329
+ console.log(`labels: source=${labelFlow.source} human=${labelFlow.humanLabelCount ?? "none"} self=${labelFlow.selfLabelCount ?? "none"} implicitPositive=${labelFlow.implicitPositiveCount ?? "none"} teacherArtifacts=${labelFlow.asyncTeacherArtifactCount ?? "none"}`);
2330
+ console.log(`path: source=${learningPath.source} pg=${learningPath.policyGradientVersion} method=${learningPath.policyGradientMethod ?? "none"} target=${learningPath.targetConstruction ?? "none"} connect=${learningPath.connectOpsFired ?? "none"} trajectories=${learningPath.reconstructedTrajectoryCount ?? "none"}`);
2331
+ console.log(`learned routing: path=${learnPathReport.learnedRoutingPath} pg=${learnPathReport.pgVersionUsed ?? "n/a"} decisions=${learnPathReport.decisionLogCount}` +
2332
+ `${learnPathReport.fallbackReason === null ? "" : ` fallback=${learnPathReport.fallbackReason}`}` +
2333
+ `${learnPathReport.pgVersionUsed === "v2" ? ` baseline=${baselinePersisted ? "persisted" : "unchanged"}` : ""}`);
1427
2334
  }
1428
2335
  return 0;
1429
2336
  }
@@ -1434,15 +2341,258 @@ function formatTimestamp() {
1434
2341
  function watchLog(message) {
1435
2342
  console.log(`${formatTimestamp()} ${message}`);
1436
2343
  }
1437
- async function runWatchCommand(parsed) {
1438
- const activationRoot = parsed.activationRoot;
1439
- const scanRoot = parsed.scanRoot !== null
1440
- ? path.resolve(parsed.scanRoot)
2344
+ function resolveOperatorTeacherSnapshotPath(activationRoot, explicitPath) {
2345
+ if (explicitPath !== null && explicitPath !== undefined) {
2346
+ return explicitPath;
2347
+ }
2348
+ const asyncSnapshotPath = resolveAsyncTeacherLiveLoopSnapshotPath(activationRoot);
2349
+ return existsSync(asyncSnapshotPath) ? asyncSnapshotPath : null;
2350
+ }
2351
+ const WATCH_STATE_DIRNAME = "watch";
2352
+ const WATCH_SESSION_TAIL_CURSOR_BASENAME = "session-tail-cursor.json";
2353
+ const WATCH_TEACHER_SNAPSHOT_BASENAME = "teacher-snapshot.json";
2354
+ function sanitizeWatchPathSegment(value) {
2355
+ const sanitized = value
2356
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
2357
+ .replace(/^-+|-+$/g, "")
2358
+ .slice(0, 96);
2359
+ return sanitized.length > 0 ? sanitized : "session";
2360
+ }
2361
+ function resolveWatchStateRoot(activationRoot) {
2362
+ return path.resolve(activationRoot, WATCH_STATE_DIRNAME);
2363
+ }
2364
+ function resolveWatchSessionTailCursorPath(activationRoot) {
2365
+ return path.join(resolveWatchStateRoot(activationRoot), WATCH_SESSION_TAIL_CURSOR_BASENAME);
2366
+ }
2367
+ function resolveWatchTeacherSnapshotPath(activationRoot) {
2368
+ return path.join(resolveWatchStateRoot(activationRoot), WATCH_TEACHER_SNAPSHOT_BASENAME);
2369
+ }
2370
+ function readOptionalJsonFile(filePath) {
2371
+ if (!existsSync(filePath)) {
2372
+ return null;
2373
+ }
2374
+ try {
2375
+ return JSON.parse(readFileSync(filePath, "utf8"));
2376
+ }
2377
+ catch {
2378
+ return null;
2379
+ }
2380
+ }
2381
+ function writeJsonFile(filePath, value) {
2382
+ mkdirSync(path.dirname(filePath), { recursive: true });
2383
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
2384
+ }
2385
+ function loadWatchSessionTailCursor(cursorPath) {
2386
+ const parsed = readOptionalJsonFile(cursorPath);
2387
+ if (Array.isArray(parsed)) {
2388
+ return parsed;
2389
+ }
2390
+ if (parsed !== null && Array.isArray(parsed.cursor)) {
2391
+ return parsed.cursor;
2392
+ }
2393
+ return [];
2394
+ }
2395
+ function persistWatchSessionTailCursor(cursorPath, cursor) {
2396
+ writeJsonFile(cursorPath, {
2397
+ contract: "openclaw_watch_session_tail_cursor.v1",
2398
+ runtimeOwner: "openclaw",
2399
+ updatedAt: new Date().toISOString(),
2400
+ cursor
2401
+ });
2402
+ }
2403
+ function loadWatchTeacherSnapshotState(snapshotPath) {
2404
+ const parsed = readOptionalJsonFile(snapshotPath);
2405
+ return {
2406
+ lastHandledMaterializationPackId: parsed !== null && typeof parsed.lastHandledMaterializationPackId === "string"
2407
+ ? parsed.lastHandledMaterializationPackId
2408
+ : null
2409
+ };
2410
+ }
2411
+ function persistWatchTeacherSnapshot(snapshotPath, input) {
2412
+ writeJsonFile(snapshotPath, {
2413
+ contract: "openclaw_watch_teacher_snapshot.v1",
2414
+ runtimeOwner: "openclaw",
2415
+ updatedAt: new Date().toISOString(),
2416
+ scanRoot: input.scanRoot,
2417
+ replayedBundleCount: input.replayedBundleCount,
2418
+ replayedEventCount: input.replayedEventCount,
2419
+ exportedBundleCount: input.exportedBundleCount,
2420
+ exportedEventCount: input.exportedEventCount,
2421
+ localSessionTailNoopReason: input.localSessionTailNoopReason,
2422
+ lastHandledMaterializationPackId: input.lastHandledMaterializationPackId,
2423
+ snapshot: input.snapshot
2424
+ });
2425
+ }
2426
+ function listWatchRuntimeEventExportBundleRoots(scanRoot) {
2427
+ if (!existsSync(scanRoot)) {
2428
+ return [];
2429
+ }
2430
+ return readdirSync(scanRoot, { withFileTypes: true })
2431
+ .filter((entry) => entry.isDirectory())
2432
+ .map((entry) => path.join(scanRoot, entry.name))
2433
+ .sort((left, right) => left.localeCompare(right));
2434
+ }
2435
+ async function replayWatchScanRootIntoTeacherLoop(teacherLoop, scanRoot) {
2436
+ const seenExportDigests = new Set();
2437
+ const bundles = listWatchRuntimeEventExportBundleRoots(scanRoot)
2438
+ .map((rootDir) => {
2439
+ try {
2440
+ return loadRuntimeEventExportBundle(rootDir);
2441
+ }
2442
+ catch {
2443
+ return null;
2444
+ }
2445
+ })
2446
+ .filter((bundle) => bundle !== null)
2447
+ .sort((left, right) => {
2448
+ const exportedAtCompare = left.manifest.exportedAt.localeCompare(right.manifest.exportedAt);
2449
+ if (exportedAtCompare !== 0) {
2450
+ return exportedAtCompare;
2451
+ }
2452
+ if (left.normalizedEventExport.range.start !== right.normalizedEventExport.range.start) {
2453
+ return left.normalizedEventExport.range.start - right.normalizedEventExport.range.start;
2454
+ }
2455
+ if (left.normalizedEventExport.range.end !== right.normalizedEventExport.range.end) {
2456
+ return left.normalizedEventExport.range.end - right.normalizedEventExport.range.end;
2457
+ }
2458
+ return left.normalizedEventExport.provenance.exportDigest.localeCompare(right.normalizedEventExport.provenance.exportDigest);
2459
+ });
2460
+ let replayedBundleCount = 0;
2461
+ let replayedEventCount = 0;
2462
+ for (const bundle of bundles) {
2463
+ const exportDigest = bundle.normalizedEventExport.provenance.exportDigest;
2464
+ if (seenExportDigests.has(exportDigest)) {
2465
+ continue;
2466
+ }
2467
+ seenExportDigests.add(exportDigest);
2468
+ let enqueue = teacherLoop.enqueueNormalizedEventExport(bundle.normalizedEventExport, {
2469
+ observedAt: bundle.manifest.exportedAt
2470
+ });
2471
+ if (!enqueue.accepted && enqueue.reason === "queue_full") {
2472
+ await teacherLoop.flush();
2473
+ enqueue = teacherLoop.enqueueNormalizedEventExport(bundle.normalizedEventExport, {
2474
+ observedAt: bundle.manifest.exportedAt
2475
+ });
2476
+ }
2477
+ if (!enqueue.accepted) {
2478
+ continue;
2479
+ }
2480
+ replayedBundleCount += 1;
2481
+ replayedEventCount += bundle.normalizedEventExport.range.count;
2482
+ }
2483
+ if (replayedBundleCount > 0) {
2484
+ await teacherLoop.flush();
2485
+ }
2486
+ return {
2487
+ replayedBundleCount,
2488
+ replayedEventCount
2489
+ };
2490
+ }
2491
+ function exportLocalSessionTailChangesToScanRoot(input) {
2492
+ let exportedBundleCount = 0;
2493
+ let exportedEventCount = 0;
2494
+ const warnings = [];
2495
+ for (const change of input.changes) {
2496
+ if (change.scannedEventExport === null) {
2497
+ continue;
2498
+ }
2499
+ const built = buildNormalizedEventExportFromScannedEvents(change.scannedEventExport);
2500
+ if (!built.ok) {
2501
+ warnings.push(`${change.sessionKey}: ${built.error}`);
2502
+ continue;
2503
+ }
2504
+ const exportDigest = built.normalizedEventExport.provenance.exportDigest.replace(/^sha256-/u, "");
2505
+ const exportName = `session-tail-${sanitizeWatchPathSegment(change.sessionKey)}-${built.normalizedEventExport.range.start}-${built.normalizedEventExport.range.end}-${exportDigest.slice(0, 12)}`;
2506
+ const result = writeScannedEventExportBundle({
2507
+ rootDir: path.join(input.scanRoot, exportName),
2508
+ exportName,
2509
+ exportedAt: input.polledAt,
2510
+ scannedEventExport: change.scannedEventExport
2511
+ });
2512
+ if (!result.ok) {
2513
+ warnings.push(`${change.sessionKey}: ${result.error}`);
2514
+ continue;
2515
+ }
2516
+ exportedBundleCount += 1;
2517
+ exportedEventCount += result.normalizedEventExport.range.count;
2518
+ }
2519
+ return {
2520
+ exportedBundleCount,
2521
+ exportedEventCount,
2522
+ warnings
2523
+ };
2524
+ }
2525
+ function applyWatchMaterialization(activationRoot, snapshot, lastHandledMaterializationPackId) {
2526
+ const materialization = snapshot?.learner?.lastMaterialization ?? null;
2527
+ if (materialization === null) {
2528
+ return {
2529
+ lastHandledMaterializationPackId,
2530
+ logLine: null,
2531
+ materializedPackId: null
2532
+ };
2533
+ }
2534
+ const packId = typeof materialization?.candidate?.summary?.packId === "string"
2535
+ ? materialization.candidate.summary.packId
2536
+ : null;
2537
+ if (packId === null || packId === lastHandledMaterializationPackId) {
2538
+ return {
2539
+ lastHandledMaterializationPackId,
2540
+ logLine: null,
2541
+ materializedPackId: packId
2542
+ };
2543
+ }
2544
+ const shortPackId = packId.length > 16 ? packId.slice(0, 16) : packId;
2545
+ try {
2546
+ const candidateRootDir = path.resolve(activationRoot, "packs", packId);
2547
+ mkdirSync(candidateRootDir, { recursive: true });
2548
+ materializeAlwaysOnLearningCandidatePack(candidateRootDir, materialization);
2549
+ const now = new Date().toISOString();
2550
+ stageCandidatePack(activationRoot, candidateRootDir, {
2551
+ updatedAt: now,
2552
+ reason: `watch_stage:${materialization.reason}:${materialization.lane}`
2553
+ });
2554
+ const inspection = inspectActivationState(activationRoot, now);
2555
+ if (inspection.promotion.allowed) {
2556
+ promoteCandidatePack(activationRoot, {
2557
+ updatedAt: now,
2558
+ reason: `watch_promote:${materialization.reason}:${materialization.lane}`
2559
+ });
2560
+ return {
2561
+ lastHandledMaterializationPackId: packId,
2562
+ materializedPackId: packId,
2563
+ logLine: `Promoted ${shortPackId} → active`
2564
+ };
2565
+ }
2566
+ return {
2567
+ lastHandledMaterializationPackId: packId,
2568
+ materializedPackId: packId,
2569
+ logLine: `Staged ${shortPackId} (promotion blocked: ${inspection.promotion.findings.join(", ")})`
2570
+ };
2571
+ }
2572
+ catch (error) {
2573
+ const message = error instanceof Error ? error.message : String(error);
2574
+ return {
2575
+ lastHandledMaterializationPackId,
2576
+ materializedPackId: packId,
2577
+ logLine: `Promotion failed for ${shortPackId}: ${message}`
2578
+ };
2579
+ }
2580
+ }
2581
+ export async function createWatchCommandRuntime(input) {
2582
+ const activationRoot = path.resolve(input.activationRoot);
2583
+ const scanRoot = input.scanRoot !== undefined && input.scanRoot !== null
2584
+ ? path.resolve(input.scanRoot)
1441
2585
  : path.resolve(activationRoot, "event-exports");
1442
- const intervalMs = parsed.interval * 1000;
1443
- watchLog(`Watch starting activation: ${shortenPath(activationRoot)}`);
1444
- watchLog(`Scan root: ${shortenPath(scanRoot)} interval: ${parsed.interval}s`);
2586
+ const sessionTailCursorPath = resolveWatchSessionTailCursorPath(activationRoot);
2587
+ const teacherSnapshotPath = resolveWatchTeacherSnapshotPath(activationRoot);
2588
+ const log = input.log ?? watchLog;
2589
+ mkdirSync(scanRoot, { recursive: true });
2590
+ mkdirSync(resolveWatchStateRoot(activationRoot), { recursive: true });
2591
+ log(`Watch starting — activation: ${shortenPath(activationRoot)}`);
2592
+ log(`Scan root: ${shortenPath(scanRoot)}`);
2593
+ log(`State: cursor=${shortenPath(sessionTailCursorPath)} snapshot=${shortenPath(teacherSnapshotPath)}`);
1445
2594
  const scanner = createRuntimeEventExportScanner({ scanRoot });
2595
+ let lastServeTimeFallbackReason = null;
1446
2596
  const teacherLoop = createAsyncTeacherLiveLoop({
1447
2597
  packLabel: "watch-cli",
1448
2598
  workspace: {
@@ -1450,10 +2600,166 @@ async function runWatchCommand(parsed) {
1450
2600
  snapshotId: `watch-cli@${new Date().toISOString().slice(0, 10)}`,
1451
2601
  capturedAt: new Date().toISOString(),
1452
2602
  rootDir: activationRoot,
1453
- revision: "watch-cli-v1"
2603
+ revision: "watch-cli-v2"
1454
2604
  },
1455
- learnedRouting: true
2605
+ learnedRouting: true,
2606
+ resolveLearnedRoutingState: () => {
2607
+ const resolved = resolveServeTimeLearningRuntimeInput(activationRoot);
2608
+ if (resolved.fallbackReason !== null && resolved.fallbackReason !== lastServeTimeFallbackReason) {
2609
+ log(`Serve-time routing fallback: ${resolved.fallbackReason}`);
2610
+ }
2611
+ lastServeTimeFallbackReason = resolved.fallbackReason;
2612
+ return {
2613
+ pgVersion: resolved.pgVersion,
2614
+ ...(resolved.decisionLogCount > 0 ? { serveTimeDecisions: resolved.serveTimeDecisions } : {}),
2615
+ ...(resolved.baselineState !== undefined ? { baselineState: resolved.baselineState } : {})
2616
+ };
2617
+ },
2618
+ persistUpdatedBaseline: (state) => {
2619
+ try {
2620
+ persistBaseline(activationRoot, state);
2621
+ }
2622
+ catch (error) {
2623
+ const message = error instanceof Error ? error.message : String(error);
2624
+ log(`Baseline persist failed: ${message}`);
2625
+ }
2626
+ }
2627
+ });
2628
+ let restoredCursor = loadWatchSessionTailCursor(sessionTailCursorPath);
2629
+ let localSessionTail;
2630
+ try {
2631
+ localSessionTail = createOpenClawLocalSessionTail({
2632
+ ...(input.profileRoots === undefined ? {} : { profileRoots: input.profileRoots }),
2633
+ cursor: restoredCursor,
2634
+ emitExistingOnFirstPoll: restoredCursor.length === 0
2635
+ });
2636
+ }
2637
+ catch (error) {
2638
+ const message = error instanceof Error ? error.message : String(error);
2639
+ log(`Session tail cursor reset: ${message}`);
2640
+ restoredCursor = [];
2641
+ localSessionTail = createOpenClawLocalSessionTail({
2642
+ ...(input.profileRoots === undefined ? {} : { profileRoots: input.profileRoots }),
2643
+ emitExistingOnFirstPoll: true
2644
+ });
2645
+ persistWatchSessionTailCursor(sessionTailCursorPath, []);
2646
+ }
2647
+ let lastHandledMaterializationPackId = loadWatchTeacherSnapshotState(teacherSnapshotPath).lastHandledMaterializationPackId;
2648
+ const replayState = await replayWatchScanRootIntoTeacherLoop(teacherLoop, scanRoot);
2649
+ if (replayState.replayedBundleCount > 0) {
2650
+ log(`Replayed ${replayState.replayedBundleCount} stored export bundle${replayState.replayedBundleCount === 1 ? "" : "s"} (${replayState.replayedEventCount} event${replayState.replayedEventCount === 1 ? "" : "s"})`);
2651
+ }
2652
+ let bootstrapSnapshot = teacherLoop.snapshot();
2653
+ const replayPromotion = applyWatchMaterialization(activationRoot, bootstrapSnapshot, lastHandledMaterializationPackId);
2654
+ lastHandledMaterializationPackId = replayPromotion.lastHandledMaterializationPackId;
2655
+ if (replayPromotion.logLine !== null) {
2656
+ log(replayPromotion.logLine);
2657
+ bootstrapSnapshot = teacherLoop.snapshot();
2658
+ }
2659
+ persistWatchTeacherSnapshot(teacherSnapshotPath, {
2660
+ scanRoot,
2661
+ replayedBundleCount: replayState.replayedBundleCount,
2662
+ replayedEventCount: replayState.replayedEventCount,
2663
+ exportedBundleCount: 0,
2664
+ exportedEventCount: 0,
2665
+ localSessionTailNoopReason: null,
2666
+ lastHandledMaterializationPackId,
2667
+ snapshot: bootstrapSnapshot
1456
2668
  });
2669
+ return {
2670
+ activationRoot,
2671
+ scanRoot,
2672
+ sessionTailCursorPath,
2673
+ teacherSnapshotPath,
2674
+ replayState,
2675
+ lastHandledMaterializationPackId,
2676
+ scanner,
2677
+ teacherLoop,
2678
+ localSessionTail
2679
+ };
2680
+ }
2681
+ export async function runWatchCommandPass(runtime, options = {}) {
2682
+ const log = options.log ?? watchLog;
2683
+ const observedAt = options.observedAt ?? new Date().toISOString();
2684
+ const localPoll = runtime.localSessionTail.pollOnce({
2685
+ observedAt
2686
+ });
2687
+ const exported = exportLocalSessionTailChangesToScanRoot({
2688
+ scanRoot: runtime.scanRoot,
2689
+ polledAt: localPoll.polledAt,
2690
+ changes: localPoll.changes
2691
+ });
2692
+ persistWatchSessionTailCursor(runtime.sessionTailCursorPath, localPoll.cursor);
2693
+ for (const warning of [...localPoll.warnings, ...exported.warnings]) {
2694
+ log(`Session tail warning: ${warning}`);
2695
+ }
2696
+ if (exported.exportedBundleCount > 0) {
2697
+ log(`Session tail exported ${exported.exportedBundleCount} bundle${exported.exportedBundleCount === 1 ? "" : "s"} from ${localPoll.changes.length} changed session${localPoll.changes.length === 1 ? "" : "s"}`);
2698
+ }
2699
+ const scanResult = runtime.scanner.scanOnce({
2700
+ scannedAt: observedAt
2701
+ });
2702
+ const totalSelected = scanResult.selected.length;
2703
+ const totalEvents = scanResult.selected.reduce((sum, hit) => sum + hit.eventRange.count, 0);
2704
+ let snapshot = runtime.teacherLoop.snapshot();
2705
+ let materializedPackId = null;
2706
+ if (totalSelected === 0) {
2707
+ log("Scanning... no changes");
2708
+ }
2709
+ else {
2710
+ log(`Scanning... ${totalSelected} export bundle${totalSelected === 1 ? "" : "s"} selected, ${totalEvents} event${totalEvents === 1 ? "" : "s"}`);
2711
+ const ingestResult = await runtime.teacherLoop.ingestRuntimeEventExportScannerScan(scanResult);
2712
+ snapshot = ingestResult.snapshot;
2713
+ const promotion = applyWatchMaterialization(runtime.activationRoot, snapshot, runtime.lastHandledMaterializationPackId);
2714
+ runtime.lastHandledMaterializationPackId = promotion.lastHandledMaterializationPackId;
2715
+ materializedPackId = promotion.materializedPackId;
2716
+ if (promotion.logLine !== null) {
2717
+ log(promotion.logLine);
2718
+ snapshot = runtime.teacherLoop.snapshot();
2719
+ }
2720
+ }
2721
+ persistWatchTeacherSnapshot(runtime.teacherSnapshotPath, {
2722
+ scanRoot: runtime.scanRoot,
2723
+ replayedBundleCount: runtime.replayState.replayedBundleCount,
2724
+ replayedEventCount: runtime.replayState.replayedEventCount,
2725
+ exportedBundleCount: exported.exportedBundleCount,
2726
+ exportedEventCount: exported.exportedEventCount,
2727
+ localSessionTailNoopReason: localPoll.noopReason,
2728
+ lastHandledMaterializationPackId: runtime.lastHandledMaterializationPackId,
2729
+ snapshot
2730
+ });
2731
+ if (options.json) {
2732
+ console.log(JSON.stringify({
2733
+ timestamp: observedAt,
2734
+ replayedBundles: runtime.replayState.replayedBundleCount,
2735
+ replayedEvents: runtime.replayState.replayedEventCount,
2736
+ exportedBundles: exported.exportedBundleCount,
2737
+ exportedEvents: exported.exportedEventCount,
2738
+ selected: totalSelected,
2739
+ events: totalEvents,
2740
+ live: scanResult.live.length,
2741
+ backfill: scanResult.backfill.length,
2742
+ materialized: materializedPackId,
2743
+ diagnostics: snapshot.diagnostics ?? null,
2744
+ localSessionTailNoopReason: localPoll.noopReason
2745
+ }));
2746
+ }
2747
+ return {
2748
+ localPoll,
2749
+ exported,
2750
+ scanResult,
2751
+ snapshot,
2752
+ materializedPackId
2753
+ };
2754
+ }
2755
+ async function runWatchCommand(parsed) {
2756
+ const intervalMs = parsed.interval * 1000;
2757
+ const runtime = await createWatchCommandRuntime({
2758
+ activationRoot: parsed.activationRoot,
2759
+ scanRoot: parsed.scanRoot,
2760
+ log: watchLog
2761
+ });
2762
+ watchLog(`Interval: ${parsed.interval}s`);
1457
2763
  let stopping = false;
1458
2764
  const onSignal = () => {
1459
2765
  if (stopping) {
@@ -1466,69 +2772,15 @@ async function runWatchCommand(parsed) {
1466
2772
  process.on("SIGTERM", onSignal);
1467
2773
  while (!stopping) {
1468
2774
  try {
1469
- const scanResult = scanner.scanOnce();
1470
- const liveCount = scanResult.live.length;
1471
- const backfillCount = scanResult.backfill.length;
1472
- const totalSelected = scanResult.selected.length;
1473
- if (totalSelected === 0) {
1474
- watchLog("Scanning... no changes");
1475
- }
1476
- else {
1477
- const totalEvents = scanResult.selected.reduce((sum, hit) => sum + hit.eventRange.count, 0);
1478
- watchLog(`Scanning... ${totalSelected} session${totalSelected === 1 ? "" : "s"} changed, ${totalEvents} new event${totalEvents === 1 ? "" : "s"}`);
1479
- // Feed exports into teacher/learner pipeline
1480
- const ingestResult = await teacherLoop.ingestRuntimeEventExportScannerScan(scanResult);
1481
- const snapshot = ingestResult.snapshot;
1482
- const materialization = snapshot.learner.lastMaterialization;
1483
- if (materialization !== null) {
1484
- const packId = materialization.candidate.summary.packId;
1485
- const shortPackId = packId.length > 16 ? packId.slice(0, 16) : packId;
1486
- watchLog(`Learning: materialized ${shortPackId}`);
1487
- // Attempt stage + promote
1488
- try {
1489
- const candidateRootDir = path.resolve(activationRoot, "packs", packId);
1490
- mkdirSync(candidateRootDir, { recursive: true });
1491
- materializeAlwaysOnLearningCandidatePack(candidateRootDir, materialization);
1492
- const now = new Date().toISOString();
1493
- stageCandidatePack(activationRoot, candidateRootDir, {
1494
- updatedAt: now,
1495
- reason: `watch_stage:${materialization.reason}:${materialization.lane}`
1496
- });
1497
- const inspection = inspectActivationState(activationRoot, now);
1498
- if (inspection.promotion.allowed) {
1499
- promoteCandidatePack(activationRoot, {
1500
- updatedAt: now,
1501
- reason: `watch_promote:${materialization.reason}:${materialization.lane}`
1502
- });
1503
- watchLog(`Promoted ${shortPackId} → active`);
1504
- }
1505
- else {
1506
- watchLog(`Staged ${shortPackId} (promotion blocked: ${inspection.promotion.findings.join(", ")})`);
1507
- }
1508
- }
1509
- catch (error) {
1510
- const message = error instanceof Error ? error.message : String(error);
1511
- watchLog(`Promotion failed: ${message}`);
1512
- }
1513
- }
1514
- if (parsed.json) {
1515
- console.log(JSON.stringify({
1516
- timestamp: new Date().toISOString(),
1517
- selected: totalSelected,
1518
- events: totalEvents,
1519
- live: liveCount,
1520
- backfill: backfillCount,
1521
- materialized: materialization?.candidate.summary.packId ?? null,
1522
- diagnostics: snapshot.diagnostics
1523
- }));
1524
- }
1525
- }
2775
+ await runWatchCommandPass(runtime, {
2776
+ json: parsed.json,
2777
+ log: watchLog
2778
+ });
1526
2779
  }
1527
2780
  catch (error) {
1528
2781
  const message = error instanceof Error ? error.message : String(error);
1529
2782
  watchLog(`Error: ${message}`);
1530
2783
  }
1531
- // Wait for the next interval, checking for stop signal periodically
1532
2784
  const deadline = Date.now() + intervalMs;
1533
2785
  while (!stopping && Date.now() < deadline) {
1534
2786
  await new Promise((resolve) => {
@@ -1721,9 +2973,15 @@ export function runOperatorCli(argv = process.argv.slice(2)) {
1721
2973
  });
1722
2974
  return 0;
1723
2975
  }
1724
- if (parsed.command === "setup") {
2976
+ if (parsed.command === "install" || parsed.command === "setup") {
1725
2977
  return runSetupCommand(parsed);
1726
2978
  }
2979
+ if (parsed.command === "detach") {
2980
+ return runDetachCommand(parsed);
2981
+ }
2982
+ if (parsed.command === "uninstall") {
2983
+ return runUninstallCommand(parsed);
2984
+ }
1727
2985
  if (parsed.command === "attach") {
1728
2986
  mkdirSync(parsed.activationRoot, { recursive: true });
1729
2987
  mkdirSync(parsed.packRoot, { recursive: true });
@@ -1801,18 +3059,17 @@ export function runOperatorCli(argv = process.argv.slice(2)) {
1801
3059
  }
1802
3060
  return result.allowed ? 0 : 1;
1803
3061
  }
1804
- const status = describeCurrentProfileBrainStatus({
3062
+ const operatorInput = {
1805
3063
  ...statusOrRollback.input,
1806
- activationRoot
1807
- });
3064
+ activationRoot,
3065
+ teacherSnapshotPath: resolveOperatorTeacherSnapshotPath(activationRoot, statusOrRollback.input.teacherSnapshotPath)
3066
+ };
3067
+ const status = describeCurrentProfileBrainStatus(operatorInput);
1808
3068
  if (statusOrRollback.json) {
1809
3069
  console.log(JSON.stringify(status, null, 2));
1810
3070
  }
1811
3071
  else {
1812
- const report = buildOperatorSurfaceReport({
1813
- ...statusOrRollback.input,
1814
- activationRoot
1815
- });
3072
+ const report = buildOperatorSurfaceReport(operatorInput);
1816
3073
  if (statusOrRollback.detailed) {
1817
3074
  console.log(formatCurrentProfileStatusSummary(status, report));
1818
3075
  }