opensyn 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  readFileSync,
11
11
  readdirSync,
12
12
  renameSync,
13
- realpathSync,
14
13
  rmSync,
15
14
  statSync,
16
15
  writeFileSync,
@@ -19,7 +18,7 @@ import os from "node:os";
19
18
  import path from "node:path";
20
19
  import { fileURLToPath } from "node:url";
21
20
  import { Type } from "@sinclair/typebox";
22
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
21
+ import type { OpenClawPluginApi, OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
23
22
 
24
23
  type ToolContent = { type: "text"; text: string };
25
24
 
@@ -122,6 +121,14 @@ type DistillationSettingsLike = {
122
121
  semantic_hint?: SemanticHint;
123
122
  };
124
123
 
124
+ type AuditStatsLike = {
125
+ raw_event_count?: number;
126
+ context_pack_count?: number;
127
+ task_episode_count?: number;
128
+ agent_asset_count?: number;
129
+ projects?: string[];
130
+ };
131
+
125
132
  type DistillationJobLike = {
126
133
  id?: string;
127
134
  project_root?: string;
@@ -264,7 +271,7 @@ type DistillationWorkerStatus = {
264
271
  enabled: boolean;
265
272
  running: boolean;
266
273
  pid?: number;
267
- source: "openclaw_host_model_agent";
274
+ source: "openclaw_host_model_agent" | "openclaw_host_model_service";
268
275
  project_root: string;
269
276
  state_path: string;
270
277
  log_path: string;
@@ -411,37 +418,20 @@ function ensureProjectRoot(projectRoot: string): void {
411
418
  }
412
419
 
413
420
  function defaultDbPath(): string {
414
- const home = process.env.HOME || os.homedir();
421
+ const home = os.homedir();
415
422
  return path.join(home, ".opensyn", "opensyn.db");
416
423
  }
417
424
 
418
425
  function defaultRuntimeRoot(): string {
419
- const home = process.env.HOME || os.homedir();
426
+ const home = os.homedir();
420
427
  return path.join(home, ".opensyn");
421
428
  }
422
429
 
423
430
  function defaultShell(): string {
424
- return path.basename(process.env.SHELL || "bash") || "bash";
431
+ return "bash";
425
432
  }
426
433
 
427
434
  function defaultOpenClawBin(): string {
428
- if (process.env.OPENCLAW_BIN) {
429
- return process.env.OPENCLAW_BIN;
430
- }
431
- const pathEnv = process.env.PATH || "";
432
- for (const entry of pathEnv.split(path.delimiter)) {
433
- if (!entry) {
434
- continue;
435
- }
436
- const candidate = path.join(entry, "openclaw");
437
- if (existsSync(candidate)) {
438
- try {
439
- return realpathSync(candidate);
440
- } catch {
441
- return candidate;
442
- }
443
- }
444
- }
445
435
  return "openclaw";
446
436
  }
447
437
 
@@ -453,7 +443,7 @@ function stableNodeRunner(): string {
453
443
  }
454
444
 
455
445
  function defaultProjectionBundleRoot(): string {
456
- const home = process.env.HOME || os.homedir();
446
+ const home = os.homedir();
457
447
  return path.join(home, ".openclaw", "workspace");
458
448
  }
459
449
 
@@ -505,7 +495,6 @@ function resolveOpenSynConfig(
505
495
  const managedRuntimeDir = ensureRuntimeRootAssets(runtimeRoot);
506
496
  const daemonBin = resolvePreferredPath([
507
497
  config.daemonBin,
508
- process.env.OPENSYN_DAEMON_BIN,
509
498
  path.join(managedRuntimeDir, "opensyn-daemon"),
510
499
  packageRuntimePath("opensyn-daemon"),
511
500
  repoFallbackPath("target", "debug", "synapse-daemon"),
@@ -514,7 +503,6 @@ function resolveOpenSynConfig(
514
503
 
515
504
  const cliBin = resolvePreferredPath([
516
505
  config.cliBin,
517
- process.env.OPENSYN_CLI_BIN,
518
506
  path.join(managedRuntimeDir, "opensyn-cli"),
519
507
  packageRuntimePath("opensyn-cli"),
520
508
  repoFallbackPath("target", "debug", "synapse-cli"),
@@ -588,6 +576,37 @@ function distillationWorkerLogPath(
588
576
  return path.join(backgroundWatchDir(config, projectRoot), "host-distiller.log");
589
577
  }
590
578
 
579
+ function appendLogLine(logPath: string, message: string): void {
580
+ mkdirSync(path.dirname(logPath), { recursive: true });
581
+ writeFileSync(logPath, `${message.trimEnd()}\n`, { encoding: "utf8", flag: "a" });
582
+ }
583
+
584
+ function updateJsonStateFile(statePath: string, payload: Record<string, unknown>): void {
585
+ const previous = readJsonFile<Record<string, unknown>>(statePath) ?? {};
586
+ mkdirSync(path.dirname(statePath), { recursive: true });
587
+ writeFileSync(
588
+ statePath,
589
+ `${JSON.stringify({ ...previous, ...payload }, null, 2)}\n`,
590
+ "utf8",
591
+ );
592
+ }
593
+
594
+ function parseIsoTimestamp(value: unknown): number | null {
595
+ if (typeof value !== "string" || !value.trim()) {
596
+ return null;
597
+ }
598
+ const parsed = Date.parse(value);
599
+ return Number.isFinite(parsed) ? parsed : null;
600
+ }
601
+
602
+ function isRecentIsoTimestamp(value: unknown, maxAgeMs: number): boolean {
603
+ const parsed = parseIsoTimestamp(value);
604
+ if (parsed === null) {
605
+ return false;
606
+ }
607
+ return Date.now() - parsed <= maxAgeMs;
608
+ }
609
+
591
610
  function isPidAlive(pid?: number): boolean {
592
611
  if (!pid || pid <= 0) {
593
612
  return false;
@@ -709,23 +728,45 @@ function getDistillationWorkerStatus(
709
728
  const logPath = distillationWorkerLogPath(config, projectRoot);
710
729
  const state = readJsonFile<{
711
730
  pid?: number;
731
+ manager_pid?: number;
732
+ runtime?: string;
733
+ service_heartbeat_at?: string;
734
+ interval_secs?: number;
712
735
  last_exit_code?: number | null;
713
736
  last_error?: string;
714
737
  last_finished_at?: string;
715
738
  }>(statePath);
716
- const pid = state?.pid;
739
+ const runtime =
740
+ state?.runtime === "openclaw_plugin_service"
741
+ ? state.runtime
742
+ : state?.runtime === "legacy" || typeof state?.pid === "number"
743
+ ? "legacy"
744
+ : "openclaw_plugin_service";
745
+ const pid = runtime === "openclaw_plugin_service" ? state?.manager_pid : state?.pid;
746
+ const intervalSecs =
747
+ typeof state?.interval_secs === "number" && Number.isFinite(state.interval_secs)
748
+ ? Math.max(5, Math.trunc(state.interval_secs))
749
+ : config.distillationWorkerIntervalSecs;
750
+ const serviceHealthy =
751
+ runtime === "openclaw_plugin_service" &&
752
+ isRecentIsoTimestamp(state?.service_heartbeat_at, Math.max(30_000, intervalSecs * 3_000));
717
753
  return {
718
754
  supported: true,
719
755
  enabled: config.distillationWorkerEnabled,
720
- running: config.distillationWorkerEnabled && isPidAlive(pid),
756
+ running:
757
+ config.distillationWorkerEnabled &&
758
+ (runtime === "openclaw_plugin_service" ? serviceHealthy : isPidAlive(pid)),
721
759
  pid,
722
- source: "openclaw_host_model_agent",
760
+ source:
761
+ runtime === "openclaw_plugin_service"
762
+ ? "openclaw_host_model_service"
763
+ : "openclaw_host_model_agent",
723
764
  project_root: projectRoot,
724
765
  state_path: statePath,
725
766
  log_path: logPath,
726
767
  openclaw_bin: config.openclawBin,
727
768
  agent_id: config.distillationWorkerAgentId,
728
- interval_secs: config.distillationWorkerIntervalSecs,
769
+ interval_secs: intervalSecs,
729
770
  last_exit_code: state?.last_exit_code,
730
771
  last_error: state?.last_error,
731
772
  last_finished_at: state?.last_finished_at,
@@ -733,6 +774,25 @@ function getDistillationWorkerStatus(
733
774
  };
734
775
  }
735
776
 
777
+ function stopLegacyDistillationWorker(
778
+ config: ResolvedOpenSynConfig,
779
+ projectRoot: string,
780
+ ): void {
781
+ const statePath = distillationWorkerStatePath(config, projectRoot);
782
+ const state = readJsonFile<{ pid?: number; runtime?: string }>(statePath);
783
+ if (!state || state.runtime === "openclaw_plugin_service") {
784
+ return;
785
+ }
786
+ const pid = state.pid;
787
+ if (pid && isPidAlive(pid)) {
788
+ try {
789
+ process.kill(pid, "SIGTERM");
790
+ } catch {
791
+ // Ignore pid races; service mode will overwrite state below.
792
+ }
793
+ }
794
+ }
795
+
736
796
  function summarizeDistillationWorkerError(error: string): string {
737
797
  const trimmed = error.trim();
738
798
  if (!trimmed) {
@@ -790,8 +850,14 @@ function buildDistillationWorkerHealth(
790
850
 
791
851
  return {
792
852
  status: "stopped",
793
- summary: "The plugin-managed host-model distillation worker is not currently running.",
794
- action: "Run openclaw repair_opensyn to restart the worker.",
853
+ summary:
854
+ status.source === "openclaw_host_model_service"
855
+ ? "The plugin-managed host-model distillation service is not currently reporting healthy heartbeats."
856
+ : "The plugin-managed host-model distillation worker is not currently running.",
857
+ action:
858
+ status.source === "openclaw_host_model_service"
859
+ ? "Restart the OpenClaw gateway if needed, then run openclaw repair_opensyn to refresh OpenSyn state."
860
+ : "Run openclaw repair_opensyn to restart the worker.",
795
861
  log_path: status.log_path,
796
862
  last_finished_at: status.last_finished_at,
797
863
  };
@@ -906,6 +972,7 @@ function buildUserNextStep(params: {
906
972
  status: "required",
907
973
  code: "repair_distillation_worker",
908
974
  summary:
975
+ distillationWorkerHealth.summary ||
909
976
  "The plugin-managed host-model distillation worker is not healthy, so OpenSyn cannot keep refining new activity automatically.",
910
977
  action:
911
978
  distillationWorkerHealth.action ||
@@ -930,63 +997,350 @@ function buildUserNextStep(params: {
930
997
  };
931
998
  }
932
999
 
933
- async function ensureDistillationWorker(
1000
+ function buildHostDistillationPrompt(projectRoot: string): string {
1001
+ return [
1002
+ `Run exactly one OpenSyn autonomous distillation cycle for project ${projectRoot}.`,
1003
+ "Rules:",
1004
+ "- Use only the OpenClaw-configured host model already available to this local runtime.",
1005
+ "- Do not browse, do not call external network tools, and do not use any model outside OpenClaw.",
1006
+ "- First call opensyn_autonomous_tick with no arguments.",
1007
+ "- If automation_state is idle_waiting_for_activity, stop immediately.",
1008
+ "- If automation_state is distillation_work_ready, follow cycle_plan.primary_steps in order.",
1009
+ "- If the primary path cannot complete safely, follow cycle_plan.fallback_steps.",
1010
+ "- Always execute cycle_plan.resume_steps before ending when they are available.",
1011
+ "- Complete at most one queued work packet in this run.",
1012
+ "- End with one short sentence describing the outcome.",
1013
+ ].join("\n");
1014
+ }
1015
+
1016
+ function resolveOpenClawCommand(
934
1017
  config: ResolvedOpenSynConfig,
935
- projectRoot: string,
936
- ): Promise<DistillationWorkerStatus> {
937
- const status = getDistillationWorkerStatus(config, projectRoot);
938
- if (!status.enabled || status.running) {
939
- return status;
1018
+ args: string[],
1019
+ ): { command: string; commandArgs: string[] } {
1020
+ const openclawBin = config.openclawBin;
1021
+ if (openclawBin.endsWith(".mjs") || openclawBin.endsWith(".js")) {
1022
+ return {
1023
+ command: stableNodeRunner(),
1024
+ commandArgs: [openclawBin, ...args],
1025
+ };
940
1026
  }
1027
+ return {
1028
+ command: openclawBin,
1029
+ commandArgs: args,
1030
+ };
1031
+ }
941
1032
 
942
- const statePath = distillationWorkerStatePath(config, projectRoot);
943
- const logPath = distillationWorkerLogPath(config, projectRoot);
944
- mkdirSync(path.dirname(statePath), { recursive: true });
945
- const logFd = openSync(logPath, "a");
946
- const child = spawn(
947
- stableNodeRunner(),
948
- [
949
- path.join(path.dirname(config.daemonBin), "opensyn-host-distiller.mjs"),
950
- "--openclaw-bin",
951
- config.openclawBin,
952
- "--agent-id",
953
- config.distillationWorkerAgentId,
954
- "--interval-secs",
955
- String(config.distillationWorkerIntervalSecs),
956
- "--project-root",
957
- projectRoot,
958
- "--state-path",
959
- statePath,
960
- ],
961
- {
962
- detached: true,
963
- stdio: ["ignore", logFd, logFd],
964
- env: process.env,
965
- },
966
- );
967
- child.unref();
968
- closeSync(logFd);
1033
+ async function runSpawnedCommand(params: {
1034
+ command: string;
1035
+ args: string[];
1036
+ cwd?: string;
1037
+ timeoutMs: number;
1038
+ onSpawn?: (pid?: number) => void;
1039
+ }): Promise<SpawnResponse> {
1040
+ return new Promise((resolve, reject) => {
1041
+ const child = spawn(params.command, params.args, {
1042
+ cwd: params.cwd,
1043
+ stdio: ["ignore", "pipe", "pipe"],
1044
+ });
969
1045
 
970
- writeFileSync(
971
- statePath,
972
- `${JSON.stringify(
973
- {
974
- pid: child.pid,
975
- source: "openclaw_host_model_agent",
1046
+ params.onSpawn?.(child.pid);
1047
+
1048
+ let stdout = "";
1049
+ let stderr = "";
1050
+ let settled = false;
1051
+ const timeout = setTimeout(() => {
1052
+ if (settled) {
1053
+ return;
1054
+ }
1055
+ settled = true;
1056
+ child.kill("SIGTERM");
1057
+ reject(new Error(`${path.basename(params.command)} timed out after ${params.timeoutMs}ms`));
1058
+ }, params.timeoutMs);
1059
+
1060
+ function finish(fn: () => void) {
1061
+ if (settled) {
1062
+ return;
1063
+ }
1064
+ settled = true;
1065
+ clearTimeout(timeout);
1066
+ fn();
1067
+ }
1068
+
1069
+ child.stdout.on("data", (chunk) => {
1070
+ stdout += chunk.toString();
1071
+ });
1072
+ child.stderr.on("data", (chunk) => {
1073
+ stderr += chunk.toString();
1074
+ });
1075
+ child.on("error", (error) => {
1076
+ finish(() => reject(error));
1077
+ });
1078
+ child.on("close", (code) => {
1079
+ finish(() =>
1080
+ resolve({
1081
+ stdout,
1082
+ stderr,
1083
+ exitCode: code,
1084
+ }),
1085
+ );
1086
+ });
1087
+ });
1088
+ }
1089
+
1090
+ function deriveDistillationProjectRoot(
1091
+ config: ResolvedOpenSynConfig,
1092
+ ctx: OpenClawPluginServiceContext,
1093
+ ): string | null {
1094
+ const projectRoot = config.defaultProjectRoot || ctx.workspaceDir;
1095
+ return projectRoot ? path.resolve(projectRoot) : null;
1096
+ }
1097
+
1098
+ function createOpenSynDistillationService(api: OpenClawPluginApi) {
1099
+ let timer: NodeJS.Timeout | null = null;
1100
+ let currentChildPid: number | undefined;
1101
+ let runningCycle = false;
1102
+ let lastCycleStartedAt = 0;
1103
+ let lifecycleRevision = 0;
1104
+
1105
+ async function runCycle(
1106
+ ctx: OpenClawPluginServiceContext,
1107
+ config: ResolvedOpenSynConfig,
1108
+ projectRoot: string,
1109
+ ): Promise<void> {
1110
+ if (runningCycle) {
1111
+ return;
1112
+ }
1113
+
1114
+ runningCycle = true;
1115
+ const statePath = distillationWorkerStatePath(config, projectRoot);
1116
+ const logPath = distillationWorkerLogPath(config, projectRoot);
1117
+ const startedAt = new Date().toISOString();
1118
+
1119
+ try {
1120
+ stopLegacyDistillationWorker(config, projectRoot);
1121
+ updateJsonStateFile(statePath, {
1122
+ runtime: "openclaw_plugin_service",
1123
+ manager_pid: process.pid,
976
1124
  project_root: projectRoot,
1125
+ source: "openclaw_host_model_service",
977
1126
  openclaw_bin: config.openclawBin,
978
1127
  agent_id: config.distillationWorkerAgentId,
979
1128
  interval_secs: config.distillationWorkerIntervalSecs,
980
- started_at: new Date().toISOString(),
1129
+ state_path: statePath,
981
1130
  log_path: logPath,
982
- last_exit_code: null,
983
- last_error: "",
984
- },
985
- null,
986
- 2,
987
- )}\n`,
988
- "utf8",
989
- );
1131
+ service_started_at: startedAt,
1132
+ service_heartbeat_at: startedAt,
1133
+ running: true,
1134
+ });
1135
+
1136
+ if (!config.distillationWorkerEnabled) {
1137
+ updateJsonStateFile(statePath, {
1138
+ service_heartbeat_at: new Date().toISOString(),
1139
+ last_error: "",
1140
+ });
1141
+ return;
1142
+ }
1143
+
1144
+ const prompt = buildHostDistillationPrompt(projectRoot);
1145
+ const { command, commandArgs } = resolveOpenClawCommand(config, [
1146
+ "agent",
1147
+ "--local",
1148
+ "--agent",
1149
+ config.distillationWorkerAgentId,
1150
+ "--thinking",
1151
+ "minimal",
1152
+ "--timeout",
1153
+ "120",
1154
+ "--message",
1155
+ prompt,
1156
+ "--json",
1157
+ ]);
1158
+
1159
+ const result = await runSpawnedCommand({
1160
+ command,
1161
+ args: commandArgs,
1162
+ cwd: projectRoot,
1163
+ timeoutMs: Math.max(config.daemonTimeoutMs, 120_000),
1164
+ onSpawn(pid) {
1165
+ currentChildPid = pid;
1166
+ updateJsonStateFile(statePath, {
1167
+ current_child_pid: pid,
1168
+ last_started_at: startedAt,
1169
+ service_heartbeat_at: startedAt,
1170
+ });
1171
+ },
1172
+ });
1173
+
1174
+ const finishedAt = new Date().toISOString();
1175
+ const computedLastError =
1176
+ result.exitCode && result.exitCode !== 0
1177
+ ? result.stderr.trim() || `openclaw agent exited with ${result.exitCode}`
1178
+ : "";
1179
+ const payload = {
1180
+ runtime: "openclaw_plugin_service",
1181
+ manager_pid: process.pid,
1182
+ source: "openclaw_host_model_service",
1183
+ current_child_pid: null,
1184
+ last_started_at: startedAt,
1185
+ last_finished_at: finishedAt,
1186
+ last_exit_code: result.exitCode,
1187
+ last_error: computedLastError,
1188
+ last_stdout: result.stdout.trim(),
1189
+ last_stderr: result.stderr.trim(),
1190
+ service_heartbeat_at: finishedAt,
1191
+ };
1192
+ updateJsonStateFile(statePath, payload);
1193
+ appendLogLine(
1194
+ logPath,
1195
+ JSON.stringify({
1196
+ started_at: startedAt,
1197
+ finished_at: finishedAt,
1198
+ command,
1199
+ command_args: commandArgs,
1200
+ exit_code: result.exitCode,
1201
+ error: computedLastError,
1202
+ stdout: result.stdout.trim(),
1203
+ stderr: result.stderr.trim(),
1204
+ }),
1205
+ );
1206
+ ctx.logger.info(
1207
+ computedLastError
1208
+ ? `opensyn distillation cycle finished with error: ${summarizeDistillationWorkerError(computedLastError)}`
1209
+ : "opensyn distillation cycle completed",
1210
+ );
1211
+ } catch (error) {
1212
+ const finishedAt = new Date().toISOString();
1213
+ const message = error instanceof Error ? error.message : String(error);
1214
+ updateJsonStateFile(statePath, {
1215
+ runtime: "openclaw_plugin_service",
1216
+ manager_pid: process.pid,
1217
+ source: "openclaw_host_model_service",
1218
+ current_child_pid: null,
1219
+ last_started_at: startedAt,
1220
+ last_finished_at: finishedAt,
1221
+ last_error: message,
1222
+ service_heartbeat_at: finishedAt,
1223
+ });
1224
+ appendLogLine(
1225
+ logPath,
1226
+ JSON.stringify({
1227
+ started_at: startedAt,
1228
+ finished_at: finishedAt,
1229
+ error: message,
1230
+ }),
1231
+ );
1232
+ ctx.logger.warn(`opensyn distillation cycle failed: ${message}`);
1233
+ } finally {
1234
+ currentChildPid = undefined;
1235
+ runningCycle = false;
1236
+ }
1237
+ }
1238
+
1239
+ async function tick(ctx: OpenClawPluginServiceContext): Promise<void> {
1240
+ const config = getResolvedPluginConfig(api, {
1241
+ defaultProjectRoot: ctx.workspaceDir,
1242
+ });
1243
+ const projectRoot = deriveDistillationProjectRoot(config, ctx);
1244
+ if (!projectRoot) {
1245
+ return;
1246
+ }
1247
+
1248
+ const intervalMs = Math.max(5, config.distillationWorkerIntervalSecs) * 1000;
1249
+ if (Date.now() - lastCycleStartedAt < intervalMs) {
1250
+ const statePath = distillationWorkerStatePath(config, projectRoot);
1251
+ updateJsonStateFile(statePath, {
1252
+ runtime: "openclaw_plugin_service",
1253
+ manager_pid: process.pid,
1254
+ source: "openclaw_host_model_service",
1255
+ project_root: projectRoot,
1256
+ interval_secs: config.distillationWorkerIntervalSecs,
1257
+ service_heartbeat_at: new Date().toISOString(),
1258
+ });
1259
+ return;
1260
+ }
1261
+
1262
+ lastCycleStartedAt = Date.now();
1263
+ await runCycle(ctx, config, projectRoot);
1264
+ }
1265
+
1266
+ return {
1267
+ id: "opensyn-distillation-service",
1268
+ async start(ctx: OpenClawPluginServiceContext): Promise<void> {
1269
+ lifecycleRevision += 1;
1270
+ const currentRevision = lifecycleRevision;
1271
+ const config = getResolvedPluginConfig(api, {
1272
+ defaultProjectRoot: ctx.workspaceDir,
1273
+ });
1274
+ const projectRoot = deriveDistillationProjectRoot(config, ctx);
1275
+ if (projectRoot) {
1276
+ stopLegacyDistillationWorker(config, projectRoot);
1277
+ updateJsonStateFile(distillationWorkerStatePath(config, projectRoot), {
1278
+ runtime: "openclaw_plugin_service",
1279
+ manager_pid: process.pid,
1280
+ source: "openclaw_host_model_service",
1281
+ project_root: projectRoot,
1282
+ openclaw_bin: config.openclawBin,
1283
+ agent_id: config.distillationWorkerAgentId,
1284
+ interval_secs: config.distillationWorkerIntervalSecs,
1285
+ state_path: distillationWorkerStatePath(config, projectRoot),
1286
+ log_path: distillationWorkerLogPath(config, projectRoot),
1287
+ service_started_at: new Date().toISOString(),
1288
+ service_heartbeat_at: new Date().toISOString(),
1289
+ running: true,
1290
+ last_error: "",
1291
+ });
1292
+ }
1293
+
1294
+ void tick(ctx);
1295
+ timer = setInterval(() => {
1296
+ if (currentRevision !== lifecycleRevision) {
1297
+ return;
1298
+ }
1299
+ void tick(ctx);
1300
+ }, 5_000);
1301
+ timer.unref?.();
1302
+ ctx.logger.info("opensyn distillation service started");
1303
+ },
1304
+ async stop(ctx: OpenClawPluginServiceContext): Promise<void> {
1305
+ lifecycleRevision += 1;
1306
+ if (timer) {
1307
+ clearInterval(timer);
1308
+ timer = null;
1309
+ }
1310
+ if (currentChildPid && isPidAlive(currentChildPid)) {
1311
+ try {
1312
+ process.kill(currentChildPid, "SIGTERM");
1313
+ } catch {
1314
+ // Ignore pid races during shutdown.
1315
+ }
1316
+ }
1317
+ const config = getResolvedPluginConfig(api, {
1318
+ defaultProjectRoot: ctx.workspaceDir,
1319
+ });
1320
+ const projectRoot = deriveDistillationProjectRoot(config, ctx);
1321
+ if (projectRoot) {
1322
+ updateJsonStateFile(distillationWorkerStatePath(config, projectRoot), {
1323
+ runtime: "openclaw_plugin_service",
1324
+ manager_pid: process.pid,
1325
+ source: "openclaw_host_model_service",
1326
+ current_child_pid: null,
1327
+ service_stopped_at: new Date().toISOString(),
1328
+ service_heartbeat_at: new Date().toISOString(),
1329
+ running: false,
1330
+ });
1331
+ }
1332
+ currentChildPid = undefined;
1333
+ runningCycle = false;
1334
+ ctx.logger.info("opensyn distillation service stopped");
1335
+ },
1336
+ };
1337
+ }
1338
+
1339
+ async function ensureDistillationWorker(
1340
+ config: ResolvedOpenSynConfig,
1341
+ projectRoot: string,
1342
+ ): Promise<DistillationWorkerStatus> {
1343
+ stopLegacyDistillationWorker(config, projectRoot);
990
1344
  return getDistillationWorkerStatus(config, projectRoot);
991
1345
  }
992
1346
 
@@ -995,14 +1349,23 @@ function stopDistillationWorker(
995
1349
  projectRoot: string,
996
1350
  ): DistillationWorkerStatus {
997
1351
  const status = getDistillationWorkerStatus(config, projectRoot);
998
- if (status.running && status.pid) {
1352
+ if (status.source === "openclaw_host_model_agent" && status.running && status.pid) {
999
1353
  try {
1000
1354
  process.kill(status.pid, "SIGTERM");
1001
1355
  } catch {
1002
1356
  // Ignore pid races; the state file is still cleaned up below.
1003
1357
  }
1004
1358
  }
1005
- rmSync(status.state_path, { force: true });
1359
+ updateJsonStateFile(status.state_path, {
1360
+ runtime:
1361
+ status.source === "openclaw_host_model_service"
1362
+ ? "openclaw_plugin_service"
1363
+ : "legacy",
1364
+ running: false,
1365
+ current_child_pid: null,
1366
+ last_error: "",
1367
+ service_heartbeat_at: new Date().toISOString(),
1368
+ });
1006
1369
  return getDistillationWorkerStatus(config, projectRoot);
1007
1370
  }
1008
1371
 
@@ -1301,6 +1664,22 @@ function summarizeResult(result: unknown): string {
1301
1664
  return summarizeContextObject(result as ContextLike);
1302
1665
  }
1303
1666
 
1667
+ if (looksLikeCollectionReport(result)) {
1668
+ return summarizeCollectionReport(
1669
+ result as {
1670
+ project_root?: string;
1671
+ audit_stats?: AuditStatsLike;
1672
+ current_context?: ContextLike | null;
1673
+ recent_episodes?: ContextLike[];
1674
+ approved_assets?: AssetLike[];
1675
+ candidate_assets?: AssetLike[];
1676
+ pending_jobs?: DistillationJobLike[];
1677
+ runtime_overlay?: RuntimeOverlayLike | null;
1678
+ next_step?: UserNextStep;
1679
+ },
1680
+ );
1681
+ }
1682
+
1304
1683
  if (looksLikeAutonomousCollectionBootstrap(result)) {
1305
1684
  return summarizeAutonomousCollectionBootstrap(
1306
1685
  result as {
@@ -1372,6 +1751,10 @@ function summarizeResult(result: unknown): string {
1372
1751
  return summarizeRuntimeOverlay(result as RuntimeOverlayLike);
1373
1752
  }
1374
1753
 
1754
+ if (looksLikeAuditStats(result)) {
1755
+ return summarizeAuditStats(result as AuditStatsLike);
1756
+ }
1757
+
1375
1758
  if (looksLikeAssetDiff(result)) {
1376
1759
  return summarizeAssetDiff(result as AssetDiffLike);
1377
1760
  }
@@ -1593,6 +1976,35 @@ function summarizeAssetApprovalResult(result: {
1593
1976
  return `${base} | synced_records=${String(sync.applied_record_count ?? 0)} | host=${sync.host || "unknown"}`;
1594
1977
  }
1595
1978
 
1979
+ function summarizeAuditStats(result: AuditStatsLike): string {
1980
+ return `OpenSyn collected data | raw_events=${String(result.raw_event_count ?? 0)} | episodes=${String(result.task_episode_count ?? 0)} | context_packs=${String(result.context_pack_count ?? 0)} | assets=${String(result.agent_asset_count ?? 0)} | projects=${String(result.projects?.length ?? 0)}`;
1981
+ }
1982
+
1983
+ function summarizeCollectionReport(result: {
1984
+ project_root?: string;
1985
+ audit_stats?: AuditStatsLike;
1986
+ current_context?: ContextLike | null;
1987
+ recent_episodes?: ContextLike[];
1988
+ approved_assets?: AssetLike[];
1989
+ candidate_assets?: AssetLike[];
1990
+ pending_jobs?: DistillationJobLike[];
1991
+ runtime_overlay?: RuntimeOverlayLike | null;
1992
+ next_step?: UserNextStep;
1993
+ }): string {
1994
+ const rawEvents = result.audit_stats?.raw_event_count ?? 0;
1995
+ const episodes = result.audit_stats?.task_episode_count ?? result.recent_episodes?.length ?? 0;
1996
+ const assets = result.audit_stats?.agent_asset_count ?? 0;
1997
+ const approved = result.runtime_overlay?.approved_asset_count ?? result.approved_assets?.length ?? 0;
1998
+ const pendingJobs = result.pending_jobs?.length ?? result.runtime_overlay?.pending_distillation_jobs ?? 0;
1999
+ const leadContext =
2000
+ result.current_context?.title ||
2001
+ result.current_context?.summary ||
2002
+ result.current_context?.semantic_hint?.activity_kind ||
2003
+ "none";
2004
+ const nextStep = result.next_step?.code || "none";
2005
+ return `OpenSyn report | project=${result.project_root || "unknown"} | raw_events=${String(rawEvents)} | episodes=${String(episodes)} | assets=${String(assets)} | approved_assets=${String(approved)} | pending_jobs=${String(pendingJobs)} | current=${leadContext} | next=${nextStep}`;
2006
+ }
2007
+
1596
2008
  function extractNextStep(result: unknown): UserNextStep | undefined {
1597
2009
  if (typeof result !== "object" || result === null) {
1598
2010
  return undefined;
@@ -1732,6 +2144,24 @@ function looksLikeRuntimeOverlay(result: unknown): boolean {
1732
2144
  );
1733
2145
  }
1734
2146
 
2147
+ function looksLikeAuditStats(result: unknown): boolean {
2148
+ return (
2149
+ typeof result === "object" &&
2150
+ result !== null &&
2151
+ ("raw_event_count" in result || "context_pack_count" in result || "task_episode_count" in result)
2152
+ );
2153
+ }
2154
+
2155
+ function looksLikeCollectionReport(result: unknown): boolean {
2156
+ return (
2157
+ typeof result === "object" &&
2158
+ result !== null &&
2159
+ "audit_stats" in result &&
2160
+ "current_context" in result &&
2161
+ "runtime_overlay" in result
2162
+ );
2163
+ }
2164
+
1735
2165
  function looksLikeAutonomousCollectionBootstrap(result: unknown): boolean {
1736
2166
  return (
1737
2167
  typeof result === "object" &&
@@ -2105,6 +2535,74 @@ async function repairAutonomousCollection(
2105
2535
  };
2106
2536
  }
2107
2537
 
2538
+ async function getOpenSynCollectionReport(
2539
+ config: ResolvedOpenSynConfig,
2540
+ params: { project_root: string; shell?: string },
2541
+ ) {
2542
+ const status = await getAutonomousCollectionStatus(config, params);
2543
+ const auditStats = await callDaemon(config, "list_collected_event_stats", {});
2544
+ const currentContext = await callDaemon(config, "synthesize_recent_activity", {
2545
+ project_root: params.project_root,
2546
+ window_secs: config.defaultWindowSecs ?? 300,
2547
+ limit: 512,
2548
+ });
2549
+ const recentEpisodes = await callDaemon(config, "get_recent_task_episodes", {
2550
+ project_root: params.project_root,
2551
+ limit: 5,
2552
+ });
2553
+ const approvedAssets = await callDaemon(config, "list_agent_assets", {
2554
+ project_root: params.project_root,
2555
+ status: "approved",
2556
+ limit: 5,
2557
+ });
2558
+ const candidateAssets = await callDaemon(config, "list_agent_assets", {
2559
+ project_root: params.project_root,
2560
+ status: "candidate",
2561
+ limit: 5,
2562
+ });
2563
+ const pendingJobs = await callDaemon(config, "list_distillation_jobs", {
2564
+ project_root: params.project_root,
2565
+ status: "pending",
2566
+ limit: 5,
2567
+ });
2568
+ const runtimeOverlay = await callDaemon(config, "get_agent_runtime_overlay", {
2569
+ project_root: params.project_root,
2570
+ host: config.projectionHost || "openclaw",
2571
+ });
2572
+
2573
+ return {
2574
+ semantic_hint: {
2575
+ activity_kind: "collection_report",
2576
+ status_label: "inspected",
2577
+ operator_focus:
2578
+ "Review what OpenSyn has collected, the latest current context, asset distillation results, and pending host-model work.",
2579
+ },
2580
+ project_root: params.project_root,
2581
+ status,
2582
+ audit_stats: auditStats,
2583
+ current_context: currentContext,
2584
+ recent_episodes: recentEpisodes,
2585
+ approved_assets: approvedAssets,
2586
+ candidate_assets: candidateAssets,
2587
+ pending_jobs: pendingJobs,
2588
+ runtime_overlay: runtimeOverlay,
2589
+ next_step:
2590
+ extractNextStep(status) ||
2591
+ buildUserNextStep({
2592
+ collectionMode:
2593
+ (status as { collection_settings?: CollectionSettingsLike })?.collection_settings
2594
+ ?.mode,
2595
+ backgroundWatch:
2596
+ (status as { background_watch?: BackgroundWatchStatus }).background_watch,
2597
+ shellCaptureHealth:
2598
+ (status as { shell_capture_health?: ShellCaptureHealth }).shell_capture_health,
2599
+ distillationWorkerHealth:
2600
+ (status as { distillation_worker_health?: DistillationWorkerHealth })
2601
+ .distillation_worker_health,
2602
+ }),
2603
+ };
2604
+ }
2605
+
2108
2606
  function printCliResult(result: unknown, jsonOutput = false): void {
2109
2607
  const summary = summarizeResult(result);
2110
2608
  if (summary) {
@@ -2130,6 +2628,8 @@ export default {
2130
2628
  register(api: OpenClawPluginApi) {
2131
2629
  const config = getResolvedPluginConfig(api);
2132
2630
 
2631
+ api.registerService(createOpenSynDistillationService(api));
2632
+
2133
2633
  api.registerCli(
2134
2634
  ({ program, workspaceDir }) => {
2135
2635
  const resolveCliProjectRoot = (input?: string): string =>
@@ -2155,6 +2655,99 @@ export default {
2155
2655
  return persisted;
2156
2656
  };
2157
2657
 
2658
+ program
2659
+ .command("report_opensyn")
2660
+ .description(
2661
+ "Show an OpenSyn collection report for the current workspace, including captured data counts, current context, asset previews, and pending distillation jobs.",
2662
+ )
2663
+ .argument("[project_root]", "Optional project root. Defaults to the current OpenClaw workspace.")
2664
+ .option("--shell <shell>", "Shell to inspect for command capture.")
2665
+ .option("--json", "Print the full JSON result.")
2666
+ .action(async (projectRootArg?: string, opts?: { shell?: string; json?: boolean }) => {
2667
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2668
+ const resolved = await resolveCliConfig(projectRoot);
2669
+ const result = await getOpenSynCollectionReport(resolved, {
2670
+ project_root: projectRoot,
2671
+ shell: opts?.shell,
2672
+ });
2673
+ printCliResult(result, opts?.json);
2674
+ });
2675
+
2676
+ program
2677
+ .command("context_opensyn")
2678
+ .description(
2679
+ "Show the latest synthesized OpenSyn context for the current workspace.",
2680
+ )
2681
+ .argument("[project_root]", "Optional project root. Defaults to the current OpenClaw workspace.")
2682
+ .option("--json", "Print the full JSON result.")
2683
+ .action(async (projectRootArg?: string, opts?: { json?: boolean }) => {
2684
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2685
+ const resolved = await resolveCliConfig(projectRoot);
2686
+ const result = await callDaemon(resolved, "synthesize_recent_activity", {
2687
+ project_root: projectRoot,
2688
+ window_secs: resolved.defaultWindowSecs ?? 300,
2689
+ limit: 512,
2690
+ });
2691
+ printCliResult(result, opts?.json);
2692
+ });
2693
+
2694
+ program
2695
+ .command("assets_opensyn")
2696
+ .description(
2697
+ "List recent OpenSyn agent assets for the current workspace.",
2698
+ )
2699
+ .argument("[project_root]", "Optional project root. Defaults to the current OpenClaw workspace.")
2700
+ .option("--status <status>", "Optional asset status filter.")
2701
+ .option("--kind <kind>", "Optional asset kind filter.")
2702
+ .option("--limit <limit>", "Maximum assets to show.", (value) => Number.parseInt(value, 10))
2703
+ .option("--json", "Print the full JSON result.")
2704
+ .action(
2705
+ async (
2706
+ projectRootArg?: string,
2707
+ opts?: { status?: string; kind?: string; limit?: number; json?: boolean },
2708
+ ) => {
2709
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2710
+ const resolved = await resolveCliConfig(projectRoot);
2711
+ const result = await callDaemon(resolved, "list_agent_assets", {
2712
+ project_root: projectRoot,
2713
+ status: opts?.status,
2714
+ kind: opts?.kind,
2715
+ limit: opts?.limit ?? resolved.defaultSearchLimit ?? 10,
2716
+ });
2717
+ printCliResult(result, opts?.json);
2718
+ },
2719
+ );
2720
+
2721
+ program
2722
+ .command("stats_opensyn")
2723
+ .description(
2724
+ "Show collected OpenSyn data counts for the current workspace.",
2725
+ )
2726
+ .option("--json", "Print the full JSON result.")
2727
+ .action(async (opts?: { json?: boolean }) => {
2728
+ const resolved = await resolveCliConfig(resolveCliProjectRoot());
2729
+ const result = await callDaemon(resolved, "list_collected_event_stats", {});
2730
+ printCliResult(result, opts?.json);
2731
+ });
2732
+
2733
+ program
2734
+ .command("episodes_opensyn")
2735
+ .description(
2736
+ "List recent OpenSyn task episodes for the current workspace.",
2737
+ )
2738
+ .argument("[project_root]", "Optional project root. Defaults to the current OpenClaw workspace.")
2739
+ .option("--limit <limit>", "Maximum episodes to show.", (value) => Number.parseInt(value, 10))
2740
+ .option("--json", "Print the full JSON result.")
2741
+ .action(async (projectRootArg?: string, opts?: { limit?: number; json?: boolean }) => {
2742
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2743
+ const resolved = await resolveCliConfig(projectRoot);
2744
+ const result = await callDaemon(resolved, "get_recent_task_episodes", {
2745
+ project_root: projectRoot,
2746
+ limit: opts?.limit ?? resolved.defaultSearchLimit ?? 10,
2747
+ });
2748
+ printCliResult(result, opts?.json);
2749
+ });
2750
+
2158
2751
  program
2159
2752
  .command("enable_opensyn")
2160
2753
  .description(
@@ -2272,6 +2865,89 @@ export default {
2272
2865
  .command("opensyn")
2273
2866
  .description("OpenSyn management commands.");
2274
2867
 
2868
+ opensyn
2869
+ .command("report")
2870
+ .description("Alias for openclaw report_opensyn.")
2871
+ .argument("[project_root]")
2872
+ .option("--shell <shell>")
2873
+ .option("--json", "Print the full JSON result.")
2874
+ .action(async (projectRootArg?: string, opts?: { shell?: string; json?: boolean }) => {
2875
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2876
+ const resolved = await resolveCliConfig(projectRoot);
2877
+ const result = await getOpenSynCollectionReport(resolved, {
2878
+ project_root: projectRoot,
2879
+ shell: opts?.shell,
2880
+ });
2881
+ printCliResult(result, opts?.json);
2882
+ });
2883
+
2884
+ opensyn
2885
+ .command("context")
2886
+ .description("Alias for openclaw context_opensyn.")
2887
+ .argument("[project_root]")
2888
+ .option("--json", "Print the full JSON result.")
2889
+ .action(async (projectRootArg?: string, opts?: { json?: boolean }) => {
2890
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2891
+ const resolved = await resolveCliConfig(projectRoot);
2892
+ const result = await callDaemon(resolved, "synthesize_recent_activity", {
2893
+ project_root: projectRoot,
2894
+ window_secs: resolved.defaultWindowSecs ?? 300,
2895
+ limit: 512,
2896
+ });
2897
+ printCliResult(result, opts?.json);
2898
+ });
2899
+
2900
+ opensyn
2901
+ .command("assets")
2902
+ .description("Alias for openclaw assets_opensyn.")
2903
+ .argument("[project_root]")
2904
+ .option("--status <status>")
2905
+ .option("--kind <kind>")
2906
+ .option("--limit <limit>", "Maximum assets to show.", (value) => Number.parseInt(value, 10))
2907
+ .option("--json", "Print the full JSON result.")
2908
+ .action(
2909
+ async (
2910
+ projectRootArg?: string,
2911
+ opts?: { status?: string; kind?: string; limit?: number; json?: boolean },
2912
+ ) => {
2913
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2914
+ const resolved = await resolveCliConfig(projectRoot);
2915
+ const result = await callDaemon(resolved, "list_agent_assets", {
2916
+ project_root: projectRoot,
2917
+ status: opts?.status,
2918
+ kind: opts?.kind,
2919
+ limit: opts?.limit ?? resolved.defaultSearchLimit ?? 10,
2920
+ });
2921
+ printCliResult(result, opts?.json);
2922
+ },
2923
+ );
2924
+
2925
+ opensyn
2926
+ .command("stats")
2927
+ .description("Alias for openclaw stats_opensyn.")
2928
+ .option("--json", "Print the full JSON result.")
2929
+ .action(async (opts?: { json?: boolean }) => {
2930
+ const resolved = await resolveCliConfig(resolveCliProjectRoot());
2931
+ const result = await callDaemon(resolved, "list_collected_event_stats", {});
2932
+ printCliResult(result, opts?.json);
2933
+ });
2934
+
2935
+ opensyn
2936
+ .command("episodes")
2937
+ .description("Alias for openclaw episodes_opensyn.")
2938
+ .argument("[project_root]")
2939
+ .option("--limit <limit>", "Maximum episodes to show.", (value) => Number.parseInt(value, 10))
2940
+ .option("--json", "Print the full JSON result.")
2941
+ .action(async (projectRootArg?: string, opts?: { limit?: number; json?: boolean }) => {
2942
+ const projectRoot = resolveCliProjectRoot(projectRootArg);
2943
+ const resolved = await resolveCliConfig(projectRoot);
2944
+ const result = await callDaemon(resolved, "get_recent_task_episodes", {
2945
+ project_root: projectRoot,
2946
+ limit: opts?.limit ?? resolved.defaultSearchLimit ?? 10,
2947
+ });
2948
+ printCliResult(result, opts?.json);
2949
+ });
2950
+
2275
2951
  opensyn
2276
2952
  .command("enable")
2277
2953
  .description("Alias for openclaw enable_opensyn.")
@@ -2356,6 +3032,11 @@ export default {
2356
3032
  },
2357
3033
  {
2358
3034
  commands: [
3035
+ "report_opensyn",
3036
+ "context_opensyn",
3037
+ "assets_opensyn",
3038
+ "stats_opensyn",
3039
+ "episodes_opensyn",
2359
3040
  "enable_opensyn",
2360
3041
  "disable_opensyn",
2361
3042
  "status_opensyn",
@@ -2367,6 +3048,54 @@ export default {
2367
3048
  },
2368
3049
  );
2369
3050
 
3051
+ api.registerTool(
3052
+ {
3053
+ name: "opensyn_collection_report",
3054
+ description:
3055
+ "Return an aggregated OpenSyn report for the current project: collection health, captured data counts, current context, recent episodes, asset previews, runtime overlay, and pending distillation jobs.",
3056
+ parameters: Type.Object({
3057
+ project_root: Type.Optional(
3058
+ Type.String({
3059
+ description:
3060
+ "Optional absolute project root. Leave unset to use defaultProjectRoot from plugin config.",
3061
+ }),
3062
+ ),
3063
+ shell: Type.Optional(
3064
+ Type.String({
3065
+ description:
3066
+ "Shell to inspect for persistent command collection, for example bash or zsh.",
3067
+ }),
3068
+ ),
3069
+ }),
3070
+ async execute(_id, params: { project_root?: string; shell?: string }) {
3071
+ const projectRoot = requireProjectRoot(params, config);
3072
+ if (!projectRoot) {
3073
+ throw new Error("project_root is required unless defaultProjectRoot is configured");
3074
+ }
3075
+ const result = await getOpenSynCollectionReport(config, {
3076
+ project_root: projectRoot,
3077
+ shell: params.shell,
3078
+ });
3079
+ return asText(result);
3080
+ },
3081
+ },
3082
+ { optional: true },
3083
+ );
3084
+
3085
+ api.registerTool(
3086
+ {
3087
+ name: "opensyn_collected_stats",
3088
+ description:
3089
+ "Return raw OpenSyn collection counts such as raw events, task episodes, context packs, and stored assets.",
3090
+ parameters: Type.Object({}),
3091
+ async execute() {
3092
+ const result = await callDaemon(config, "list_collected_event_stats", {});
3093
+ return asText(result);
3094
+ },
3095
+ },
3096
+ { optional: true },
3097
+ );
3098
+
2370
3099
  api.registerTool(
2371
3100
  {
2372
3101
  name: "opensyn_recent_failure",
@@ -2489,6 +3218,35 @@ export default {
2489
3218
  { optional: true },
2490
3219
  );
2491
3220
 
3221
+ api.registerTool(
3222
+ {
3223
+ name: "opensyn_recent_episodes",
3224
+ description:
3225
+ "Return recent OpenSyn task episodes for the current project so the host can inspect what was just collected.",
3226
+ parameters: Type.Object({
3227
+ project_root: Type.Optional(
3228
+ Type.String({
3229
+ description:
3230
+ "Optional absolute project root. Leave unset to use defaultProjectRoot from plugin config.",
3231
+ }),
3232
+ ),
3233
+ limit: Type.Optional(Type.Integer({ minimum: 1 })),
3234
+ }),
3235
+ async execute(_id, params: { project_root?: string; limit?: number }) {
3236
+ const projectRoot = requireProjectRoot(params, config);
3237
+ if (!projectRoot) {
3238
+ throw new Error("project_root is required unless defaultProjectRoot is configured");
3239
+ }
3240
+ const result = await callDaemon(config, "get_recent_task_episodes", {
3241
+ project_root: projectRoot,
3242
+ limit: params.limit ?? config.defaultSearchLimit ?? 10,
3243
+ });
3244
+ return asText(result);
3245
+ },
3246
+ },
3247
+ { optional: true },
3248
+ );
3249
+
2492
3250
  api.registerTool(
2493
3251
  {
2494
3252
  name: "opensyn_distill_assets",