@openclawbrain/openclaw 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +10 -0
  3. package/dist/extension/index.d.ts +1 -0
  4. package/dist/extension/index.js +73 -0
  5. package/dist/extension/index.js.map +1 -0
  6. package/dist/extension/runtime-guard.d.ts +61 -0
  7. package/dist/extension/runtime-guard.js +230 -0
  8. package/dist/extension/runtime-guard.js.map +1 -0
  9. package/dist/src/cli.d.ts +66 -4
  10. package/dist/src/cli.js +1845 -241
  11. package/dist/src/cli.js.map +1 -1
  12. package/dist/src/daemon.d.ts +7 -4
  13. package/dist/src/daemon.js +311 -28
  14. package/dist/src/daemon.js.map +1 -1
  15. package/dist/src/index.d.ts +213 -4
  16. package/dist/src/index.js +1151 -157
  17. package/dist/src/index.js.map +1 -1
  18. package/dist/src/learning-spine.d.ts +2 -1
  19. package/dist/src/learning-spine.js +8 -0
  20. package/dist/src/learning-spine.js.map +1 -1
  21. package/dist/src/local-session-passive-learning.d.ts +1 -0
  22. package/dist/src/local-session-passive-learning.js +97 -7
  23. package/dist/src/local-session-passive-learning.js.map +1 -1
  24. package/dist/src/ollama-client.d.ts +46 -0
  25. package/dist/src/ollama-client.js +231 -0
  26. package/dist/src/ollama-client.js.map +1 -0
  27. package/dist/src/provider-config.d.ts +28 -0
  28. package/dist/src/provider-config.js +150 -0
  29. package/dist/src/provider-config.js.map +1 -0
  30. package/dist/src/resolve-activation-root.d.ts +3 -3
  31. package/dist/src/resolve-activation-root.js +105 -35
  32. package/dist/src/resolve-activation-root.js.map +1 -1
  33. package/dist/src/session-store.d.ts +18 -0
  34. package/dist/src/session-store.js +40 -0
  35. package/dist/src/session-store.js.map +1 -1
  36. package/dist/src/session-tail.d.ts +6 -3
  37. package/dist/src/session-tail.js +35 -4
  38. package/dist/src/session-tail.js.map +1 -1
  39. package/dist/src/shadow-extension-proof.d.ts +40 -0
  40. package/dist/src/shadow-extension-proof.js +214 -0
  41. package/dist/src/shadow-extension-proof.js.map +1 -0
  42. package/dist/src/teacher-labeler.d.ts +50 -0
  43. package/dist/src/teacher-labeler.js +424 -0
  44. package/dist/src/teacher-labeler.js.map +1 -0
  45. package/extension/index.ts +74 -35
  46. package/extension/runtime-guard.ts +353 -0
  47. package/package.json +13 -13
package/dist/src/cli.js CHANGED
@@ -1,21 +1,170 @@
1
1
  #!/usr/bin/env node
2
- import { execSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, readdirSync, readSync, openSync, closeSync, realpathSync, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs";
2
+ import { execFileSync, execSync } from "node:child_process";
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, loadPackFromActivation, 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, loadWatchTeacherSnapshotState, loadRuntimeEventExportBundle, persistWatchTeacherSnapshot, rollbackRuntimeAttach, resolveOperatorTeacherSnapshotPath, resolveAsyncTeacherLiveLoopSnapshotPath, resolveWatchSessionTailCursorPath, resolveWatchStateRoot, resolveWatchTeacherSnapshotPath, scanLiveEventExport, scanRecordedSession, summarizeLearningPathFromMaterialization, summarizeNormalizedEventExportLabelFlow, writeScannedEventExportBundle } from "./index.js";
15
+ import { appendLearningUpdateLogs } from "./learning-spine.js";
14
16
  import { buildPassiveLearningSessionExportFromOpenClawSessionStore } from "./local-session-passive-learning.js";
15
- import { discoverOpenClawMainSessionStores, loadOpenClawSessionIndex, readOpenClawSessionFile } from "./session-store.js";
17
+ import { discoverOpenClawSessionStores, loadOpenClawSessionIndex, readOpenClawSessionFile } from "./session-store.js";
18
+ import { readOpenClawBrainProviderConfig } from "./provider-config.js";
16
19
  function quoteShellArg(value) {
17
20
  return `'${value.replace(/'/g, `"'"'`)}'`;
18
21
  }
22
+ function normalizeOptionalCliString(value) {
23
+ if (typeof value !== "string") {
24
+ return null;
25
+ }
26
+ const trimmed = value.trim();
27
+ return trimmed.length > 0 ? trimmed : null;
28
+ }
29
+ function getCliHomeDir() {
30
+ return process.env.HOME ?? process.env.USERPROFILE ?? "~";
31
+ }
32
+ function discoverInstallCandidateOpenClawHomes(homeDir = getCliHomeDir()) {
33
+ const resolvedHomeDir = path.resolve(homeDir);
34
+ let entries;
35
+ try {
36
+ entries = readdirSync(resolvedHomeDir, { withFileTypes: true });
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ return entries
42
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(".openclaw-"))
43
+ .map((entry) => path.join(resolvedHomeDir, entry.name))
44
+ .filter((candidate) => existsSync(path.join(candidate, "openclaw.json")))
45
+ .sort((left, right) => left.localeCompare(right));
46
+ }
47
+ function formatInstallOpenClawHomeSource(source) {
48
+ switch (source) {
49
+ case "explicit":
50
+ return "--openclaw-home";
51
+ case "env":
52
+ return "OPENCLAW_HOME";
53
+ case "discovered_single_profile":
54
+ return "single discovered live profile";
55
+ default:
56
+ return source;
57
+ }
58
+ }
59
+ function resolveInstallOpenClawHome(explicitOpenclawHome) {
60
+ const normalizedExplicitHome = normalizeOptionalCliString(explicitOpenclawHome);
61
+ if (normalizedExplicitHome !== null) {
62
+ return {
63
+ openclawHome: path.resolve(normalizedExplicitHome),
64
+ openclawHomeSource: "explicit"
65
+ };
66
+ }
67
+ const envOpenClawHome = normalizeOptionalCliString(process.env.OPENCLAW_HOME);
68
+ if (envOpenClawHome !== null) {
69
+ return {
70
+ openclawHome: path.resolve(envOpenClawHome),
71
+ openclawHomeSource: "env"
72
+ };
73
+ }
74
+ const discoveredHomes = discoverInstallCandidateOpenClawHomes();
75
+ if (discoveredHomes.length === 1) {
76
+ return {
77
+ openclawHome: path.resolve(discoveredHomes[0]),
78
+ openclawHomeSource: "discovered_single_profile"
79
+ };
80
+ }
81
+ if (discoveredHomes.length > 1) {
82
+ const installPrefix = detectConsumerSafeOperatorCliPrefix();
83
+ const targetChoices = discoveredHomes
84
+ .map((candidate) => {
85
+ const resolvedCandidate = path.resolve(candidate);
86
+ return ` - ${resolvedCandidate}\n ${installPrefix} install --openclaw-home ${quoteShellArg(resolvedCandidate)}`;
87
+ })
88
+ .join("\n");
89
+ throw new Error([
90
+ "Refusing ambiguous live OpenClaw targets for install.",
91
+ targetChoices,
92
+ "Pass --openclaw-home <path> or set OPENCLAW_HOME to pin one profile."
93
+ ].join("\n"));
94
+ }
95
+ throw new Error("No OpenClaw profile home found. Pass --openclaw-home <path> or set OPENCLAW_HOME.");
96
+ }
97
+ function resolveInstallActivationRoot(openclawHome, explicitActivationRoot) {
98
+ const normalizedExplicitActivationRoot = normalizeOptionalCliString(explicitActivationRoot);
99
+ if (normalizedExplicitActivationRoot !== null) {
100
+ return {
101
+ activationRoot: path.resolve(normalizedExplicitActivationRoot),
102
+ source: "explicit"
103
+ };
104
+ }
105
+ return {
106
+ activationRoot: path.resolve(path.dirname(openclawHome), ".openclawbrain", "activation"),
107
+ source: "default_from_openclaw_home"
108
+ };
109
+ }
110
+ function resolveInstallWorkspaceId(openclawHome, explicitWorkspaceId) {
111
+ const normalizedExplicitWorkspaceId = normalizeOptionalCliString(explicitWorkspaceId);
112
+ if (normalizedExplicitWorkspaceId !== null) {
113
+ return {
114
+ workspaceId: normalizedExplicitWorkspaceId,
115
+ source: "explicit"
116
+ };
117
+ }
118
+ try {
119
+ const openclawConfigPath = path.join(openclawHome, "openclaw.json");
120
+ const openclawConfig = JSON.parse(readFileSync(openclawConfigPath, "utf8"));
121
+ if (typeof openclawConfig.profile === "string" && openclawConfig.profile.trim().length > 0) {
122
+ return {
123
+ workspaceId: openclawConfig.profile.trim(),
124
+ source: "openclaw_json_profile"
125
+ };
126
+ }
127
+ }
128
+ catch {
129
+ // Fall back to the profile-home name when install is pointed at an incomplete or not-yet-readable profile.
130
+ }
131
+ const dirName = path.basename(openclawHome);
132
+ if (dirName === ".openclaw") {
133
+ return {
134
+ workspaceId: "default",
135
+ source: "openclaw_home_dir"
136
+ };
137
+ }
138
+ const derivedWorkspaceId = dirName.startsWith(".openclaw-") ? dirName.slice(".openclaw-".length) : dirName;
139
+ if (derivedWorkspaceId.trim().length > 0) {
140
+ return {
141
+ workspaceId: derivedWorkspaceId,
142
+ source: "openclaw_home_dir"
143
+ };
144
+ }
145
+ return {
146
+ workspaceId: "workspace",
147
+ source: "fallback"
148
+ };
149
+ }
150
+ function formatInstallActivationRootSource(source) {
151
+ if (source === "explicit") {
152
+ return "explicit --activation-root";
153
+ }
154
+ return "default beside --openclaw-home";
155
+ }
156
+ function formatInstallWorkspaceIdSource(source) {
157
+ switch (source) {
158
+ case "explicit":
159
+ return "explicit --workspace-id";
160
+ case "openclaw_json_profile":
161
+ return "from openclaw.json profile";
162
+ case "openclaw_home_dir":
163
+ return "from OpenClaw home dir";
164
+ default:
165
+ return "fallback default";
166
+ }
167
+ }
19
168
  function detectConsumerSafeOperatorCliPrefix() {
20
169
  const npmExecPath = (process.env.npm_execpath ?? "").toLowerCase();
21
170
  const userAgent = process.env.npm_config_user_agent ?? "";
@@ -115,7 +264,7 @@ function buildDoctorDeletedMessage(args) {
115
264
  const jsonCommand = buildStatusReplacementCommand(replacementInput, true);
116
265
  const lines = [
117
266
  "`doctor` is no longer a separate operator surface.",
118
- 'Use `openclawbrain status` as the human answer to "How\'s the brain?" and `status --json` for the canonical current-profile object.',
267
+ 'Use `openclawbrain status --activation-root <path>` as the human answer to "How\'s the brain?" and `status --json` for the canonical current-profile object.',
119
268
  "Use `describeAttachStatus()` or the proof helpers only when you need deeper activation diagnostics."
120
269
  ];
121
270
  if (json && jsonCommand !== null) {
@@ -129,30 +278,42 @@ function buildDoctorDeletedMessage(args) {
129
278
  }
130
279
  return lines.join(" ");
131
280
  }
281
+ function buildSetupDeletedMessage() {
282
+ return [
283
+ "`setup` has been removed.",
284
+ "Use `openclawbrain install` instead.",
285
+ "The install command still accepts the explicit targeting flags that setup used: `--openclaw-home`, `--activation-root`, `--workspace-id`, and `--shared`."
286
+ ].join(" ");
287
+ }
132
288
  function operatorCliHelp() {
133
289
  return [
134
290
  "Usage:",
135
- " openclawbrain setup --openclaw-home <path> [options]",
291
+ " openclawbrain install [--openclaw-home <path>] [options]",
292
+ " openclawbrain detach --openclaw-home <path> [options]",
293
+ " openclawbrain uninstall --openclaw-home <path> [--keep-data|--purge-data] [options]",
136
294
  " openclawbrain attach --activation-root <path> [options]",
137
- " openclawbrain <status|rollback> --activation-root <path> [options]",
138
- " openclawbrain context \"message\" [--activation-root <path>]",
139
- " openclawbrain history [--activation-root <path>] [--limit N] [--json]",
295
+ " openclawbrain <status|rollback> [--activation-root <path>|--openclaw-home <path>] [options]",
296
+ " openclawbrain context \"message\" [--activation-root <path>|--openclaw-home <path>]",
297
+ " openclawbrain history [--activation-root <path>|--openclaw-home <path>] [--limit N] [--json]",
140
298
  " openclawbrain scan --session <trace.json> --root <path> [options]",
141
299
  " openclawbrain scan --live <event-export-path> --workspace <workspace.json> [options]",
142
- " openclawbrain learn [--activation-root <path>] [--json]",
143
- " openclawbrain watch [--activation-root <path>] [--scan-root <path>] [--interval <seconds>]",
144
- " openclawbrain daemon <start|stop|status|logs> [--activation-root <path>]",
145
- " openclawbrain-ops <status|rollback> --activation-root <path> [options] # compatibility alias",
300
+ " openclawbrain learn [--activation-root <path>|--openclaw-home <path>] [--json]",
301
+ " openclawbrain watch --activation-root <path> [--scan-root <path>] [--interval <seconds>]",
302
+ " openclawbrain daemon <start|stop|status|logs> --activation-root <path> [--json]",
303
+ " openclawbrain-ops <status|rollback> [--activation-root <path>|--openclaw-home <path>] [options] # compatibility alias",
146
304
  " openclawbrain-ops scan --session <trace.json> --root <path> [options] # compatibility alias",
147
305
  "",
148
306
  "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.",
307
+ " --openclaw-home <path> OpenClaw profile home dir for install/detach/uninstall (e.g. ~/.openclaw-Tern). Also pins status/rollback/context/history/learn to that installed profile when applicable.",
308
+ " --shared Set brain-attachment-policy to shared instead of dedicated (install only).",
309
+ " --activation-root <path> Explicit activation root for attach/watch/daemon and other stateful commands; install defaults to sibling .openclawbrain/activation next to the selected OpenClaw home.",
310
+ " --keep-data Preserve activation data on uninstall; detach always behaves this way.",
311
+ " --purge-data Remove activation data on uninstall; requires the installed profile hook or --activation-root.",
312
+ " --restart <never|safe|external> Restart guidance mode for detach/uninstall. 'safe' is conservative; 'never' leaves restart entirely to the operator.",
152
313
  " --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').",
314
+ " --workspace-id <id> Workspace identifier for attach/install provenance; install defaults to openclaw.json.profile or the profile name, attach defaults to 'workspace'.",
154
315
  " --event-export <path> Event-export bundle root or normalized export JSON payload.",
155
- " --teacher-snapshot <path> Async teacher snapshot JSON from teacherLoop.snapshot()/flush(); keeps live-first, principal-priority, and passive-backfill learner truth explicit.",
316
+ " --teacher-snapshot <path> Canonical watch teacher snapshot JSON or raw async teacher snapshot JSON; keeps live-first, principal-priority, and passive-backfill learner truth explicit.",
156
317
  " --updated-at <iso> Observation time to use for freshness checks.",
157
318
  " --brain-attachment-policy <undeclared|dedicated|shared> Override attachment policy semantics for status inspection.",
158
319
  " --detailed Show verbose diagnostic output for status (default is human-friendly summary).",
@@ -171,19 +332,26 @@ function operatorCliHelp() {
171
332
  " --help Show this help.",
172
333
  "",
173
334
  "Common flow:",
335
+ " 0. install openclawbrain install — attach the brain with sane defaults; pass --openclaw-home for explicit targeting on many-profile hosts",
336
+ " 0. detach openclawbrain detach --openclaw-home <path> — remove the profile hook only; activation data stays in place",
337
+ " 0. uninstall openclawbrain uninstall --openclaw-home <path> --keep-data|--purge-data — remove the profile hook and choose the data outcome explicitly",
174
338
  " 0. context openclawbrain context \"hello\" — preview the brain context that would be injected for a message",
175
339
  " 0. attach openclawbrain attach --activation-root <path>",
176
- " 1. status answer \"How's the brain?\" for the current profile on that activation root",
177
- " 2. status --json read the canonical current_profile_brain_status.v1 object for that same boundary",
178
- " 3. rollback --dry-run preview active <- previous, active -> candidate",
179
- " 4. rollback apply the rollback when the preview says ready",
340
+ " 1. status openclawbrain status --activation-root <path> — answer \"How's the brain?\" for that boundary",
341
+ " 2. status --json openclawbrain status --activation-root <path> --json — read the canonical current_profile_brain_status.v1 object",
342
+ " 3. rollback --dry-run openclawbrain rollback --activation-root <path> --dry-run — preview active <- previous, active -> candidate",
343
+ " 4. rollback openclawbrain rollback --activation-root <path> — apply the rollback when the preview says ready",
180
344
  " 5. scan --session replay one sanitized session trace across no_brain, seed_pack, and learned_replay",
181
345
  " 6. scan --live scan one live event export into teacher/learner state without claiming a daemon is running",
182
346
  " status --teacher-snapshot keeps the current live-first / principal-priority / passive-backfill learner order visible when that snapshot exists",
347
+ " watch/daemon persist their operator snapshot at <activation-root>/watch/teacher-snapshot.json; --teacher-snapshot overrides the default path",
348
+ " watch Ollama teacher labels: set OPENCLAWBRAIN_TEACHER_PROVIDER=ollama plus optional OPENCLAWBRAIN_TEACHER_BASE_URL, OPENCLAWBRAIN_TEACHER_MODEL, and OPENCLAWBRAIN_TEACHER_* budget vars",
183
349
  "",
184
350
  "Exit codes:",
185
351
  " status: 0 on successful inspection, 1 on input/read failure.",
186
352
  " rollback: 0 when ready/applied, 1 when blocked or on input/read failure.",
353
+ " detach: 0 on successful unhook, 1 on input/read failure.",
354
+ " uninstall: 0 on successful unhook/cleanup, 1 on input/read failure.",
187
355
  " scan: 0 on successful replay/scan, 1 on input/read failure."
188
356
  ].join("\n");
189
357
  }
@@ -225,6 +393,12 @@ function formatLearningBuckets(report) {
225
393
  function formatLearningWarnings(report) {
226
394
  return report.learning.warningStates.length === 0 ? "none" : report.learning.warningStates.join("|");
227
395
  }
396
+ function formatLabelFlowSummary(labelFlow) {
397
+ return `source=${labelFlow.source} human=${labelFlow.humanLabelCount ?? "none"} self=${labelFlow.selfLabelCount ?? "none"} implicitPositive=${labelFlow.implicitPositiveCount ?? "none"} teacherArtifacts=${labelFlow.asyncTeacherArtifactCount ?? "none"}`;
398
+ }
399
+ function formatLearningPathSummary(learningPath) {
400
+ return `source=${learningPath.source} pg=${learningPath.policyGradientVersion} method=${learningPath.policyGradientMethod ?? "none"} target=${learningPath.targetConstruction ?? "none"} connect=${learningPath.connectOpsFired ?? "none"} trajectories=${learningPath.reconstructedTrajectoryCount ?? "none"}`;
401
+ }
228
402
  function formatCurrentProfileStatusSummary(status, report) {
229
403
  const profileIdSuffix = status.profile.profileId === null ? "" : ` id=${status.profile.profileId}`;
230
404
  return [
@@ -233,6 +407,7 @@ function formatCurrentProfileStatusSummary(status, report) {
233
407
  `host runtime=${status.host.runtimeOwner} activation=${status.host.activationRoot}`,
234
408
  `profile selector=${status.profile.selector}${profileIdSuffix} attachment=${status.attachment.state} policy=${status.attachment.policyMode}`,
235
409
  `manyProfile surface=${report.manyProfile.operatorSurface} policy=${report.manyProfile.declaredAttachmentPolicy} intent=${report.manyProfile.sameGatewayIntent} checkedProof=${report.manyProfile.checkedInProofTopology} sameGatewayProof=${yesNo(report.manyProfile.sameGatewayProof)} sharedWriteProof=${yesNo(report.manyProfile.sharedWriteSafetyProof)}`,
410
+ `activation state=${status.brainStatus.activationState} detail=${status.brain.detail}`,
236
411
  `brain pack=${status.brain.activePackId ?? "none"} state=${status.brain.state} init=${status.brain.initMode ?? "unknown"} routeFreshness=${status.brain.routeFreshness} lastPromotion=${status.brain.lastPromotionAt ?? "none"} router=${status.brain.routerIdentity ?? "none"}`,
237
412
  `serve state=${status.brainStatus.serveState} failOpen=${yesNo(status.brainStatus.failOpen)} hardFail=${yesNo(report.servePath.hardRequirementViolated)} usedRouteFn=${yesNo(status.brainStatus.usedLearnedRouteFn)} awaitingFirstExport=${yesNo(status.brainStatus.awaitingFirstExport)} detail=${status.brainStatus.detail}`,
238
413
  `route router=${report.servePath.routerIdentity ?? status.brain.routerIdentity ?? "none"} supervision=${report.servePath.refreshStatus ?? status.brain.routeFreshness} freshness=${report.servePath.freshnessChecksum ?? "none"}`,
@@ -240,8 +415,12 @@ function formatCurrentProfileStatusSummary(status, report) {
240
415
  `decision ${status.brainStatus.structuralDecision.detail}`,
241
416
  `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
417
  `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"}`,
418
+ `labels ${formatLabelFlowSummary(report.labelFlow)}`,
243
419
  `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}`,
420
+ `path ${formatLearningPathSummary(report.learningPath)}`,
244
421
  `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}`,
422
+ `teacher snapshot=${report.teacherLoop.sourcePath ?? "none"} kind=${report.teacherLoop.sourceKind} lastRun=${report.teacherLoop.lastRunAt ?? "none"} artifacts=${report.teacherLoop.artifactCount ?? "none"} freshness=${report.teacherLoop.latestFreshness} queue=${report.teacherLoop.queueDepth ?? "none"}/${report.teacherLoop.queueCapacity ?? "none"} running=${yesNo(report.teacherLoop.running)} noOp=${report.teacherLoop.lastNoOpReason} failure=${report.teacherLoop.failureMode}${report.teacherLoop.failureDetail === null ? "" : `(${report.teacherLoop.failureDetail})`}`,
423
+ `passive cadence=${report.teacherLoop.learningCadence} scan=${report.teacherLoop.scanPolicy} slices=${report.teacherLoop.liveSlicesPerCycle ?? "none"}/${report.teacherLoop.backfillSlicesPerCycle ?? "none"} replayed=${report.teacherLoop.replayedBundleCount ?? "none"}/${report.teacherLoop.replayedEventCount ?? "none"} exported=${report.teacherLoop.exportedBundleCount ?? "none"}/${report.teacherLoop.exportedEventCount ?? "none"} tail=${report.teacherLoop.sessionTailSessionsTracked ?? "none"}/${report.teacherLoop.sessionTailBridgedEventCount ?? "none"} tailState=${report.teacherLoop.localSessionTailNoopReason ?? "none"} lastJob=${report.teacherLoop.lastAppliedMaterializationJobId ?? "none"} lastPack=${report.teacherLoop.lastMaterializedPackId ?? "none"}`,
245
424
  `rollback ready=${yesNo(report.rollback.allowed)} state=${report.rollback.state} previous=${report.rollback.previousPackId ?? "none"}`,
246
425
  `proof lastExport=${status.brain.lastExportAt ?? "none"} lastLearningUpdate=${status.brain.lastLearningUpdateAt ?? "none"} lastPromotion=${status.brain.lastPromotionAt ?? "none"}`,
247
426
  `logs root=${status.brain.logRoot ?? "none"}`,
@@ -258,6 +437,47 @@ function shortenPath(fullPath) {
258
437
  }
259
438
  return fullPath;
260
439
  }
440
+ function buildInstallStatusCommand(activationRoot) {
441
+ return `openclawbrain status --activation-root ${quoteShellArg(activationRoot)}`;
442
+ }
443
+ function buildInstallCommand(openclawHome) {
444
+ return `openclawbrain install --openclaw-home ${quoteShellArg(openclawHome)}`;
445
+ }
446
+ function buildInstallReloadGuidance() {
447
+ 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.";
448
+ }
449
+ function buildCleanupRestartGuidance(restart) {
450
+ if (restart === "never") {
451
+ return "No restart requested. If this OpenClaw profile is currently running, it may keep the previous hook state until the next restart.";
452
+ }
453
+ if (restart === "external") {
454
+ 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.";
455
+ }
456
+ 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.";
457
+ }
458
+ function buildStatusNextStep(status, report) {
459
+ const activationRootArg = quoteShellArg(status.host.activationRoot);
460
+ if (status.brainStatus.activationState === "broken_install") {
461
+ return "Repair or replace the activation root before trusting serve-path status again.";
462
+ }
463
+ if (status.brainStatus.activationState === "stale_incomplete") {
464
+ return "Clean up or repair the retained activation state before reattaching or promoting packs.";
465
+ }
466
+ if (status.brainStatus.status === "fail") {
467
+ return `Run \`openclawbrain status --activation-root ${activationRootArg} --detailed\` before changing lifecycle state so the serve-path failure is explicit.`;
468
+ }
469
+ if (status.brainStatus.awaitingFirstExport) {
470
+ return `Let the attached OpenClaw profile emit a real export, then rerun \`openclawbrain status --activation-root ${activationRootArg}\`.`;
471
+ }
472
+ if (report.learning.warningStates.includes("principal_live_backlog") ||
473
+ report.learning.warningStates.includes("active_pack_behind_latest_principal")) {
474
+ return "A newer principal correction is still pending promotion; keep the current pack conservative until learner promotion lands.";
475
+ }
476
+ if (report.rollback.allowed) {
477
+ return `Use \`openclawbrain rollback --activation-root ${activationRootArg} --dry-run\` before restoring the previous pack.`;
478
+ }
479
+ return `Use \`openclawbrain status --activation-root ${activationRootArg} --detailed\` when you need the full lifecycle, serve-path, and backlog proof.`;
480
+ }
261
481
  function formatHumanFriendlyStatus(status, report) {
262
482
  // Brain status line
263
483
  const brainActive = status.brainStatus.status === "ok" || status.brainStatus.serveState === "serving_active_pack";
@@ -270,11 +490,22 @@ function formatHumanFriendlyStatus(status, report) {
270
490
  const activationPath = shortenPath(status.host.activationRoot);
271
491
  // Policy
272
492
  const policy = status.attachment.policyMode ?? report.manyProfile.declaredAttachmentPolicy ?? "undeclared";
493
+ const principalFrontier = formatPrincipalCheckpointFrontier(report);
494
+ const pendingLive = String(report.learning.pendingLive ?? "none");
495
+ const pendingBackfill = String(report.learning.pendingBackfill ?? "none");
496
+ const nextLane = report.learning.nextPriorityLane ?? "none";
497
+ const nextBucket = report.learning.nextPriorityBucket ?? "none";
273
498
  const lines = [
274
499
  `Brain: ${brainIcon}`,
275
500
  `Pack: ${packShort} (${state})`,
276
501
  `Activation: ${activationPath}`,
277
- `Policy: ${policy}`
502
+ `Policy: ${policy}`,
503
+ `Lifecycle: activation=${status.brainStatus.activationState} attachment=${status.attachment.state} serve=${status.brainStatus.serveState} awaitingFirstExport=${yesNo(status.brainStatus.awaitingFirstExport)}`,
504
+ `Rollback: state=${report.rollback.state} ready=${yesNo(report.rollback.allowed)} previous=${report.rollback.previousPackId ?? "none"}`,
505
+ `Backlog: principal=${principalFrontier} live=${pendingLive} backfill=${pendingBackfill} next=${nextLane}/${nextBucket}`,
506
+ `Labels: ${formatLabelFlowSummary(report.labelFlow)}`,
507
+ `Teacher: lastRun=${report.teacherLoop.lastRunAt ?? "none"} artifacts=${report.teacherLoop.artifactCount ?? "none"} exported=${report.teacherLoop.exportedBundleCount ?? "none"}/${report.teacherLoop.exportedEventCount ?? "none"} cadence=${report.teacherLoop.learningCadence}/${report.teacherLoop.scanPolicy} failure=${report.teacherLoop.failureMode}`,
508
+ `Learning: ${formatLearningPathSummary(report.learningPath)}`
278
509
  ];
279
510
  // Add learning/serve warnings if relevant
280
511
  if (report.learning.warningStates.length > 0) {
@@ -283,13 +514,25 @@ function formatHumanFriendlyStatus(status, report) {
283
514
  if (status.brainStatus.awaitingFirstExport) {
284
515
  lines.push(`Note: Awaiting first event export`);
285
516
  }
517
+ lines.push(`Next: ${buildStatusNextStep(status, report)}`);
286
518
  return lines.join("\n");
287
519
  }
288
- function requireActivationRoot(input, _command) {
289
- // Use the shared auto-detect chain for ALL commands:
290
- // explicit flag ~/.openclawbrain/activation → extension scan → clear error
520
+ function requireActivationRoot(input, openclawHome, command) {
521
+ const explicitActivationRoot = input.activationRoot.trim().length > 0 ? input.activationRoot : null;
522
+ if (explicitActivationRoot !== null) {
523
+ return path.resolve(explicitActivationRoot);
524
+ }
525
+ if (openclawHome !== null) {
526
+ return resolveActivationRoot({
527
+ openclawHome
528
+ });
529
+ }
530
+ throw new Error(`${command} requires --activation-root <path> or --openclaw-home <path>; unpinned host auto-resolution is no longer supported for ${command}.`);
531
+ }
532
+ function resolveCliActivationRoot(explicitActivationRoot, openclawHome) {
291
533
  return resolveActivationRoot({
292
- explicit: input.activationRoot.trim().length > 0 ? input.activationRoot : null,
534
+ explicit: explicitActivationRoot,
535
+ openclawHome
293
536
  });
294
537
  }
295
538
  function readJsonFile(filePath) {
@@ -321,6 +564,8 @@ function formatScanLiveSummary(result, snapshotOutPath) {
321
564
  "SCAN live ok",
322
565
  `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
566
  `teacher artifacts=${result.snapshot.teacher.artifactCount} freshness=${result.snapshot.teacher.latestFreshness} humanLabels=${result.supervision.humanLabelCount} noop=${result.snapshot.diagnostics.lastNoOpReason}`,
567
+ `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"}`,
568
+ `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
569
  `learner packLabel=${result.packLabel} materialized=${materializedPackId} reason=${materializationReason}`,
325
570
  `observed ${result.observedAt}`,
326
571
  `snapshot ${snapshotOutPath ?? "none"}`
@@ -344,6 +589,10 @@ export function parseOperatorCliArgs(argv) {
344
589
  let snapshotOutPath = null;
345
590
  let openclawHome = null;
346
591
  let shared = false;
592
+ let keepData = false;
593
+ let purgeData = false;
594
+ let restart = "safe";
595
+ let restartExplicitlySet = false;
347
596
  let json = false;
348
597
  let help = false;
349
598
  let dryRun = false;
@@ -352,11 +601,14 @@ export function parseOperatorCliArgs(argv) {
352
601
  if (args[0] === "doctor") {
353
602
  throw new Error(buildDoctorDeletedMessage(args.slice(1)));
354
603
  }
604
+ if (args[0] === "setup") {
605
+ throw new Error(buildSetupDeletedMessage());
606
+ }
355
607
  if (args[0] === "daemon") {
356
608
  args.shift();
357
609
  return parseDaemonArgs(args);
358
610
  }
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") {
611
+ if (args[0] === "status" || args[0] === "rollback" || args[0] === "scan" || args[0] === "attach" || args[0] === "install" || 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
612
  command = args.shift();
361
613
  }
362
614
  if (command === "learn") {
@@ -379,6 +631,15 @@ export function parseOperatorCliArgs(argv) {
379
631
  index += 1;
380
632
  continue;
381
633
  }
634
+ if (arg === "--openclaw-home") {
635
+ const next = args[index + 1];
636
+ if (next === undefined) {
637
+ throw new Error("--openclaw-home requires a value");
638
+ }
639
+ openclawHome = next;
640
+ index += 1;
641
+ continue;
642
+ }
382
643
  if (arg.startsWith("--")) {
383
644
  throw new Error(`unknown argument for learn: ${arg}`);
384
645
  }
@@ -388,7 +649,7 @@ export function parseOperatorCliArgs(argv) {
388
649
  }
389
650
  return {
390
651
  command,
391
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
652
+ activationRoot: resolveCliActivationRoot(activationRoot, openclawHome),
392
653
  json,
393
654
  help
394
655
  };
@@ -444,9 +705,12 @@ export function parseOperatorCliArgs(argv) {
444
705
  if (help) {
445
706
  return { command, activationRoot: "", scanRoot: null, interval: 30, json, help };
446
707
  }
708
+ if (activationRoot === null || activationRoot.trim().length === 0) {
709
+ throw new Error("watch requires --activation-root <path>");
710
+ }
447
711
  return {
448
712
  command,
449
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
713
+ activationRoot: path.resolve(activationRoot),
450
714
  scanRoot: watchScanRoot,
451
715
  interval: watchInterval,
452
716
  json,
@@ -474,6 +738,15 @@ export function parseOperatorCliArgs(argv) {
474
738
  index += 1;
475
739
  continue;
476
740
  }
741
+ if (arg === "--openclaw-home") {
742
+ const next = args[index + 1];
743
+ if (next === undefined) {
744
+ throw new Error("--openclaw-home requires a value");
745
+ }
746
+ openclawHome = next;
747
+ index += 1;
748
+ continue;
749
+ }
477
750
  if (arg.startsWith("--")) {
478
751
  throw new Error(`unknown argument for context: ${arg}`);
479
752
  }
@@ -488,7 +761,7 @@ export function parseOperatorCliArgs(argv) {
488
761
  return {
489
762
  command,
490
763
  message: messageParts.join(" "),
491
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
764
+ activationRoot: resolveCliActivationRoot(activationRoot, openclawHome),
492
765
  json,
493
766
  help
494
767
  };
@@ -514,6 +787,15 @@ export function parseOperatorCliArgs(argv) {
514
787
  index += 1;
515
788
  continue;
516
789
  }
790
+ if (arg === "--openclaw-home") {
791
+ const next = args[index + 1];
792
+ if (next === undefined) {
793
+ throw new Error("--openclaw-home requires a value");
794
+ }
795
+ openclawHome = next;
796
+ index += 1;
797
+ continue;
798
+ }
517
799
  if (arg === "--limit") {
518
800
  const next = args[index + 1];
519
801
  if (next === undefined) {
@@ -536,7 +818,7 @@ export function parseOperatorCliArgs(argv) {
536
818
  }
537
819
  return {
538
820
  command,
539
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
821
+ activationRoot: resolveCliActivationRoot(activationRoot, openclawHome),
540
822
  limit: historyLimit,
541
823
  json,
542
824
  help
@@ -562,6 +844,14 @@ export function parseOperatorCliArgs(argv) {
562
844
  index += 1;
563
845
  continue;
564
846
  }
847
+ if (arg === "--openclaw-home") {
848
+ const next = args[index + 1];
849
+ if (next === undefined)
850
+ throw new Error("--openclaw-home requires a value");
851
+ openclawHome = next;
852
+ index += 1;
853
+ continue;
854
+ }
565
855
  if (arg === "-o" || arg === "--output") {
566
856
  const next = args[index + 1];
567
857
  if (next === undefined)
@@ -579,7 +869,7 @@ export function parseOperatorCliArgs(argv) {
579
869
  throw new Error("export requires -o <output.tar.gz>");
580
870
  return {
581
871
  command,
582
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
872
+ activationRoot: resolveCliActivationRoot(activationRoot, openclawHome),
583
873
  outputPath: path.resolve(outputPath),
584
874
  json,
585
875
  help,
@@ -610,6 +900,14 @@ export function parseOperatorCliArgs(argv) {
610
900
  index += 1;
611
901
  continue;
612
902
  }
903
+ if (arg === "--openclaw-home") {
904
+ const next = args[index + 1];
905
+ if (next === undefined)
906
+ throw new Error("--openclaw-home requires a value");
907
+ openclawHome = next;
908
+ index += 1;
909
+ continue;
910
+ }
613
911
  if (arg.startsWith("--"))
614
912
  throw new Error(`unknown argument for import: ${arg}`);
615
913
  if (archivePath === null) {
@@ -626,7 +924,7 @@ export function parseOperatorCliArgs(argv) {
626
924
  return {
627
925
  command,
628
926
  archivePath: path.resolve(archivePath),
629
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
927
+ activationRoot: resolveCliActivationRoot(activationRoot, openclawHome),
630
928
  force,
631
929
  json,
632
930
  help,
@@ -656,13 +954,21 @@ export function parseOperatorCliArgs(argv) {
656
954
  index += 1;
657
955
  continue;
658
956
  }
957
+ if (arg === "--openclaw-home") {
958
+ const next = args[index + 1];
959
+ if (next === undefined)
960
+ throw new Error("--openclaw-home requires a value");
961
+ openclawHome = next;
962
+ index += 1;
963
+ continue;
964
+ }
659
965
  throw new Error(`unknown argument for reset: ${arg}`);
660
966
  }
661
967
  if (help)
662
968
  return { command, activationRoot: "", yes: false, json, help };
663
969
  return {
664
970
  command,
665
- activationRoot: resolveActivationRoot({ explicit: activationRoot }),
971
+ activationRoot: resolveCliActivationRoot(activationRoot, openclawHome),
666
972
  yes,
667
973
  json,
668
974
  help
@@ -686,6 +992,27 @@ export function parseOperatorCliArgs(argv) {
686
992
  shared = true;
687
993
  continue;
688
994
  }
995
+ if (arg === "--keep-data") {
996
+ keepData = true;
997
+ continue;
998
+ }
999
+ if (arg === "--purge-data") {
1000
+ purgeData = true;
1001
+ continue;
1002
+ }
1003
+ if (arg === "--restart") {
1004
+ const next = args[index + 1];
1005
+ if (next === undefined) {
1006
+ throw new Error("--restart requires a value");
1007
+ }
1008
+ if (next !== "never" && next !== "safe" && next !== "external") {
1009
+ throw new Error(`invalid --restart value: ${next}`);
1010
+ }
1011
+ restart = next;
1012
+ restartExplicitlySet = true;
1013
+ index += 1;
1014
+ continue;
1015
+ }
689
1016
  if (arg === "--detailed") {
690
1017
  detailed = true;
691
1018
  continue;
@@ -816,25 +1143,99 @@ export function parseOperatorCliArgs(argv) {
816
1143
  }
817
1144
  throw new Error(`unknown argument: ${arg}`);
818
1145
  }
819
- if (command === "setup") {
1146
+ if (command !== "detach" && command !== "uninstall" && restartExplicitlySet) {
1147
+ throw new Error("--restart only applies to detach/uninstall");
1148
+ }
1149
+ if (command !== "uninstall" && keepData) {
1150
+ throw new Error("--keep-data only applies to uninstall; use detach to preserve activation data");
1151
+ }
1152
+ if (command !== "uninstall" && purgeData) {
1153
+ throw new Error("--purge-data only applies to uninstall");
1154
+ }
1155
+ if (command === "install") {
1156
+ if (help) {
1157
+ return {
1158
+ command,
1159
+ openclawHome: "",
1160
+ openclawHomeSource: "explicit",
1161
+ activationRoot: "",
1162
+ activationRootSource: "explicit",
1163
+ shared: false,
1164
+ workspaceId: "",
1165
+ workspaceIdSource: "explicit",
1166
+ json,
1167
+ help
1168
+ };
1169
+ }
1170
+ const resolvedOpenclawHome = resolveInstallOpenClawHome(openclawHome);
1171
+ const resolvedActivationRoot = resolveInstallActivationRoot(resolvedOpenclawHome.openclawHome, activationRoot);
1172
+ const resolvedWorkspaceId = resolveInstallWorkspaceId(resolvedOpenclawHome.openclawHome, workspaceId);
1173
+ return {
1174
+ command,
1175
+ openclawHome: resolvedOpenclawHome.openclawHome,
1176
+ openclawHomeSource: resolvedOpenclawHome.openclawHomeSource,
1177
+ activationRoot: resolvedActivationRoot.activationRoot,
1178
+ activationRootSource: resolvedActivationRoot.source,
1179
+ shared,
1180
+ workspaceId: resolvedWorkspaceId.workspaceId,
1181
+ workspaceIdSource: resolvedWorkspaceId.source,
1182
+ json,
1183
+ help
1184
+ };
1185
+ }
1186
+ if (command === "detach") {
820
1187
  if (help) {
821
- return { command, openclawHome: "", activationRoot: "", shared: false, workspaceId: "", json, help };
1188
+ return { command, openclawHome: "", activationRoot: null, restart: "safe", json, help };
822
1189
  }
823
1190
  if (openclawHome === null || openclawHome.trim().length === 0) {
824
- throw new Error("--openclaw-home is required for setup");
1191
+ throw new Error("--openclaw-home is required for detach");
1192
+ }
1193
+ if (purgeData) {
1194
+ throw new Error("detach always preserves activation data; use uninstall --purge-data to remove it");
825
1195
  }
826
1196
  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;
1197
+ const resolvedActivationRoot = resolveActivationRoot({
1198
+ explicit: activationRoot,
1199
+ openclawHome: resolvedOpenclawHome,
1200
+ quiet: true
1201
+ });
832
1202
  return {
833
1203
  command,
834
1204
  openclawHome: resolvedOpenclawHome,
835
- activationRoot: resolvedActivationRoot,
836
- shared,
837
- workspaceId: resolvedWorkspaceId,
1205
+ activationRoot: resolvedActivationRoot.trim().length === 0 ? null : path.resolve(resolvedActivationRoot),
1206
+ restart,
1207
+ json,
1208
+ help
1209
+ };
1210
+ }
1211
+ if (command === "uninstall") {
1212
+ if (help) {
1213
+ return { command, openclawHome: "", activationRoot: null, dataMode: "keep", restart: "safe", json, help };
1214
+ }
1215
+ if (openclawHome === null || openclawHome.trim().length === 0) {
1216
+ throw new Error("--openclaw-home is required for uninstall");
1217
+ }
1218
+ if (!keepData && !purgeData) {
1219
+ throw new Error("uninstall requires exactly one of --keep-data or --purge-data");
1220
+ }
1221
+ if (keepData && purgeData) {
1222
+ throw new Error("--keep-data and --purge-data are mutually exclusive");
1223
+ }
1224
+ const resolvedOpenclawHome = path.resolve(openclawHome);
1225
+ const resolvedActivationRoot = resolveActivationRoot({
1226
+ explicit: activationRoot,
1227
+ openclawHome: resolvedOpenclawHome,
1228
+ quiet: true
1229
+ });
1230
+ if (purgeData && resolvedActivationRoot.trim().length === 0) {
1231
+ throw new Error("--purge-data requires a resolvable activation root from the installed profile hook or --activation-root <path>");
1232
+ }
1233
+ return {
1234
+ command,
1235
+ openclawHome: resolvedOpenclawHome,
1236
+ activationRoot: resolvedActivationRoot.trim().length === 0 ? null : path.resolve(resolvedActivationRoot),
1237
+ dataMode: purgeData ? "purge" : "keep",
1238
+ restart,
838
1239
  json,
839
1240
  help
840
1241
  };
@@ -905,6 +1306,7 @@ export function parseOperatorCliArgs(argv) {
905
1306
  updatedAt,
906
1307
  brainAttachmentPolicy
907
1308
  },
1309
+ openclawHome: normalizeOptionalCliString(openclawHome),
908
1310
  json,
909
1311
  help,
910
1312
  dryRun,
@@ -939,28 +1341,162 @@ function resolveExtensionTemplatePath() {
939
1341
  throw new Error("Pre-built extension template not found. Searched:\n" +
940
1342
  candidates.map((c) => ` - ${c}`).join("\n"));
941
1343
  }
1344
+ function resolveExtensionRuntimeGuardPath() {
1345
+ const tsCandidates = [
1346
+ path.resolve(__dirname, "..", "extension", "runtime-guard.ts"),
1347
+ path.resolve(__dirname, "..", "..", "extension", "runtime-guard.ts"),
1348
+ ];
1349
+ const jsCandidates = [
1350
+ path.resolve(__dirname, "extension", "runtime-guard.js"),
1351
+ path.resolve(__dirname, "..", "extension", "runtime-guard.js"),
1352
+ ];
1353
+ const tsPath = tsCandidates.find((c) => existsSync(c)) ?? null;
1354
+ const jsPath = jsCandidates.find((c) => existsSync(c));
1355
+ if (!jsPath) {
1356
+ throw new Error("Pre-built extension runtime-guard.js not found. Searched:\n" +
1357
+ jsCandidates.map((c) => ` - ${c}`).join("\n"));
1358
+ }
1359
+ return { ts: tsPath, js: jsPath };
1360
+ }
1361
+ const LOCAL_WORKSPACE_EXTENSION_PACKAGES = [
1362
+ "activation",
1363
+ "compiler",
1364
+ "contracts",
1365
+ "event-export",
1366
+ "events",
1367
+ "learner",
1368
+ "openclaw",
1369
+ "pack-format",
1370
+ "provenance",
1371
+ "workspace-metadata"
1372
+ ];
1373
+ const OPENCLAWBRAIN_EXTENSION_TARBALL_DIR_ENV = "OPENCLAWBRAIN_EXTENSION_TARBALL_DIR";
1374
+ function resolveNpmCommand() {
1375
+ return process.platform === "win32" ? "npm.cmd" : "npm";
1376
+ }
1377
+ function resolveExtensionInstallReleaseTarballs() {
1378
+ const configuredDir = normalizeOptionalCliString(process.env[OPENCLAWBRAIN_EXTENSION_TARBALL_DIR_ENV]);
1379
+ if (configuredDir === null) {
1380
+ return null;
1381
+ }
1382
+ const artifactDir = path.resolve(configuredDir);
1383
+ let entries;
1384
+ try {
1385
+ entries = readdirSync(artifactDir, { withFileTypes: true });
1386
+ }
1387
+ catch (error) {
1388
+ const detail = error instanceof Error ? error.message : String(error);
1389
+ throw new Error(`${OPENCLAWBRAIN_EXTENSION_TARBALL_DIR_ENV} is unreadable: ${artifactDir} (${detail})`);
1390
+ }
1391
+ const tarballs = entries
1392
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"))
1393
+ .map((entry) => path.join(artifactDir, entry.name))
1394
+ .sort((left, right) => left.localeCompare(right));
1395
+ if (tarballs.length === 0) {
1396
+ throw new Error(`${OPENCLAWBRAIN_EXTENSION_TARBALL_DIR_ENV} has no .tgz release artifacts: ${artifactDir}`);
1397
+ }
1398
+ return {
1399
+ artifactDir,
1400
+ tarballs
1401
+ };
1402
+ }
1403
+ function resolveLocalWorkspaceRootForExtensionInstall() {
1404
+ const candidates = [
1405
+ path.resolve(__dirname, "..", "..", "..", ".."),
1406
+ path.resolve(__dirname, "..", "..", "..")
1407
+ ];
1408
+ for (const candidate of candidates) {
1409
+ const packageRoot = path.join(candidate, "packages", "openclaw");
1410
+ const distEntry = path.join(packageRoot, "dist", "src", "index.js");
1411
+ if (existsSync(packageRoot) && existsSync(distEntry)) {
1412
+ return candidate;
1413
+ }
1414
+ }
1415
+ return null;
1416
+ }
1417
+ function installExtensionFromLocalWorkspaceBuild(extensionDir) {
1418
+ const workspaceRoot = resolveLocalWorkspaceRootForExtensionInstall();
1419
+ if (workspaceRoot === null) {
1420
+ return null;
1421
+ }
1422
+ const nodeModulesRoot = path.join(extensionDir, "node_modules", "@openclawbrain");
1423
+ mkdirSync(nodeModulesRoot, { recursive: true });
1424
+ for (const packageName of LOCAL_WORKSPACE_EXTENSION_PACKAGES) {
1425
+ const packageDir = path.join(workspaceRoot, "packages", packageName);
1426
+ const packageDistEntry = path.join(packageDir, "dist", "src", "index.js");
1427
+ if (!existsSync(packageDir) || !existsSync(packageDistEntry)) {
1428
+ return null;
1429
+ }
1430
+ }
1431
+ for (const packageName of LOCAL_WORKSPACE_EXTENSION_PACKAGES) {
1432
+ const packageDir = path.join(workspaceRoot, "packages", packageName);
1433
+ const linkPath = path.join(nodeModulesRoot, packageName);
1434
+ rmSync(linkPath, { recursive: true, force: true });
1435
+ symlinkSync(packageDir, linkPath, "dir");
1436
+ }
1437
+ return [...LOCAL_WORKSPACE_EXTENSION_PACKAGES];
1438
+ }
1439
+ let cachedOpenClawPackageMetadata = null;
1440
+ function resolveOpenClawPackageManifestPath() {
1441
+ const candidates = [
1442
+ path.resolve(__dirname, "..", "package.json"),
1443
+ path.resolve(__dirname, "..", "..", "package.json"),
1444
+ ];
1445
+ for (const candidate of candidates) {
1446
+ if (existsSync(candidate)) {
1447
+ return candidate;
1448
+ }
1449
+ }
1450
+ throw new Error("OpenClawBrain package manifest not found. Searched:\n" +
1451
+ candidates.map((candidate) => ` - ${candidate}`).join("\n"));
1452
+ }
1453
+ function readOpenClawPackageMetadata() {
1454
+ if (cachedOpenClawPackageMetadata !== null) {
1455
+ return cachedOpenClawPackageMetadata;
1456
+ }
1457
+ const manifestPath = resolveOpenClawPackageManifestPath();
1458
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
1459
+ const name = typeof manifest.name === "string" && manifest.name.trim().length > 0
1460
+ ? manifest.name.trim()
1461
+ : "@openclawbrain/openclaw";
1462
+ const version = typeof manifest.version === "string" && manifest.version.trim().length > 0
1463
+ ? manifest.version.trim()
1464
+ : "0.0.0";
1465
+ cachedOpenClawPackageMetadata = { name, version };
1466
+ return cachedOpenClawPackageMetadata;
1467
+ }
942
1468
  function buildExtensionIndexTs(activationRoot) {
943
1469
  const templatePath = resolveExtensionTemplatePath();
944
1470
  const template = readFileSync(templatePath, "utf8");
945
1471
  return template.replace(/const ACTIVATION_ROOT = "__ACTIVATION_ROOT__";/, `const ACTIVATION_ROOT = ${JSON.stringify(activationRoot)};`);
946
1472
  }
947
1473
  function buildExtensionPackageJson() {
1474
+ const packageMetadata = readOpenClawPackageMetadata();
948
1475
  return JSON.stringify({
949
1476
  name: "openclawbrain-extension",
950
- version: "0.1.0",
1477
+ version: packageMetadata.version,
951
1478
  private: true,
952
1479
  type: "module",
1480
+ openclaw: {
1481
+ extensions: ["index.ts"]
1482
+ },
953
1483
  dependencies: {
954
- "@openclawbrain/openclaw": ">=0.2.0"
1484
+ [packageMetadata.name]: packageMetadata.version
955
1485
  }
956
1486
  }, null, 2) + "\n";
957
1487
  }
958
1488
  function buildExtensionPluginManifest() {
1489
+ const packageMetadata = readOpenClawPackageMetadata();
959
1490
  return JSON.stringify({
960
1491
  id: "openclawbrain",
961
1492
  name: "OpenClawBrain",
962
1493
  description: "Learned memory and context from OpenClawBrain",
963
- version: "0.2.0"
1494
+ version: packageMetadata.version,
1495
+ configSchema: {
1496
+ type: "object",
1497
+ additionalProperties: false,
1498
+ properties: {}
1499
+ }
964
1500
  }, null, 2) + "\n";
965
1501
  }
966
1502
  function formatContextForHuman(result) {
@@ -1040,15 +1576,94 @@ function buildHistoryEntry(record, slot, isActive) {
1040
1576
  current: isActive
1041
1577
  };
1042
1578
  }
1579
+ function formatInspectionFindings(findings) {
1580
+ return findings.join("; ");
1581
+ }
1582
+ function buildInstallRefusalError(parsed, detail) {
1583
+ const purgeCommand = `openclawbrain uninstall --openclaw-home ${quoteShellArg(parsed.openclawHome)} ` +
1584
+ `--activation-root ${quoteShellArg(parsed.activationRoot)} --purge-data`;
1585
+ return new Error(`Refusing to reuse activation root ${path.resolve(parsed.activationRoot)}: ${detail}. ` +
1586
+ "Install only repairs an empty first-state root; it will not overwrite populated or broken activation state. " +
1587
+ `Inspect: ${buildInstallStatusCommand(parsed.activationRoot)}. ` +
1588
+ `Reset: ${purgeCommand}.`);
1589
+ }
1590
+ function inspectInstallActivationPlan(parsed) {
1591
+ const resolvedActivationRoot = path.resolve(parsed.activationRoot);
1592
+ const activationPointersPath = path.join(resolvedActivationRoot, "activation-pointers.json");
1593
+ if (!existsSync(resolvedActivationRoot)) {
1594
+ return {
1595
+ createActivationRoot: true,
1596
+ action: "bootstrap",
1597
+ resolution: "new_root",
1598
+ inspectionStep: "Activation state inspection: activation root is missing; bootstrapping first state.",
1599
+ activePackId: null
1600
+ };
1601
+ }
1602
+ const activationRootStats = statSync(resolvedActivationRoot);
1603
+ if (!activationRootStats.isDirectory()) {
1604
+ throw buildInstallRefusalError(parsed, "activation root path exists but is not a directory");
1605
+ }
1606
+ if (!existsSync(activationPointersPath)) {
1607
+ return {
1608
+ createActivationRoot: false,
1609
+ action: "bootstrap",
1610
+ resolution: "missing_pointers",
1611
+ inspectionStep: "Activation state inspection: activation root exists but activation-pointers.json is missing; bootstrapping first state.",
1612
+ activePackId: null
1613
+ };
1614
+ }
1615
+ let inspection;
1616
+ try {
1617
+ inspection = inspectActivationState(resolvedActivationRoot, new Date().toISOString());
1618
+ }
1619
+ catch (error) {
1620
+ const detail = error instanceof Error ? error.message : String(error);
1621
+ throw buildInstallRefusalError(parsed, `activation pointers could not be inspected (${detail})`);
1622
+ }
1623
+ if (inspection.active === null && inspection.candidate === null && inspection.previous === null) {
1624
+ return {
1625
+ createActivationRoot: false,
1626
+ action: "bootstrap",
1627
+ resolution: "empty_pointers",
1628
+ inspectionStep: "Activation state inspection: activation pointers are present but all slots are empty; bootstrapping first state.",
1629
+ activePackId: null
1630
+ };
1631
+ }
1632
+ const unhealthySlots = [inspection.active, inspection.candidate, inspection.previous]
1633
+ .filter((slot) => slot !== null && !slot.activationReady)
1634
+ .map((slot) => `${slot.slot}: ${formatInspectionFindings(slot.findings)}`);
1635
+ if (unhealthySlots.length > 0) {
1636
+ throw buildInstallRefusalError(parsed, `activation state contains unhealthy slots (${unhealthySlots.join(" | ")})`);
1637
+ }
1638
+ if (inspection.active === null) {
1639
+ const populatedSlots = [inspection.candidate, inspection.previous]
1640
+ .filter((slot) => slot !== null)
1641
+ .map((slot) => slot.slot);
1642
+ throw buildInstallRefusalError(parsed, `activation state is populated without an active pack (${populatedSlots.join(", ")})`);
1643
+ }
1644
+ if (inspection.candidate !== null && !inspection.promotion.allowed) {
1645
+ throw buildInstallRefusalError(parsed, `candidate slot is stale or incoherent (${formatInspectionFindings(inspection.promotion.findings)})`);
1646
+ }
1647
+ if (inspection.previous !== null && !inspection.rollback.allowed) {
1648
+ throw buildInstallRefusalError(parsed, `previous slot is stale or incoherent (${formatInspectionFindings(inspection.rollback.findings)})`);
1649
+ }
1650
+ return {
1651
+ createActivationRoot: false,
1652
+ action: "keep",
1653
+ resolution: "healthy_existing",
1654
+ inspectionStep: `Activation state inspection: active pack ${inspection.active.packId} is healthy; keeping existing activation state.`,
1655
+ activePackId: inspection.active.packId
1656
+ };
1657
+ }
1043
1658
  function runHistoryCommand(parsed) {
1044
1659
  const activationRoot = parsed.activationRoot;
1045
1660
  const pointersPath = path.join(activationRoot, "activation-pointers.json");
1046
1661
  if (!existsSync(pointersPath)) {
1047
1662
  if (parsed.json) {
1048
- console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain setup" }, null, 2));
1663
+ console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain install" }, null, 2));
1049
1664
  }
1050
1665
  else {
1051
- console.log("No history yet. Run: openclawbrain setup");
1666
+ console.log("No history yet. Run: openclawbrain install");
1052
1667
  }
1053
1668
  return 0;
1054
1669
  }
@@ -1081,10 +1696,10 @@ function runHistoryCommand(parsed) {
1081
1696
  }
1082
1697
  if (entries.length === 0) {
1083
1698
  if (parsed.json) {
1084
- console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain setup" }, null, 2));
1699
+ console.log(JSON.stringify({ entries: [], empty: true, message: "No history yet. Run: openclawbrain install" }, null, 2));
1085
1700
  }
1086
1701
  else {
1087
- console.log("No history yet. Run: openclawbrain setup");
1702
+ console.log("No history yet. Run: openclawbrain install");
1088
1703
  }
1089
1704
  return 0;
1090
1705
  }
@@ -1121,30 +1736,25 @@ function runHistoryCommand(parsed) {
1121
1736
  }
1122
1737
  return 0;
1123
1738
  }
1124
- function runSetupCommand(parsed) {
1739
+ function runInstallCommand(parsed) {
1125
1740
  const steps = [];
1741
+ const commandLabel = parsed.command.toUpperCase();
1742
+ steps.push(`Target OpenClaw profile home: ${parsed.openclawHome} (${formatInstallOpenClawHomeSource(parsed.openclawHomeSource)})`);
1126
1743
  // 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
- }
1134
- // 2. Create activation root if needed
1135
- if (!existsSync(parsed.activationRoot)) {
1744
+ validateOpenClawHome(parsed.openclawHome);
1745
+ // 2. Inspect the activation root before writing profile hook artifacts.
1746
+ const activationPlan = inspectInstallActivationPlan(parsed);
1747
+ // 3. Create activation root if needed
1748
+ if (activationPlan.createActivationRoot) {
1136
1749
  mkdirSync(parsed.activationRoot, { recursive: true });
1137
1750
  steps.push(`Created activation root: ${parsed.activationRoot}`);
1138
1751
  }
1139
1752
  else {
1140
1753
  steps.push(`Activation root exists: ${parsed.activationRoot}`);
1141
1754
  }
1142
- // 3. Run bootstrapRuntimeAttach if not already attached
1143
- const activationPointersPath = path.join(parsed.activationRoot, "activation-pointers.json");
1144
- if (existsSync(activationPointersPath)) {
1145
- steps.push("Brain already attached (activation-pointers.json exists), skipping bootstrap.");
1146
- }
1147
- else {
1755
+ steps.push(activationPlan.inspectionStep);
1756
+ // 4. Bootstrap only for safe empty first-state roots; otherwise keep the inspected healthy state.
1757
+ if (activationPlan.action === "bootstrap") {
1148
1758
  const packRoot = path.resolve(parsed.activationRoot, "packs", "initial");
1149
1759
  mkdirSync(packRoot, { recursive: true });
1150
1760
  const brainAttachmentPolicy = parsed.shared ? "shared" : "dedicated";
@@ -1153,52 +1763,85 @@ function runSetupCommand(parsed) {
1153
1763
  brainAttachmentPolicy,
1154
1764
  activationRoot: parsed.activationRoot,
1155
1765
  packRoot,
1156
- packLabel: "setup-cli",
1766
+ packLabel: "install-cli",
1157
1767
  workspace: {
1158
1768
  workspaceId: parsed.workspaceId,
1159
- snapshotId: `${parsed.workspaceId}@setup-${new Date().toISOString().slice(0, 10)}`,
1769
+ snapshotId: `${parsed.workspaceId}@install-${new Date().toISOString().slice(0, 10)}`,
1160
1770
  capturedAt: new Date().toISOString(),
1161
1771
  rootDir: parsed.openclawHome,
1162
- revision: "cli-setup-v1"
1772
+ revision: "cli-install-v1"
1163
1773
  },
1164
1774
  interactionEvents: [],
1165
1775
  feedbackEvents: []
1166
1776
  });
1167
- steps.push(`Bootstrapped brain attach: ${result.status}`);
1777
+ steps.push(`Bootstrapped brain attach: state=${result.currentProfile.brain.state} awaitingFirstExport=${yesNo(result.currentProfile.brainStatus.awaitingFirstExport)}`);
1778
+ }
1779
+ else {
1780
+ steps.push(`Kept inspected activation state: active pack ${activationPlan.activePackId}`);
1168
1781
  }
1169
- // 4-7. Write extension files
1782
+ // 5-8. Write extension files
1170
1783
  const extensionDir = path.join(parsed.openclawHome, "extensions", "openclawbrain");
1171
1784
  mkdirSync(extensionDir, { recursive: true });
1172
- // 4. Write index.ts
1785
+ // 5. Write index.ts
1173
1786
  const indexTsPath = path.join(extensionDir, "index.ts");
1174
1787
  writeFileSync(indexTsPath, buildExtensionIndexTs(parsed.activationRoot), "utf8");
1175
1788
  steps.push(`Wrote extension: ${indexTsPath}`);
1176
- // 5. Write package.json
1789
+ // 5b. Write runtime-guard files (imported by index.ts as ./runtime-guard.js)
1790
+ const runtimeGuardPaths = resolveExtensionRuntimeGuardPath();
1791
+ if (runtimeGuardPaths.ts !== null) {
1792
+ const runtimeGuardTsPath = path.join(extensionDir, "runtime-guard.ts");
1793
+ writeFileSync(runtimeGuardTsPath, readFileSync(runtimeGuardPaths.ts, "utf8"), "utf8");
1794
+ steps.push(`Wrote extension runtime-guard source: ${runtimeGuardTsPath}`);
1795
+ }
1796
+ const runtimeGuardJsPath = path.join(extensionDir, "runtime-guard.js");
1797
+ writeFileSync(runtimeGuardJsPath, readFileSync(runtimeGuardPaths.js, "utf8"), "utf8");
1798
+ steps.push(`Wrote extension runtime-guard: ${runtimeGuardJsPath}`);
1799
+ // 6. Write package.json
1177
1800
  const packageJsonPath = path.join(extensionDir, "package.json");
1178
1801
  writeFileSync(packageJsonPath, buildExtensionPackageJson(), "utf8");
1179
1802
  steps.push(`Wrote package.json: ${packageJsonPath}`);
1180
- // 6. npm install
1803
+ // 7. npm install
1804
+ const releaseTarballInstall = resolveExtensionInstallReleaseTarballs();
1181
1805
  try {
1182
- execSync("npm install --ignore-scripts", { cwd: extensionDir, stdio: "pipe" });
1183
- steps.push("Ran npm install --ignore-scripts");
1806
+ if (releaseTarballInstall !== null) {
1807
+ execFileSync(resolveNpmCommand(), ["install", "--ignore-scripts", "--no-save", ...releaseTarballInstall.tarballs], { cwd: extensionDir, stdio: "pipe" });
1808
+ steps.push(`Installed extension dependencies from release artifacts: ${releaseTarballInstall.tarballs.length} tarballs from ${releaseTarballInstall.artifactDir}`);
1809
+ }
1810
+ else {
1811
+ execSync("npm install --ignore-scripts", { cwd: extensionDir, stdio: "pipe" });
1812
+ steps.push("Ran npm install --ignore-scripts");
1813
+ const linkedPackages = installExtensionFromLocalWorkspaceBuild(extensionDir);
1814
+ if (linkedPackages !== null) {
1815
+ steps.push(`Linked coherent local workspace packages: ${linkedPackages.join(", ")}`);
1816
+ }
1817
+ }
1184
1818
  }
1185
1819
  catch (err) {
1186
1820
  const message = err instanceof Error ? err.message : String(err);
1187
- steps.push(`npm install failed (non-fatal): ${message}`);
1821
+ if (releaseTarballInstall !== null) {
1822
+ throw new Error(`Extension dependency install from release artifacts failed: ${message}`);
1823
+ }
1824
+ const linkedPackages = installExtensionFromLocalWorkspaceBuild(extensionDir);
1825
+ if (linkedPackages !== null) {
1826
+ steps.push(`Linked coherent local workspace packages: ${linkedPackages.join(", ")}`);
1827
+ }
1828
+ else {
1829
+ steps.push(`npm install failed (non-fatal): ${message}`);
1830
+ }
1188
1831
  }
1189
- // 7. Write plugin manifest
1832
+ // 8. Write plugin manifest
1190
1833
  const manifestPath = path.join(extensionDir, "openclaw.plugin.json");
1191
1834
  writeFileSync(manifestPath, buildExtensionPluginManifest(), "utf8");
1192
1835
  steps.push(`Wrote manifest: ${manifestPath}`);
1193
- // 8. Write BRAIN.md to workspace directories
1836
+ // 9. Write BRAIN.md to workspace directories
1194
1837
  const brainMdContent = [
1195
1838
  "## OpenClawBrain",
1196
1839
  `You have a learning brain attached at ${parsed.activationRoot}.`,
1197
1840
  "- It learns automatically from your conversations",
1198
1841
  '- Corrections matter — "no, actually X" teaches the brain X',
1199
1842
  "- You don't manage it — background daemon handles learning",
1200
- "- Check: `openclawbrain status`",
1201
- "- Rollback: `openclawbrain rollback`",
1843
+ `- Check: \`openclawbrain status --activation-root ${quoteShellArg(parsed.activationRoot)}\``,
1844
+ `- Rollback: \`openclawbrain rollback --activation-root ${quoteShellArg(parsed.activationRoot)}\``,
1202
1845
  '- See what brain knows: `openclawbrain context "your question"`',
1203
1846
  ""
1204
1847
  ].join("\n");
@@ -1266,58 +1909,361 @@ function runSetupCommand(parsed) {
1266
1909
  const message = err instanceof Error ? err.message : String(err);
1267
1910
  steps.push(`BRAIN.md generation failed (non-fatal): ${message}`);
1268
1911
  }
1912
+ const restartGuidance = buildInstallReloadGuidance();
1913
+ const nextSteps = [
1914
+ restartGuidance,
1915
+ `Check status: ${buildInstallStatusCommand(parsed.activationRoot)}`
1916
+ ];
1917
+ const lifecycleSummary = [
1918
+ `OpenClaw profile: ${shortenPath(parsed.openclawHome)} (${formatInstallOpenClawHomeSource(parsed.openclawHomeSource)})`,
1919
+ `Activation root: ${shortenPath(parsed.activationRoot)} (${formatInstallActivationRootSource(parsed.activationRootSource)})`,
1920
+ `Workspace ID: ${parsed.workspaceId} (${formatInstallWorkspaceIdSource(parsed.workspaceIdSource)})`,
1921
+ `Profile hook: installed at ${shortenPath(extensionDir)}`,
1922
+ activationPlan.resolution === "new_root"
1923
+ ? `Activation data: initialized at ${shortenPath(parsed.activationRoot)}`
1924
+ : activationPlan.resolution === "missing_pointers"
1925
+ ? `Activation data: repaired missing pointers at ${shortenPath(parsed.activationRoot)}`
1926
+ : activationPlan.resolution === "empty_pointers"
1927
+ ? `Activation data: repaired empty pointers at ${shortenPath(parsed.activationRoot)}`
1928
+ : `Activation data: reused healthy state at ${shortenPath(parsed.activationRoot)}`,
1929
+ activationPlan.action === "bootstrap"
1930
+ ? activationPlan.resolution === "new_root"
1931
+ ? "Brain attach: bootstrapped a seed/current-profile attach"
1932
+ : activationPlan.resolution === "missing_pointers"
1933
+ ? "Brain attach: repaired missing activation pointers and bootstrapped a seed/current-profile attach"
1934
+ : "Brain attach: repaired empty activation pointers and bootstrapped a seed/current-profile attach"
1935
+ : `Brain attach: kept healthy active pack ${activationPlan.activePackId} in place`
1936
+ ];
1269
1937
  // 9. Print summary
1270
1938
  if (parsed.json) {
1271
1939
  console.log(JSON.stringify({
1272
- command: "setup",
1940
+ command: parsed.command,
1273
1941
  openclawHome: parsed.openclawHome,
1942
+ openclawHomeSource: parsed.openclawHomeSource,
1274
1943
  activationRoot: parsed.activationRoot,
1944
+ resolvedInputs: {
1945
+ activationRoot: {
1946
+ value: parsed.activationRoot,
1947
+ source: parsed.activationRootSource
1948
+ },
1949
+ workspaceId: {
1950
+ value: parsed.workspaceId,
1951
+ source: parsed.workspaceIdSource
1952
+ }
1953
+ },
1275
1954
  workspaceId: parsed.workspaceId,
1276
1955
  shared: parsed.shared,
1277
1956
  extensionDir,
1957
+ lifecycleSummary,
1958
+ restartGuidance,
1959
+ nextSteps,
1278
1960
  steps
1279
1961
  }, null, 2));
1280
1962
  }
1281
1963
  else {
1282
- console.log("SETUP complete\n");
1964
+ console.log(`${commandLabel} complete\n`);
1283
1965
  for (const step of steps) {
1284
1966
  console.log(` ✓ ${step}`);
1285
1967
  }
1286
1968
  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.");
1969
+ console.log("Lifecycle:");
1970
+ for (const line of lifecycleSummary) {
1971
+ console.log(` ${line}`);
1972
+ }
1973
+ console.log(`Next: ${restartGuidance}`);
1974
+ console.log(`Check: ${buildInstallStatusCommand(parsed.activationRoot)}`);
1289
1975
  }
1290
1976
  return 0;
1291
1977
  }
1292
- function runLearnCommand(parsed) {
1293
- const activationRoot = parsed.activationRoot;
1294
- // 1. Discover local session stores
1295
- const stores = discoverOpenClawMainSessionStores();
1296
- if (stores.length === 0) {
1297
- 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." }));
1299
- }
1300
- else {
1301
- console.log("No new session data. Brain is up to date.");
1302
- }
1303
- return 0;
1978
+ function validateOpenClawHome(openclawHome) {
1979
+ if (!existsSync(openclawHome)) {
1980
+ throw new Error(`--openclaw-home directory does not exist: ${openclawHome}`);
1304
1981
  }
1305
- // 2. Build passive learning export from ALL discovered sessions in one monotonic sequence space
1306
- let totalSessions = 0;
1307
- let totalInteractionEvents = 0;
1308
- let totalFeedbackEvents = 0;
1309
- const allInteractionEvents = [];
1310
- const allFeedbackEvents = [];
1311
- let nextSequence = 1;
1312
- const discoveredSessions = stores
1313
- .flatMap((store) => {
1314
- const sessionIndex = loadOpenClawSessionIndex(store.indexPath);
1315
- return Object.entries(sessionIndex).map(([sessionKey, entry]) => ({
1316
- store,
1317
- sessionKey,
1318
- entry
1319
- }));
1320
- })
1982
+ const openclawJsonPath = path.join(openclawHome, "openclaw.json");
1983
+ if (!existsSync(openclawJsonPath)) {
1984
+ throw new Error(`openclaw.json not found in ${openclawHome}`);
1985
+ }
1986
+ }
1987
+ function resolveCleanupActivationRoot(openclawHome, explicitActivationRoot) {
1988
+ if (explicitActivationRoot !== null) {
1989
+ return path.resolve(explicitActivationRoot);
1990
+ }
1991
+ const resolved = resolveActivationRoot({
1992
+ openclawHome,
1993
+ quiet: true
1994
+ });
1995
+ return resolved.trim().length === 0 ? null : path.resolve(resolved);
1996
+ }
1997
+ function removeProfileHookup(openclawHome, steps) {
1998
+ const extensionDir = path.join(openclawHome, "extensions", "openclawbrain");
1999
+ if (!existsSync(extensionDir)) {
2000
+ steps.push(`Profile hookup already absent: ${extensionDir}`);
2001
+ return extensionDir;
2002
+ }
2003
+ rmSync(extensionDir, { recursive: true, force: true });
2004
+ steps.push(`Removed profile hookup: ${extensionDir}`);
2005
+ return extensionDir;
2006
+ }
2007
+ function summarizeKeptActivationData(activationRoot) {
2008
+ if (activationRoot === null) {
2009
+ return {
2010
+ activationRoot: null,
2011
+ activationDataState: "unresolved",
2012
+ activationDataDetail: "Activation data preserved, but the activation root could not be resolved from the profile hook."
2013
+ };
2014
+ }
2015
+ return {
2016
+ activationRoot,
2017
+ activationDataState: "kept",
2018
+ activationDataDetail: `Activation data preserved at ${activationRoot}`
2019
+ };
2020
+ }
2021
+ function buildRestartGuidance(restart) {
2022
+ return buildCleanupRestartGuidance(restart);
2023
+ }
2024
+ function runDetachCommand(parsed) {
2025
+ const steps = [];
2026
+ validateOpenClawHome(parsed.openclawHome);
2027
+ const activationRoot = resolveCleanupActivationRoot(parsed.openclawHome, parsed.activationRoot);
2028
+ const extensionDir = removeProfileHookup(parsed.openclawHome, steps);
2029
+ const activationData = summarizeKeptActivationData(activationRoot);
2030
+ const restartGuidance = buildRestartGuidance(parsed.restart);
2031
+ const nextSteps = [
2032
+ restartGuidance,
2033
+ activationRoot === null ? null : `Inspect preserved data: ${buildInstallStatusCommand(activationRoot)}`,
2034
+ `Reattach later: ${buildInstallCommand(parsed.openclawHome)}`
2035
+ ].filter((step) => step !== null);
2036
+ steps.push(activationData.activationDataDetail);
2037
+ steps.push("Detach only removes the OpenClaw profile hook; it does not delete OpenClawBrain data.");
2038
+ if (parsed.json) {
2039
+ console.log(JSON.stringify({
2040
+ command: "detach",
2041
+ openclawHome: parsed.openclawHome,
2042
+ extensionDir,
2043
+ activationRoot,
2044
+ dataAction: "kept",
2045
+ activationDataState: activationData.activationDataState,
2046
+ restartMode: parsed.restart,
2047
+ restartGuidance,
2048
+ nextSteps,
2049
+ steps
2050
+ }, null, 2));
2051
+ }
2052
+ else {
2053
+ console.log("DETACH complete\n");
2054
+ for (const step of steps) {
2055
+ console.log(` ✓ ${step}`);
2056
+ }
2057
+ console.log("");
2058
+ console.log(`Lifecycle: OpenClaw profile ${shortenPath(parsed.openclawHome)} is detached from the brain hook.`);
2059
+ if (activationRoot !== null) {
2060
+ console.log(`Brain data: ${shortenPath(activationRoot)} remains available for inspection or reattach.`);
2061
+ }
2062
+ else {
2063
+ console.log("Brain data: preserved, but the activation root could not be resolved from the removed hook.");
2064
+ }
2065
+ console.log(`Next: ${restartGuidance}`);
2066
+ if (activationRoot !== null) {
2067
+ console.log(`Check: ${buildInstallStatusCommand(activationRoot)}`);
2068
+ }
2069
+ console.log(`Reattach: ${buildInstallCommand(parsed.openclawHome)}`);
2070
+ }
2071
+ return 0;
2072
+ }
2073
+ function runUninstallCommand(parsed) {
2074
+ const steps = [];
2075
+ validateOpenClawHome(parsed.openclawHome);
2076
+ const activationRoot = resolveCleanupActivationRoot(parsed.openclawHome, parsed.activationRoot);
2077
+ const extensionDir = removeProfileHookup(parsed.openclawHome, steps);
2078
+ let activationData;
2079
+ if (parsed.dataMode === "purge") {
2080
+ if (activationRoot === null) {
2081
+ throw new Error("--purge-data requires a resolvable activation root from the installed profile hook or --activation-root <path>");
2082
+ }
2083
+ if (existsSync(activationRoot)) {
2084
+ rmSync(activationRoot, { recursive: true, force: true });
2085
+ activationData = {
2086
+ activationRoot,
2087
+ activationDataState: "removed",
2088
+ activationDataDetail: `Activation data removed at ${activationRoot}`
2089
+ };
2090
+ }
2091
+ else {
2092
+ activationData = {
2093
+ activationRoot,
2094
+ activationDataState: "already_absent",
2095
+ activationDataDetail: `Activation data already absent at ${activationRoot}`
2096
+ };
2097
+ }
2098
+ }
2099
+ else {
2100
+ activationData = summarizeKeptActivationData(activationRoot);
2101
+ }
2102
+ const restartGuidance = buildRestartGuidance(parsed.restart);
2103
+ const nextSteps = [
2104
+ restartGuidance,
2105
+ parsed.dataMode === "keep" && activationRoot !== null ? `Inspect preserved data: ${buildInstallStatusCommand(activationRoot)}` : null,
2106
+ `Reinstall later: ${buildInstallCommand(parsed.openclawHome)}`
2107
+ ].filter((step) => step !== null);
2108
+ steps.push(activationData.activationDataDetail);
2109
+ steps.push(parsed.dataMode === "purge"
2110
+ ? "Uninstall removed the OpenClaw profile hook and activation data."
2111
+ : "Uninstall removed the OpenClaw profile hook and kept activation data explicitly.");
2112
+ if (parsed.json) {
2113
+ console.log(JSON.stringify({
2114
+ command: "uninstall",
2115
+ openclawHome: parsed.openclawHome,
2116
+ extensionDir,
2117
+ activationRoot,
2118
+ dataAction: parsed.dataMode,
2119
+ activationDataState: activationData.activationDataState,
2120
+ restartMode: parsed.restart,
2121
+ restartGuidance,
2122
+ nextSteps,
2123
+ steps
2124
+ }, null, 2));
2125
+ }
2126
+ else {
2127
+ console.log("UNINSTALL complete\n");
2128
+ for (const step of steps) {
2129
+ console.log(` ✓ ${step}`);
2130
+ }
2131
+ console.log("");
2132
+ console.log(`Lifecycle: OpenClaw profile ${shortenPath(parsed.openclawHome)} no longer has the brain hook installed.`);
2133
+ console.log(`Data mode: ${parsed.dataMode === "purge" ? "purged" : "kept"}`);
2134
+ if (activationRoot !== null) {
2135
+ console.log(`Activation: ${parsed.dataMode === "purge" ? shortenPath(activationRoot) : `${shortenPath(activationRoot)} preserved`}`);
2136
+ }
2137
+ console.log(`Next: ${restartGuidance}`);
2138
+ if (parsed.dataMode === "keep" && activationRoot !== null) {
2139
+ console.log(`Check: ${buildInstallStatusCommand(activationRoot)}`);
2140
+ }
2141
+ console.log(`Reinstall: ${buildInstallCommand(parsed.openclawHome)}`);
2142
+ }
2143
+ return 0;
2144
+ }
2145
+ function resolveServeTimeLearningRuntimeInput(activationRoot) {
2146
+ let serveTimeDecisions = [];
2147
+ let fallbackReason = null;
2148
+ try {
2149
+ serveTimeDecisions = readLearningSpineLogEntries(activationRoot, "serveTimeRouteDecisions");
2150
+ }
2151
+ catch {
2152
+ fallbackReason = "serve_time_decision_log_read_failed";
2153
+ }
2154
+ const decisionLogCount = serveTimeDecisions.length;
2155
+ const pgVersion = decisionLogCount > 0 ? "v2" : "v1";
2156
+ return {
2157
+ pgVersion,
2158
+ serveTimeDecisions,
2159
+ decisionLogCount,
2160
+ baselineState: pgVersion === "v2" ? loadOrInitBaseline(activationRoot) : undefined,
2161
+ fallbackReason
2162
+ };
2163
+ }
2164
+ function runLearnCommand(parsed) {
2165
+ const learnStatePath = path.join(parsed.activationRoot, "learn-cli-state.json");
2166
+ const teacherSnapshotPath = resolveAsyncTeacherLiveLoopSnapshotPath(parsed.activationRoot);
2167
+ function isLearnRuntimeStateLike(value) {
2168
+ if (typeof value !== "object" || value === null) {
2169
+ return false;
2170
+ }
2171
+ const candidate = value;
2172
+ return (candidate.runtimeOwner === "openclaw" &&
2173
+ typeof candidate.cursor === "object" &&
2174
+ candidate.cursor !== null &&
2175
+ typeof candidate.pending === "object" &&
2176
+ candidate.pending !== null &&
2177
+ Array.isArray(candidate.pending.live) &&
2178
+ Array.isArray(candidate.pending.backfill) &&
2179
+ typeof candidate.materializationCount === "number" &&
2180
+ typeof candidate.sparseFeedback === "object" &&
2181
+ candidate.sparseFeedback !== null);
2182
+ }
2183
+ function loadPersistedLearnCliState() {
2184
+ if (!existsSync(learnStatePath)) {
2185
+ return {
2186
+ state: createAlwaysOnLearningRuntimeState(),
2187
+ loaded: false,
2188
+ resetReason: null
2189
+ };
2190
+ }
2191
+ try {
2192
+ const persisted = readJsonFile(learnStatePath);
2193
+ if (persisted.contract !== "openclaw.learn_cli_state.v1" || !isLearnRuntimeStateLike(persisted.state)) {
2194
+ throw new Error("persisted learn state shape is invalid");
2195
+ }
2196
+ return {
2197
+ state: persisted.state,
2198
+ loaded: true,
2199
+ resetReason: null
2200
+ };
2201
+ }
2202
+ catch (error) {
2203
+ return {
2204
+ state: createAlwaysOnLearningRuntimeState(),
2205
+ loaded: false,
2206
+ resetReason: error instanceof Error ? error.message : "persisted learn state could not be parsed"
2207
+ };
2208
+ }
2209
+ }
2210
+ function persistLearnCliState(state, updatedAt) {
2211
+ const payload = {
2212
+ contract: "openclaw.learn_cli_state.v1",
2213
+ updatedAt,
2214
+ state
2215
+ };
2216
+ mkdirSync(path.dirname(learnStatePath), { recursive: true });
2217
+ writeFileSync(learnStatePath, JSON.stringify(payload, null, 2) + "\n", "utf8");
2218
+ }
2219
+ const activationRoot = parsed.activationRoot;
2220
+ const persistedState = loadPersistedLearnCliState();
2221
+ const stores = discoverOpenClawSessionStores();
2222
+ if (stores.length === 0) {
2223
+ const labelFlow = {
2224
+ source: "missing",
2225
+ humanLabelCount: 0,
2226
+ selfLabelCount: 0,
2227
+ asyncTeacherArtifactCount: 0,
2228
+ implicitPositiveCount: 0,
2229
+ detail: "no local session stores were found"
2230
+ };
2231
+ const learningPath = summarizeLearningPathFromMaterialization(null);
2232
+ if (parsed.json) {
2233
+ console.log(JSON.stringify({
2234
+ command: "learn",
2235
+ activationRoot,
2236
+ scannedSessions: 0,
2237
+ newEvents: 0,
2238
+ loadedState: persistedState.loaded,
2239
+ statePath: learnStatePath,
2240
+ stateResetReason: persistedState.resetReason,
2241
+ materialized: null,
2242
+ promoted: false,
2243
+ graph: null,
2244
+ labelFlow,
2245
+ learningPath,
2246
+ message: "No local session stores found."
2247
+ }));
2248
+ }
2249
+ else {
2250
+ console.log("No new session data. Brain is up to date.");
2251
+ }
2252
+ return 0;
2253
+ }
2254
+ let totalSessions = 0;
2255
+ const allInteractionEvents = [];
2256
+ const allFeedbackEvents = [];
2257
+ let nextSequence = 1;
2258
+ const discoveredSessions = stores
2259
+ .flatMap((store) => {
2260
+ const sessionIndex = loadOpenClawSessionIndex(store.indexPath);
2261
+ return Object.entries(sessionIndex).map(([sessionKey, entry]) => ({
2262
+ store,
2263
+ sessionKey,
2264
+ entry
2265
+ }));
2266
+ })
1321
2267
  .sort((left, right) => {
1322
2268
  if (left.entry.updatedAt !== right.entry.updatedAt) {
1323
2269
  return left.entry.updatedAt - right.entry.updatedAt;
@@ -1326,7 +2272,20 @@ function runLearnCommand(parsed) {
1326
2272
  return left.store.indexPath.localeCompare(right.store.indexPath);
1327
2273
  }
1328
2274
  return left.sessionKey.localeCompare(right.sessionKey);
1329
- });
2275
+ })
2276
+ .filter((() => {
2277
+ const seenSessionIds = new Set();
2278
+ return (session) => {
2279
+ const sessionId = session.entry.sessionId;
2280
+ if (sessionId !== undefined && seenSessionIds.has(sessionId)) {
2281
+ return false;
2282
+ }
2283
+ if (sessionId !== undefined) {
2284
+ seenSessionIds.add(sessionId);
2285
+ }
2286
+ return true;
2287
+ };
2288
+ })());
1330
2289
  for (const session of discoveredSessions) {
1331
2290
  const sessionFile = session.entry.sessionFile;
1332
2291
  const records = typeof sessionFile !== "string" || sessionFile.trim().length === 0
@@ -1348,24 +2307,75 @@ function runLearnCommand(parsed) {
1348
2307
  });
1349
2308
  nextSequence = sessionExport.nextSequence;
1350
2309
  totalSessions += 1;
1351
- totalInteractionEvents += sessionExport.interactionEvents.length;
1352
- totalFeedbackEvents += sessionExport.feedbackEvents.length;
1353
2310
  allInteractionEvents.push(...sessionExport.interactionEvents);
1354
2311
  allFeedbackEvents.push(...sessionExport.feedbackEvents);
1355
2312
  }
1356
- const totalEvents = totalInteractionEvents + totalFeedbackEvents;
2313
+ const seenInteractionIds = new Set();
2314
+ const dedupedInteractionEvents = [];
2315
+ for (const event of allInteractionEvents) {
2316
+ if (!seenInteractionIds.has(event.eventId)) {
2317
+ seenInteractionIds.add(event.eventId);
2318
+ dedupedInteractionEvents.push(event);
2319
+ }
2320
+ }
2321
+ const seenFeedbackIds = new Set();
2322
+ const dedupedFeedbackEvents = [];
2323
+ for (const event of allFeedbackEvents) {
2324
+ if (!seenFeedbackIds.has(event.eventId)) {
2325
+ seenFeedbackIds.add(event.eventId);
2326
+ dedupedFeedbackEvents.push(event);
2327
+ }
2328
+ }
2329
+ const totalEvents = dedupedInteractionEvents.length + dedupedFeedbackEvents.length;
2330
+ const now = new Date().toISOString();
2331
+ const normalizedEventExport = totalEvents === 0
2332
+ ? null
2333
+ : buildNormalizedEventExport({
2334
+ interactionEvents: dedupedInteractionEvents,
2335
+ feedbackEvents: dedupedFeedbackEvents
2336
+ });
2337
+ const teacherArtifacts = normalizedEventExport === null
2338
+ ? []
2339
+ : buildTeacherSupervisionArtifactsFromNormalizedEventExport({
2340
+ normalizedEventExport,
2341
+ observedAt: now
2342
+ });
2343
+ const labelFlow = normalizedEventExport === null
2344
+ ? {
2345
+ source: "missing",
2346
+ humanLabelCount: 0,
2347
+ selfLabelCount: 0,
2348
+ asyncTeacherArtifactCount: 0,
2349
+ implicitPositiveCount: 0,
2350
+ detail: "no normalized learning export was built"
2351
+ }
2352
+ : summarizeNormalizedEventExportLabelFlow(normalizedEventExport, teacherArtifacts.length);
1357
2353
  if (totalEvents === 0) {
1358
2354
  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." }));
2355
+ console.log(JSON.stringify({
2356
+ command: "learn",
2357
+ activationRoot,
2358
+ scannedSessions: totalSessions,
2359
+ newEvents: 0,
2360
+ loadedState: persistedState.loaded,
2361
+ statePath: learnStatePath,
2362
+ stateResetReason: persistedState.resetReason,
2363
+ materialized: null,
2364
+ promoted: false,
2365
+ graph: null,
2366
+ labelFlow,
2367
+ learningPath: summarizeLearningPathFromMaterialization(null),
2368
+ message: "No new session data. Brain is up to date."
2369
+ }));
1360
2370
  }
1361
2371
  else {
1362
2372
  console.log("No new session data. Brain is up to date.");
1363
2373
  }
1364
2374
  return 0;
1365
2375
  }
1366
- // 3. Run single learning cycle
1367
- const now = new Date().toISOString();
1368
- const learnerResult = advanceAlwaysOnLearningRuntime({
2376
+ const learningExport = normalizedEventExport;
2377
+ const serveTimeLearning = resolveServeTimeLearningRuntimeInput(activationRoot);
2378
+ const learnerResult = drainAlwaysOnLearningRuntime({
1369
2379
  packLabel: "learn-cli",
1370
2380
  workspace: {
1371
2381
  workspaceId: "learn-cli",
@@ -1374,17 +2384,58 @@ function runLearnCommand(parsed) {
1374
2384
  rootDir: activationRoot,
1375
2385
  revision: "learn-cli-v1"
1376
2386
  },
1377
- interactionEvents: allInteractionEvents,
1378
- feedbackEvents: allFeedbackEvents,
2387
+ interactionEvents: dedupedInteractionEvents,
2388
+ feedbackEvents: dedupedFeedbackEvents,
2389
+ teacherSupervisionArtifacts: teacherArtifacts,
1379
2390
  learnedRouting: true,
1380
- state: createAlwaysOnLearningRuntimeState(),
1381
- builtAt: now
2391
+ state: persistedState.state,
2392
+ builtAt: now,
2393
+ maxCycles: 16,
2394
+ pgVersion: serveTimeLearning.pgVersion,
2395
+ ...(serveTimeLearning.decisionLogCount > 0 ? { serveTimeDecisions: serveTimeLearning.serveTimeDecisions } : {}),
2396
+ ...(serveTimeLearning.baselineState !== undefined ? { baselineState: serveTimeLearning.baselineState } : {})
1382
2397
  });
1383
- // 4. If materialization produced, materialize → stage → promote
1384
- if (learnerResult.materialization !== null) {
2398
+ const lastMaterialization = learnerResult.materializations.at(-1) ?? null;
2399
+ const plan = describeAlwaysOnLearningRuntimeState(learnerResult.state, lastMaterialization);
2400
+ const learningPath = summarizeLearningPathFromMaterialization(lastMaterialization);
2401
+ const supervisionCount = lastMaterialization?.candidate.summary.learnedRouter.supervisionCount ?? 0;
2402
+ const routerUpdateCount = lastMaterialization?.candidate.summary.learnedRouter.updateCount ?? 0;
2403
+ const routerNoOpReason = lastMaterialization?.candidate.summary.learnedRouter.noOpReason ?? null;
2404
+ const graphEvolution = lastMaterialization?.candidate.payloads.graph.evolution;
2405
+ const graphSummary = graphEvolution === undefined
2406
+ ? null
2407
+ : {
2408
+ structuralOps: graphEvolution.structuralOps,
2409
+ connectDiagnostics: graphEvolution.connectDiagnostics ?? null
2410
+ };
2411
+ const connectSummary = graphSummary?.connectDiagnostics === null || graphSummary?.connectDiagnostics === undefined
2412
+ ? ""
2413
+ : ` connect candidates=${graphSummary.connectDiagnostics.candidatePairCount} applied=${graphSummary.connectDiagnostics.appliedPairCount} edges=${graphSummary.connectDiagnostics.createdEdgeCount}.`;
2414
+ const routingBuild = lastMaterialization?.candidate.routingBuild ?? {
2415
+ learnedRoutingPath: serveTimeLearning.pgVersion === "v2" ? "policy_gradient_v2" : "policy_gradient_v1",
2416
+ pgVersionRequested: serveTimeLearning.pgVersion,
2417
+ pgVersionUsed: serveTimeLearning.pgVersion,
2418
+ decisionLogCount: serveTimeLearning.decisionLogCount,
2419
+ fallbackReason: serveTimeLearning.pgVersion === "v1" ? serveTimeLearning.fallbackReason ?? "no_serve_time_decisions" : null,
2420
+ updatedBaseline: null
2421
+ };
2422
+ const learnPathReport = {
2423
+ ...routingBuild,
2424
+ fallbackReason: routingBuild.fallbackReason ??
2425
+ (routingBuild.pgVersionUsed === "v1" ? serveTimeLearning.fallbackReason ?? "no_serve_time_decisions" : null)
2426
+ };
2427
+ let promoted = false;
2428
+ let materializedPackId = null;
2429
+ let baselinePersisted = false;
2430
+ const latestTeacherFreshness = teacherArtifacts.length === 0
2431
+ ? "none"
2432
+ : teacherArtifacts.some((artifact) => artifact.freshness.status === "fresh")
2433
+ ? "fresh"
2434
+ : "stale";
2435
+ if (lastMaterialization !== null) {
1385
2436
  const candidatePackRoot = path.join(activationRoot, "packs", `learn-cli-${Date.now()}`);
1386
2437
  mkdirSync(candidatePackRoot, { recursive: true });
1387
- const candidateDescriptor = materializeAlwaysOnLearningCandidatePack(candidatePackRoot, learnerResult.materialization);
2438
+ const candidateDescriptor = materializeAlwaysOnLearningCandidatePack(candidatePackRoot, lastMaterialization);
1388
2439
  stageCandidatePack(activationRoot, candidatePackRoot, {
1389
2440
  updatedAt: now,
1390
2441
  reason: "learn_cli_stage"
@@ -1393,37 +2444,111 @@ function runLearnCommand(parsed) {
1393
2444
  updatedAt: now,
1394
2445
  reason: "learn_cli_promote"
1395
2446
  });
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.`);
2447
+ if (learnPathReport.pgVersionUsed === "v2" && learnPathReport.updatedBaseline !== null) {
2448
+ persistBaseline(activationRoot, learnPathReport.updatedBaseline);
2449
+ baselinePersisted = true;
2450
+ }
2451
+ materializedPackId = candidateDescriptor.manifest.packId;
2452
+ promoted = true;
2453
+ }
2454
+ persistLearnCliState(learnerResult.state, now);
2455
+ writeJsonFile(teacherSnapshotPath, {
2456
+ runtimeOwner: "openclaw",
2457
+ queue: {
2458
+ capacity: 1,
2459
+ depth: 0,
2460
+ running: false
2461
+ },
2462
+ teacher: {
2463
+ artifactCount: teacherArtifacts.length,
2464
+ artifacts: teacherArtifacts,
2465
+ latestFreshness: latestTeacherFreshness
2466
+ },
2467
+ learner: {
2468
+ state: learnerResult.state,
2469
+ lastMaterialization
2470
+ },
2471
+ diagnostics: {
2472
+ acceptedExportCount: 1,
2473
+ processedExportCount: 1,
2474
+ duplicateExportCount: 0,
2475
+ droppedExportCount: 0,
2476
+ emittedArtifactCount: teacherArtifacts.length,
2477
+ dedupedArtifactCount: 0,
2478
+ lastProcessedAt: now,
2479
+ latestFreshness: latestTeacherFreshness,
2480
+ lastNoOpReason: teacherArtifacts.length === 0 ? "no_teacher_artifacts" : "none",
2481
+ notes: [
2482
+ `learn-cli export=${learningExport.provenance.exportDigest} range=${learningExport.range.start}-${learningExport.range.end}/${learningExport.range.count}`,
2483
+ `teacher artifacts=${teacherArtifacts.length} freshness=${latestTeacherFreshness}`,
2484
+ `last materialized pack=${materializedPackId ?? "none"}`
2485
+ ]
2486
+ },
2487
+ state: {
2488
+ interactionEvents: learningExport.interactionEvents,
2489
+ feedbackEvents: learningExport.feedbackEvents,
2490
+ seenExportDigests: [learningExport.provenance.exportDigest]
2491
+ },
2492
+ runtime: {
2493
+ startedAt: now,
2494
+ lastHeartbeatAt: now,
2495
+ lastScanAt: now,
2496
+ scanRoot: null,
2497
+ lastAppliedMaterializationJobId: lastMaterialization?.jobId ?? null
1410
2498
  }
2499
+ });
2500
+ const summaryMessage = materializedPackId === null
2501
+ ? `Scanned ${totalSessions} sessions, ${totalEvents} new events, no candidate materialized, no promotion.`
2502
+ : `Scanned ${totalSessions} sessions, ${totalEvents} new events, materialized ${materializedPackId}, promoted.${connectSummary}`;
2503
+ if (parsed.json) {
2504
+ console.log(JSON.stringify({
2505
+ command: "learn",
2506
+ activationRoot,
2507
+ scannedSessions: totalSessions,
2508
+ newEvents: totalEvents,
2509
+ loadedState: persistedState.loaded,
2510
+ statePath: learnStatePath,
2511
+ stateResetReason: persistedState.resetReason,
2512
+ drain: {
2513
+ cyclesRun: learnerResult.cycles.length,
2514
+ stopReason: learnerResult.stopReason,
2515
+ drained: learnerResult.drained,
2516
+ materializationCount: learnerResult.materializations.length
2517
+ },
2518
+ learner: {
2519
+ teacherBudget: learnerResult.state.sparseFeedback.teacherBudget,
2520
+ eligibleFeedbackCount: learnerResult.state.sparseFeedback.eligibleFeedbackCount,
2521
+ budgetedOutFeedbackCount: learnerResult.state.sparseFeedback.budgetedOutFeedbackCount,
2522
+ supervisionCount,
2523
+ routerUpdateCount,
2524
+ routerNoOpReason,
2525
+ pending: plan.pending,
2526
+ learnedRange: plan.learnedRange
2527
+ },
2528
+ materialized: materializedPackId,
2529
+ promoted,
2530
+ graph: graphSummary,
2531
+ labelFlow,
2532
+ learningPath,
2533
+ learnedRoutingPath: learnPathReport.learnedRoutingPath,
2534
+ pgVersionRequested: learnPathReport.pgVersionRequested,
2535
+ pgVersionUsed: learnPathReport.pgVersionUsed,
2536
+ decisionLogCount: learnPathReport.decisionLogCount,
2537
+ fallbackReason: learnPathReport.fallbackReason,
2538
+ baselinePersisted,
2539
+ message: summaryMessage
2540
+ }, null, 2));
1411
2541
  }
1412
2542
  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
- }
2543
+ const text = materializedPackId === null
2544
+ ? `Scanned ${totalSessions} sessions, ${totalEvents} new events, no promotion. cycles=${learnerResult.cycles.length} stop=${learnerResult.stopReason} supervision=${supervisionCount}.`
2545
+ : `Scanned ${totalSessions} sessions, ${totalEvents} new events, materialized ${materializedPackId}, promoted.${connectSummary} cycles=${learnerResult.cycles.length} supervision=${supervisionCount}.`;
2546
+ console.log(text);
2547
+ console.log(`labels: source=${labelFlow.source} human=${labelFlow.humanLabelCount ?? "none"} self=${labelFlow.selfLabelCount ?? "none"} implicitPositive=${labelFlow.implicitPositiveCount ?? "none"} teacherArtifacts=${labelFlow.asyncTeacherArtifactCount ?? "none"}`);
2548
+ 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"}`);
2549
+ console.log(`learned routing: path=${learnPathReport.learnedRoutingPath} pg=${learnPathReport.pgVersionUsed ?? "n/a"} decisions=${learnPathReport.decisionLogCount}` +
2550
+ `${learnPathReport.fallbackReason === null ? "" : ` fallback=${learnPathReport.fallbackReason}`}` +
2551
+ `${learnPathReport.pgVersionUsed === "v2" ? ` baseline=${baselinePersisted ? "persisted" : "unchanged"}` : ""}`);
1427
2552
  }
1428
2553
  return 0;
1429
2554
  }
@@ -1434,26 +2559,553 @@ function formatTimestamp() {
1434
2559
  function watchLog(message) {
1435
2560
  console.log(`${formatTimestamp()} ${message}`);
1436
2561
  }
1437
- async function runWatchCommand(parsed) {
1438
- const activationRoot = parsed.activationRoot;
1439
- const scanRoot = parsed.scanRoot !== null
1440
- ? path.resolve(parsed.scanRoot)
2562
+ function formatWatchError(error) {
2563
+ return error instanceof Error ? error.message : String(error);
2564
+ }
2565
+ function sanitizeWatchPathSegment(value) {
2566
+ const sanitized = value
2567
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
2568
+ .replace(/^-+|-+$/g, "")
2569
+ .slice(0, 96);
2570
+ return sanitized.length > 0 ? sanitized : "session";
2571
+ }
2572
+ function readOptionalJsonFile(filePath) {
2573
+ if (!existsSync(filePath)) {
2574
+ return null;
2575
+ }
2576
+ try {
2577
+ return JSON.parse(readFileSync(filePath, "utf8"));
2578
+ }
2579
+ catch {
2580
+ return null;
2581
+ }
2582
+ }
2583
+ function writeJsonFile(filePath, value) {
2584
+ mkdirSync(path.dirname(filePath), { recursive: true });
2585
+ writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
2586
+ }
2587
+ function loadWatchSessionTailCursor(cursorPath) {
2588
+ const parsed = readOptionalJsonFile(cursorPath);
2589
+ if (Array.isArray(parsed)) {
2590
+ return parsed;
2591
+ }
2592
+ if (parsed !== null && Array.isArray(parsed.cursor)) {
2593
+ return parsed.cursor;
2594
+ }
2595
+ return [];
2596
+ }
2597
+ function persistWatchSessionTailCursor(cursorPath, cursor) {
2598
+ writeJsonFile(cursorPath, {
2599
+ contract: "openclaw_watch_session_tail_cursor.v1",
2600
+ runtimeOwner: "openclaw",
2601
+ updatedAt: new Date().toISOString(),
2602
+ cursor
2603
+ });
2604
+ }
2605
+ function countWatchCursorBridgedEvents(cursor) {
2606
+ return cursor.reduce((sum, entry) => sum + entry.bridgedEventCount, 0);
2607
+ }
2608
+ function listWatchRuntimeEventExportBundleRoots(scanRoot) {
2609
+ if (!existsSync(scanRoot)) {
2610
+ return [];
2611
+ }
2612
+ return readdirSync(scanRoot, { withFileTypes: true })
2613
+ .filter((entry) => entry.isDirectory())
2614
+ .map((entry) => path.join(scanRoot, entry.name))
2615
+ .sort((left, right) => left.localeCompare(right));
2616
+ }
2617
+ async function replayWatchScanRootIntoTeacherLoop(teacherLoop, scanRoot) {
2618
+ const seenExportDigests = new Set();
2619
+ const bundles = listWatchRuntimeEventExportBundleRoots(scanRoot)
2620
+ .map((rootDir) => {
2621
+ try {
2622
+ return loadRuntimeEventExportBundle(rootDir);
2623
+ }
2624
+ catch {
2625
+ return null;
2626
+ }
2627
+ })
2628
+ .filter((bundle) => bundle !== null)
2629
+ .sort((left, right) => {
2630
+ const exportedAtCompare = left.manifest.exportedAt.localeCompare(right.manifest.exportedAt);
2631
+ if (exportedAtCompare !== 0) {
2632
+ return exportedAtCompare;
2633
+ }
2634
+ if (left.normalizedEventExport.range.start !== right.normalizedEventExport.range.start) {
2635
+ return left.normalizedEventExport.range.start - right.normalizedEventExport.range.start;
2636
+ }
2637
+ if (left.normalizedEventExport.range.end !== right.normalizedEventExport.range.end) {
2638
+ return left.normalizedEventExport.range.end - right.normalizedEventExport.range.end;
2639
+ }
2640
+ return left.normalizedEventExport.provenance.exportDigest.localeCompare(right.normalizedEventExport.provenance.exportDigest);
2641
+ });
2642
+ let replayedBundleCount = 0;
2643
+ let replayedEventCount = 0;
2644
+ for (const bundle of bundles) {
2645
+ const exportDigest = bundle.normalizedEventExport.provenance.exportDigest;
2646
+ if (seenExportDigests.has(exportDigest)) {
2647
+ continue;
2648
+ }
2649
+ seenExportDigests.add(exportDigest);
2650
+ let enqueue = teacherLoop.enqueueNormalizedEventExport(bundle.normalizedEventExport, {
2651
+ observedAt: bundle.manifest.exportedAt
2652
+ });
2653
+ if (!enqueue.accepted && enqueue.reason === "queue_full") {
2654
+ await teacherLoop.flush();
2655
+ enqueue = teacherLoop.enqueueNormalizedEventExport(bundle.normalizedEventExport, {
2656
+ observedAt: bundle.manifest.exportedAt
2657
+ });
2658
+ }
2659
+ if (!enqueue.accepted) {
2660
+ continue;
2661
+ }
2662
+ replayedBundleCount += 1;
2663
+ replayedEventCount += bundle.normalizedEventExport.range.count;
2664
+ }
2665
+ if (replayedBundleCount > 0) {
2666
+ await teacherLoop.flush();
2667
+ }
2668
+ return {
2669
+ replayedBundleCount,
2670
+ replayedEventCount
2671
+ };
2672
+ }
2673
+ function exportLocalSessionTailChangesToScanRoot(input) {
2674
+ let exportedBundleCount = 0;
2675
+ let exportedEventCount = 0;
2676
+ const warnings = [];
2677
+ for (const change of input.changes) {
2678
+ if (change.scannedEventExport === null) {
2679
+ continue;
2680
+ }
2681
+ const built = buildNormalizedEventExportFromScannedEvents(change.scannedEventExport);
2682
+ if (!built.ok) {
2683
+ warnings.push(`${change.sessionKey}: ${built.error}`);
2684
+ continue;
2685
+ }
2686
+ const exportDigest = built.normalizedEventExport.provenance.exportDigest.replace(/^sha256-/u, "");
2687
+ const exportName = `session-tail-${sanitizeWatchPathSegment(change.sessionKey)}-${built.normalizedEventExport.range.start}-${built.normalizedEventExport.range.end}-${exportDigest.slice(0, 12)}`;
2688
+ const result = writeScannedEventExportBundle({
2689
+ rootDir: path.join(input.scanRoot, exportName),
2690
+ exportName,
2691
+ exportedAt: input.polledAt,
2692
+ scannedEventExport: change.scannedEventExport
2693
+ });
2694
+ if (!result.ok) {
2695
+ warnings.push(`${change.sessionKey}: ${result.error}`);
2696
+ continue;
2697
+ }
2698
+ exportedBundleCount += 1;
2699
+ exportedEventCount += result.normalizedEventExport.range.count;
2700
+ }
2701
+ return {
2702
+ exportedBundleCount,
2703
+ exportedEventCount,
2704
+ warnings
2705
+ };
2706
+ }
2707
+ function applyWatchMaterialization(activationRoot, snapshot, lastHandledMaterializationPackId) {
2708
+ const materialization = snapshot?.learner?.lastMaterialization ?? null;
2709
+ if (materialization === null) {
2710
+ return {
2711
+ lastHandledMaterializationPackId,
2712
+ logLine: null,
2713
+ materializedPackId: null,
2714
+ failure: null
2715
+ };
2716
+ }
2717
+ const packId = typeof materialization?.candidate?.summary?.packId === "string"
2718
+ ? materialization.candidate.summary.packId
2719
+ : null;
2720
+ if (packId === null || packId === lastHandledMaterializationPackId) {
2721
+ return {
2722
+ lastHandledMaterializationPackId,
2723
+ logLine: null,
2724
+ materializedPackId: packId,
2725
+ failure: null
2726
+ };
2727
+ }
2728
+ const shortPackId = packId.length > 16 ? packId.slice(0, 16) : packId;
2729
+ try {
2730
+ const candidateRootDir = path.resolve(activationRoot, "packs", packId);
2731
+ mkdirSync(candidateRootDir, { recursive: true });
2732
+ let activeBeforePack = null;
2733
+ try {
2734
+ activeBeforePack = loadPackFromActivation(activationRoot, "active", { requireActivationReady: true });
2735
+ }
2736
+ catch {
2737
+ activeBeforePack = null;
2738
+ }
2739
+ const candidateDescriptor = materializeAlwaysOnLearningCandidatePack(candidateRootDir, materialization);
2740
+ appendLearningUpdateLogs({
2741
+ activationRoot,
2742
+ materialization,
2743
+ activeBeforePack,
2744
+ candidateDescriptor
2745
+ });
2746
+ const now = new Date().toISOString();
2747
+ stageCandidatePack(activationRoot, candidateRootDir, {
2748
+ updatedAt: now,
2749
+ reason: `watch_stage:${materialization.reason}:${materialization.lane}`
2750
+ });
2751
+ const inspection = inspectActivationState(activationRoot, now);
2752
+ if (inspection.promotion.allowed) {
2753
+ promoteCandidatePack(activationRoot, {
2754
+ updatedAt: now,
2755
+ reason: `watch_promote:${materialization.reason}:${materialization.lane}`
2756
+ });
2757
+ return {
2758
+ lastHandledMaterializationPackId: packId,
2759
+ materializedPackId: packId,
2760
+ logLine: `Promoted ${shortPackId} → active`,
2761
+ failure: null
2762
+ };
2763
+ }
2764
+ return {
2765
+ lastHandledMaterializationPackId: packId,
2766
+ materializedPackId: packId,
2767
+ logLine: `Staged ${shortPackId} (promotion blocked: ${inspection.promotion.findings.join(", ")})`,
2768
+ failure: null
2769
+ };
2770
+ }
2771
+ catch (error) {
2772
+ const message = error instanceof Error ? error.message : String(error);
2773
+ return {
2774
+ lastHandledMaterializationPackId,
2775
+ materializedPackId: packId,
2776
+ logLine: `Promotion failed for ${shortPackId}: ${message}`,
2777
+ failure: {
2778
+ mode: "materialization_failed",
2779
+ detail: message,
2780
+ at: new Date().toISOString()
2781
+ }
2782
+ };
2783
+ }
2784
+ }
2785
+ function resolveWatchTeacherLabelerConfig(input) {
2786
+ if (input !== undefined) {
2787
+ return {
2788
+ teacherLabeler: input,
2789
+ warnings: []
2790
+ };
2791
+ }
2792
+ const providerConfig = readOpenClawBrainProviderConfig(process.env);
2793
+ const warnings = providerConfig.warnings.filter((warning) => /OPENCLAWBRAIN_TEACHER_/u.test(warning));
2794
+ if (providerConfig.teacher.provider !== "ollama") {
2795
+ return {
2796
+ teacherLabeler: null,
2797
+ warnings
2798
+ };
2799
+ }
2800
+ return {
2801
+ teacherLabeler: {
2802
+ provider: "ollama",
2803
+ baseUrl: providerConfig.teacherBaseUrl,
2804
+ model: providerConfig.teacher.model,
2805
+ ...(providerConfig.teacher.timeoutMs === undefined ? {} : { timeoutMs: providerConfig.teacher.timeoutMs }),
2806
+ ...(providerConfig.teacher.maxPromptChars === undefined ? {} : { maxPromptChars: providerConfig.teacher.maxPromptChars }),
2807
+ ...(providerConfig.teacher.maxResponseChars === undefined ? {} : { maxResponseChars: providerConfig.teacher.maxResponseChars }),
2808
+ ...(providerConfig.teacher.maxOutputTokens === undefined ? {} : { maxOutputTokens: providerConfig.teacher.maxOutputTokens }),
2809
+ ...(providerConfig.teacher.maxArtifactsPerExport === undefined
2810
+ ? {}
2811
+ : { maxArtifactsPerExport: providerConfig.teacher.maxArtifactsPerExport }),
2812
+ ...(providerConfig.teacher.maxInteractionsPerExport === undefined
2813
+ ? {}
2814
+ : { maxInteractionsPerExport: providerConfig.teacher.maxInteractionsPerExport })
2815
+ },
2816
+ warnings
2817
+ };
2818
+ }
2819
+ export async function createWatchCommandRuntime(input) {
2820
+ const activationRoot = path.resolve(input.activationRoot);
2821
+ const bootstrapObservedAt = new Date().toISOString();
2822
+ const scanRoot = input.scanRoot !== undefined && input.scanRoot !== null
2823
+ ? path.resolve(input.scanRoot)
1441
2824
  : 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`);
2825
+ const sessionTailCursorPath = resolveWatchSessionTailCursorPath(activationRoot);
2826
+ const teacherSnapshotPath = resolveWatchTeacherSnapshotPath(activationRoot);
2827
+ const restoredTeacherState = loadWatchTeacherSnapshotState(teacherSnapshotPath);
2828
+ const log = input.log ?? watchLog;
2829
+ const startupWarnings = [];
2830
+ mkdirSync(scanRoot, { recursive: true });
2831
+ mkdirSync(resolveWatchStateRoot(activationRoot), { recursive: true });
2832
+ log(`Watch starting — activation: ${shortenPath(activationRoot)}`);
2833
+ log(`Scan root: ${shortenPath(scanRoot)}`);
2834
+ log(`State: cursor=${shortenPath(sessionTailCursorPath)} snapshot=${shortenPath(teacherSnapshotPath)}`);
2835
+ const resolvedTeacherLabeler = resolveWatchTeacherLabelerConfig(input.teacherLabeler);
2836
+ const teacherLabeler = resolvedTeacherLabeler.teacherLabeler;
2837
+ for (const warning of resolvedTeacherLabeler.warnings) {
2838
+ startupWarnings.push(`teacher_env_warning:${warning}`);
2839
+ log(`Teacher env warning: ${warning}`);
2840
+ }
2841
+ if (teacherLabeler?.provider === "ollama") {
2842
+ log(`Teacher labeler: provider=ollama model=${teacherLabeler.model ?? "qwen3.5:9b"}`);
2843
+ }
1445
2844
  const scanner = createRuntimeEventExportScanner({ scanRoot });
1446
- const teacherLoop = createAsyncTeacherLiveLoop({
2845
+ let lastServeTimeFallbackReason = null;
2846
+ const baseTeacherLoopInput = {
1447
2847
  packLabel: "watch-cli",
1448
2848
  workspace: {
1449
2849
  workspaceId: "watch-cli",
1450
2850
  snapshotId: `watch-cli@${new Date().toISOString().slice(0, 10)}`,
1451
2851
  capturedAt: new Date().toISOString(),
1452
2852
  rootDir: activationRoot,
1453
- revision: "watch-cli-v1"
2853
+ revision: "watch-cli-v2"
1454
2854
  },
1455
- learnedRouting: true
2855
+ learnedRouting: true,
2856
+ ...(teacherLabeler !== null ? { teacherLabeler } : {}),
2857
+ resolveLearnedRoutingState: () => {
2858
+ const resolved = resolveServeTimeLearningRuntimeInput(activationRoot);
2859
+ if (resolved.fallbackReason !== null && resolved.fallbackReason !== lastServeTimeFallbackReason) {
2860
+ log(`Serve-time routing fallback: ${resolved.fallbackReason}`);
2861
+ }
2862
+ lastServeTimeFallbackReason = resolved.fallbackReason;
2863
+ return {
2864
+ pgVersion: resolved.pgVersion,
2865
+ ...(resolved.decisionLogCount > 0 ? { serveTimeDecisions: resolved.serveTimeDecisions } : {}),
2866
+ ...(resolved.baselineState !== undefined ? { baselineState: resolved.baselineState } : {})
2867
+ };
2868
+ },
2869
+ persistUpdatedBaseline: (state) => {
2870
+ try {
2871
+ persistBaseline(activationRoot, state);
2872
+ }
2873
+ catch (error) {
2874
+ log(`Baseline persist failed: ${formatWatchError(error)}`);
2875
+ }
2876
+ }
2877
+ };
2878
+ let teacherLoop;
2879
+ let lastHandledMaterializationPackId = restoredTeacherState.lastHandledMaterializationPackId;
2880
+ if (restoredTeacherState.error !== null) {
2881
+ const message = restoredTeacherState.error;
2882
+ startupWarnings.push(`teacher_snapshot_reset:${message}`);
2883
+ lastHandledMaterializationPackId = null;
2884
+ log(`Teacher snapshot reset: ${message}`);
2885
+ teacherLoop = createAsyncTeacherLiveLoop(baseTeacherLoopInput);
2886
+ }
2887
+ else {
2888
+ try {
2889
+ teacherLoop = createAsyncTeacherLiveLoop({
2890
+ ...baseTeacherLoopInput,
2891
+ ...(restoredTeacherState.snapshot !== null ? { resumeFromSnapshot: restoredTeacherState.snapshot } : {})
2892
+ });
2893
+ }
2894
+ catch (error) {
2895
+ const message = formatWatchError(error);
2896
+ startupWarnings.push(`teacher_snapshot_reset:${message}`);
2897
+ lastHandledMaterializationPackId = null;
2898
+ log(`Teacher snapshot reset: ${message}`);
2899
+ teacherLoop = createAsyncTeacherLiveLoop(baseTeacherLoopInput);
2900
+ }
2901
+ }
2902
+ if (restoredTeacherState.snapshot !== null && startupWarnings.length === 0) {
2903
+ const restoredSeenExportCount = restoredTeacherState.snapshot.state?.seenExportDigests.length ?? 0;
2904
+ log(`Restored teacher snapshot: seen=${restoredSeenExportCount} artifacts=${restoredTeacherState.snapshot.teacher.artifactCount}`);
2905
+ }
2906
+ let restoredCursor = loadWatchSessionTailCursor(sessionTailCursorPath);
2907
+ let localSessionTail;
2908
+ try {
2909
+ localSessionTail = createOpenClawLocalSessionTail({
2910
+ ...(input.profileRoots === undefined ? {} : { profileRoots: input.profileRoots }),
2911
+ cursor: restoredCursor,
2912
+ emitExistingOnFirstPoll: restoredCursor.length === 0
2913
+ });
2914
+ }
2915
+ catch (error) {
2916
+ const message = error instanceof Error ? error.message : String(error);
2917
+ log(`Session tail cursor reset: ${message}`);
2918
+ restoredCursor = [];
2919
+ localSessionTail = createOpenClawLocalSessionTail({
2920
+ ...(input.profileRoots === undefined ? {} : { profileRoots: input.profileRoots }),
2921
+ emitExistingOnFirstPoll: true
2922
+ });
2923
+ persistWatchSessionTailCursor(sessionTailCursorPath, []);
2924
+ }
2925
+ let replayState = {
2926
+ replayedBundleCount: 0,
2927
+ replayedEventCount: 0
2928
+ };
2929
+ try {
2930
+ replayState = await replayWatchScanRootIntoTeacherLoop(teacherLoop, scanRoot);
2931
+ }
2932
+ catch (error) {
2933
+ const message = formatWatchError(error);
2934
+ startupWarnings.push(`teacher_replay_failed:${message}`);
2935
+ log(`Async teacher replay fail-open: ${message}`);
2936
+ }
2937
+ if (replayState.replayedBundleCount > 0) {
2938
+ log(`Replayed ${replayState.replayedBundleCount} stored export bundle${replayState.replayedBundleCount === 1 ? "" : "s"} (${replayState.replayedEventCount} event${replayState.replayedEventCount === 1 ? "" : "s"})`);
2939
+ }
2940
+ let bootstrapSnapshot = teacherLoop.snapshot();
2941
+ const replayPromotion = applyWatchMaterialization(activationRoot, bootstrapSnapshot, lastHandledMaterializationPackId);
2942
+ lastHandledMaterializationPackId = replayPromotion.lastHandledMaterializationPackId;
2943
+ if (replayPromotion.logLine !== null) {
2944
+ log(replayPromotion.logLine);
2945
+ bootstrapSnapshot = teacherLoop.snapshot();
2946
+ }
2947
+ const bootstrapCursor = localSessionTail.snapshot();
2948
+ persistWatchTeacherSnapshot(teacherSnapshotPath, {
2949
+ lastRunAt: bootstrapObservedAt,
2950
+ scanRoot,
2951
+ sessionTailCursorPath,
2952
+ sessionTailCursorUpdatedAt: bootstrapObservedAt,
2953
+ sessionTailSessionsTracked: bootstrapCursor.length,
2954
+ sessionTailBridgedEventCount: countWatchCursorBridgedEvents(bootstrapCursor),
2955
+ scannerCheckpointPath: scanner.checkpointPath,
2956
+ scannerCheckpoint: scanner.snapshot(),
2957
+ replayedBundleCount: replayState.replayedBundleCount,
2958
+ replayedEventCount: replayState.replayedEventCount,
2959
+ exportedBundleCount: 0,
2960
+ exportedEventCount: 0,
2961
+ startupWarnings,
2962
+ lastTeacherError: null,
2963
+ localSessionTailNoopReason: null,
2964
+ lastHandledMaterializationPackId,
2965
+ failure: replayPromotion.failure,
2966
+ snapshot: bootstrapSnapshot
2967
+ });
2968
+ return {
2969
+ activationRoot,
2970
+ scanRoot,
2971
+ sessionTailCursorPath,
2972
+ teacherSnapshotPath,
2973
+ startupWarnings,
2974
+ lastTeacherError: null,
2975
+ replayState,
2976
+ lastHandledMaterializationPackId,
2977
+ scanner,
2978
+ teacherLoop,
2979
+ localSessionTail
2980
+ };
2981
+ }
2982
+ export async function runWatchCommandPass(runtime, options = {}) {
2983
+ const log = options.log ?? watchLog;
2984
+ const observedAt = options.observedAt ?? new Date().toISOString();
2985
+ const localPoll = runtime.localSessionTail.pollOnce({
2986
+ observedAt
2987
+ });
2988
+ const scannerCheckpointBeforeScan = runtime.scanner.snapshot();
2989
+ const exported = exportLocalSessionTailChangesToScanRoot({
2990
+ scanRoot: runtime.scanRoot,
2991
+ polledAt: localPoll.polledAt,
2992
+ changes: localPoll.changes
2993
+ });
2994
+ persistWatchSessionTailCursor(runtime.sessionTailCursorPath, localPoll.cursor);
2995
+ for (const warning of [...localPoll.warnings, ...exported.warnings]) {
2996
+ log(`Session tail warning: ${warning}`);
2997
+ }
2998
+ if (exported.exportedBundleCount > 0) {
2999
+ log(`Session tail exported ${exported.exportedBundleCount} bundle${exported.exportedBundleCount === 1 ? "" : "s"} from ${localPoll.changes.length} changed session${localPoll.changes.length === 1 ? "" : "s"}`);
3000
+ }
3001
+ const scanResult = runtime.scanner.scanOnce({
3002
+ scannedAt: observedAt
1456
3003
  });
3004
+ const totalSelected = scanResult.selected.length;
3005
+ const totalEvents = scanResult.selected.reduce((sum, hit) => sum + hit.eventRange.count, 0);
3006
+ let snapshot = runtime.teacherLoop.snapshot();
3007
+ let materializedPackId = null;
3008
+ let failure = null;
3009
+ if (totalSelected === 0) {
3010
+ log("Scanning... no changes");
3011
+ }
3012
+ else {
3013
+ log(`Scanning... ${totalSelected} export bundle${totalSelected === 1 ? "" : "s"} selected, ${totalEvents} event${totalEvents === 1 ? "" : "s"}`);
3014
+ try {
3015
+ const ingestResult = await runtime.teacherLoop.ingestRuntimeEventExportScannerScan(scanResult);
3016
+ runtime.lastTeacherError = null;
3017
+ snapshot = ingestResult.snapshot;
3018
+ const promotion = applyWatchMaterialization(runtime.activationRoot, snapshot, runtime.lastHandledMaterializationPackId);
3019
+ runtime.lastHandledMaterializationPackId = promotion.lastHandledMaterializationPackId;
3020
+ materializedPackId = promotion.materializedPackId;
3021
+ failure = promotion.failure;
3022
+ if (promotion.logLine !== null) {
3023
+ log(promotion.logLine);
3024
+ snapshot = runtime.teacherLoop.snapshot();
3025
+ }
3026
+ }
3027
+ catch (error) {
3028
+ const message = formatWatchError(error);
3029
+ runtime.lastTeacherError = message;
3030
+ failure = {
3031
+ mode: "teacher_fail_open",
3032
+ detail: message,
3033
+ at: observedAt
3034
+ };
3035
+ log(`Async teacher fail-open: ${message}`);
3036
+ try {
3037
+ runtime.scanner.restoreCheckpoint(scannerCheckpointBeforeScan);
3038
+ }
3039
+ catch (restoreError) {
3040
+ const restoreMessage = formatWatchError(restoreError);
3041
+ runtime.lastTeacherError = `${message}; scanner checkpoint restore failed: ${restoreMessage}`;
3042
+ failure = {
3043
+ mode: "teacher_fail_open",
3044
+ detail: runtime.lastTeacherError,
3045
+ at: observedAt
3046
+ };
3047
+ log(`Scanner checkpoint restore failed: ${restoreMessage}`);
3048
+ }
3049
+ snapshot = runtime.teacherLoop.snapshot();
3050
+ }
3051
+ }
3052
+ persistWatchTeacherSnapshot(runtime.teacherSnapshotPath, {
3053
+ lastRunAt: observedAt,
3054
+ scanRoot: runtime.scanRoot,
3055
+ sessionTailCursorPath: runtime.sessionTailCursorPath,
3056
+ sessionTailCursorUpdatedAt: observedAt,
3057
+ sessionTailSessionsTracked: localPoll.cursor.length,
3058
+ sessionTailBridgedEventCount: countWatchCursorBridgedEvents(localPoll.cursor),
3059
+ scannerCheckpointPath: runtime.scanner.checkpointPath,
3060
+ scannerCheckpoint: runtime.scanner.snapshot(),
3061
+ replayedBundleCount: runtime.replayState.replayedBundleCount,
3062
+ replayedEventCount: runtime.replayState.replayedEventCount,
3063
+ exportedBundleCount: exported.exportedBundleCount,
3064
+ exportedEventCount: exported.exportedEventCount,
3065
+ startupWarnings: runtime.startupWarnings,
3066
+ lastTeacherError: runtime.lastTeacherError,
3067
+ localSessionTailNoopReason: localPoll.noopReason,
3068
+ lastHandledMaterializationPackId: runtime.lastHandledMaterializationPackId,
3069
+ failure,
3070
+ snapshot
3071
+ });
3072
+ const persistedScannerCheckpoint = runtime.scanner.snapshot();
3073
+ if (options.json) {
3074
+ console.log(JSON.stringify({
3075
+ timestamp: observedAt,
3076
+ replayedBundles: runtime.replayState.replayedBundleCount,
3077
+ replayedEvents: runtime.replayState.replayedEventCount,
3078
+ exportedBundles: exported.exportedBundleCount,
3079
+ exportedEvents: exported.exportedEventCount,
3080
+ selected: totalSelected,
3081
+ events: totalEvents,
3082
+ live: scanResult.live.length,
3083
+ backfill: scanResult.backfill.length,
3084
+ sessionTailSessionsTracked: localPoll.cursor.length,
3085
+ sessionTailBridgedEvents: countWatchCursorBridgedEvents(localPoll.cursor),
3086
+ scannerProcessedBundles: persistedScannerCheckpoint.processedExportDigests.length,
3087
+ scannerLiveAfter: persistedScannerCheckpoint.live.after?.exportDigest ?? null,
3088
+ materialized: materializedPackId,
3089
+ diagnostics: snapshot.diagnostics ?? null,
3090
+ localSessionTailNoopReason: localPoll.noopReason
3091
+ }));
3092
+ }
3093
+ return {
3094
+ localPoll,
3095
+ exported,
3096
+ scanResult,
3097
+ snapshot,
3098
+ materializedPackId
3099
+ };
3100
+ }
3101
+ async function runWatchCommand(parsed) {
3102
+ const intervalMs = parsed.interval * 1000;
3103
+ const runtime = await createWatchCommandRuntime({
3104
+ activationRoot: parsed.activationRoot,
3105
+ scanRoot: parsed.scanRoot,
3106
+ log: watchLog
3107
+ });
3108
+ watchLog(`Interval: ${parsed.interval}s`);
1457
3109
  let stopping = false;
1458
3110
  const onSignal = () => {
1459
3111
  if (stopping) {
@@ -1466,69 +3118,15 @@ async function runWatchCommand(parsed) {
1466
3118
  process.on("SIGTERM", onSignal);
1467
3119
  while (!stopping) {
1468
3120
  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
- }
3121
+ await runWatchCommandPass(runtime, {
3122
+ json: parsed.json,
3123
+ log: watchLog
3124
+ });
1526
3125
  }
1527
3126
  catch (error) {
1528
3127
  const message = error instanceof Error ? error.message : String(error);
1529
3128
  watchLog(`Error: ${message}`);
1530
3129
  }
1531
- // Wait for the next interval, checking for stop signal periodically
1532
3130
  const deadline = Date.now() + intervalMs;
1533
3131
  while (!stopping && Date.now() < deadline) {
1534
3132
  await new Promise((resolve) => {
@@ -1590,12 +3188,13 @@ function resetActivationRoot(activationRoot) {
1590
3188
  function runResetCommand(parsed) {
1591
3189
  if (parsed.help) {
1592
3190
  console.log([
1593
- "Usage: openclawbrain reset [--activation-root <path>] [--yes] [--json]",
3191
+ "Usage: openclawbrain reset [--activation-root <path>|--openclaw-home <path>] [--yes] [--json]",
1594
3192
  "",
1595
3193
  "Wipes all learned state and returns the brain to seed state.",
1596
3194
  "",
1597
3195
  "Options:",
1598
3196
  " --activation-root <path> Activation root (auto-detected if omitted)",
3197
+ " --openclaw-home <path> Pin auto-detection to one installed OpenClaw profile",
1599
3198
  " --yes, -y Skip confirmation prompt",
1600
3199
  " --json Emit machine-readable JSON output",
1601
3200
  " --help Show this help"
@@ -1646,7 +3245,7 @@ function runResetCommand(parsed) {
1646
3245
  }
1647
3246
  console.log(" Activation pointers reset to seed state.");
1648
3247
  console.log(`\nBrain at ${shortenPath(activationRoot)} is now in seed state.`);
1649
- console.log("Run `openclawbrain status` to verify.");
3248
+ console.log(`Run \`openclawbrain status --activation-root ${quoteShellArg(activationRoot)}\` to verify.`);
1650
3249
  }
1651
3250
  return 0;
1652
3251
  }
@@ -1721,8 +3320,14 @@ export function runOperatorCli(argv = process.argv.slice(2)) {
1721
3320
  });
1722
3321
  return 0;
1723
3322
  }
1724
- if (parsed.command === "setup") {
1725
- return runSetupCommand(parsed);
3323
+ if (parsed.command === "install") {
3324
+ return runInstallCommand(parsed);
3325
+ }
3326
+ if (parsed.command === "detach") {
3327
+ return runDetachCommand(parsed);
3328
+ }
3329
+ if (parsed.command === "uninstall") {
3330
+ return runUninstallCommand(parsed);
1726
3331
  }
1727
3332
  if (parsed.command === "attach") {
1728
3333
  mkdirSync(parsed.activationRoot, { recursive: true });
@@ -1786,7 +3391,7 @@ export function runOperatorCli(argv = process.argv.slice(2)) {
1786
3391
  }
1787
3392
  // At this point only status/rollback commands remain
1788
3393
  const statusOrRollback = parsed;
1789
- const activationRoot = requireActivationRoot(statusOrRollback.input, statusOrRollback.command);
3394
+ const activationRoot = requireActivationRoot(statusOrRollback.input, statusOrRollback.openclawHome, statusOrRollback.command);
1790
3395
  if (statusOrRollback.command === "rollback") {
1791
3396
  const result = rollbackRuntimeAttach({
1792
3397
  activationRoot,
@@ -1801,18 +3406,17 @@ export function runOperatorCli(argv = process.argv.slice(2)) {
1801
3406
  }
1802
3407
  return result.allowed ? 0 : 1;
1803
3408
  }
1804
- const status = describeCurrentProfileBrainStatus({
3409
+ const operatorInput = {
1805
3410
  ...statusOrRollback.input,
1806
- activationRoot
1807
- });
3411
+ activationRoot,
3412
+ teacherSnapshotPath: resolveOperatorTeacherSnapshotPath(activationRoot, statusOrRollback.input.teacherSnapshotPath)
3413
+ };
3414
+ const status = describeCurrentProfileBrainStatus(operatorInput);
1808
3415
  if (statusOrRollback.json) {
1809
3416
  console.log(JSON.stringify(status, null, 2));
1810
3417
  }
1811
3418
  else {
1812
- const report = buildOperatorSurfaceReport({
1813
- ...statusOrRollback.input,
1814
- activationRoot
1815
- });
3419
+ const report = buildOperatorSurfaceReport(operatorInput);
1816
3420
  if (statusOrRollback.detailed) {
1817
3421
  console.log(formatCurrentProfileStatusSummary(status, report));
1818
3422
  }