@haaaiawd/second-nature 0.1.32 → 0.1.33

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.js CHANGED
@@ -71,7 +71,7 @@ process.stderr.write("[second-nature] module evaluated\n");
71
71
  const INTERNAL_RUNTIME_TRACE_PREFIX = "sn-runtime-";
72
72
  const HOST_SAFE_LIMITATION_MESSAGE = "Host-safe plugin package keeps synchronous register/load semantics, but mutating workspace runtime flows remain unavailable here.";
73
73
  const SETUP_MARKER_RELATIVE_PATH = path.join(".second-nature", "setup", "agent-inner-guide-ack.json");
74
- const SETUP_GUIDE_VERSION = "0.1.32";
74
+ const SETUP_GUIDE_VERSION = "0.1.33";
75
75
  const SETUP_COMMANDS = new Set(["setup_hint", "setup_ack"]);
76
76
  let activationSpine = null;
77
77
  /** T1.1.4 — lazily opened full read bridge; closed when workspace root / resolution changes. */
@@ -111,6 +111,7 @@ const WORKSPACE_BRIDGE_COMMANDS = new Set([
111
111
  "self_health",
112
112
  "tool_affordance",
113
113
  "heartbeat_digest",
114
+ "snapshot:capture",
114
115
  "narrative:diff",
115
116
  "timeline",
116
117
  "restore",
@@ -1094,6 +1095,12 @@ function parseCommandInput(rawArgs) {
1094
1095
  command,
1095
1096
  input: rest[0] ? { date: rest[0] } : undefined,
1096
1097
  };
1098
+ case "snapshot:capture":
1099
+ return {
1100
+ ok: true,
1101
+ command,
1102
+ input: rest[0] ? { snapshotId: rest[0] } : undefined,
1103
+ };
1097
1104
  case "narrative:diff":
1098
1105
  return {
1099
1106
  ok: true,
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "second-nature",
3
3
  "name": "Second Nature",
4
- "version": "0.1.32",
5
- "description": "OpenClaw native plugin with synchronous surface registration and bundled runtime spine. Set SECOND_NATURE_WORKSPACE_ROOT or tool workspaceRoot to the same path as the agent workspace. Agent inner guide is packaged as agent-inner-guide.md. v7 ops surface: self_health, tool_affordance, heartbeat_digest, narrative:diff, timeline, restore, runtime_secret_bootstrap.",
4
+ "version": "0.1.33",
5
+ "description": "OpenClaw native plugin with synchronous surface registration and bundled runtime spine. Set SECOND_NATURE_WORKSPACE_ROOT or tool workspaceRoot to the same path as the agent workspace. Agent inner guide is packaged as agent-inner-guide.md. v7 ops surface: self_health, tool_affordance, heartbeat_digest, snapshot:capture, narrative:diff, timeline, restore, runtime_secret_bootstrap.",
6
6
  "activation": {
7
7
  "onStartup": true,
8
8
  "onCapabilities": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haaaiawd/second-nature",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "OpenClaw native plugin with synchronous registration, a packaged runtime artifact, and operator-facing status/explain flows.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -280,6 +280,7 @@ export function createCliCommands(deps) {
280
280
  opsCommand("self_health", "T-ROS.C.1 — show v7 self-health snapshot and degraded dimensions"),
281
281
  opsCommand("tool_affordance", "T-ROS.C.1 — show v7 tool affordance map or explicit unavailable state"),
282
282
  opsCommand("heartbeat_digest", "T-ROS.C.1 — assemble v7 heartbeat digest for a day"),
283
+ opsCommand("snapshot:capture", "T-V7C.C.1 — capture restore snapshot and narrative timeline version"),
283
284
  opsCommand("narrative:diff", "T-ROS.C.1 — compare two narrative timeline versions"),
284
285
  opsCommand("timeline", "T-ROS.C.1 — query v7 narrative timeline with cursor pagination"),
285
286
  opsCommand("restore", "T-ROS.C.1 — apply bounded restore and write restore audit"),
@@ -5,7 +5,9 @@
5
5
  * heartbeat_digest, narrative:diff, timeline, restore, runtime_secret_bootstrap.
6
6
  * All commands return RuntimeOpsEnvelope.
7
7
  */
8
+ import { createHash } from "node:crypto";
8
9
  import fs from "node:fs";
10
+ import path from "node:path";
9
11
  import { heartbeatCheck, } from "./heartbeat-surface.js";
10
12
  import { showOperatorFallback, OperatorFallbackNotFoundError, } from "./show-operator-fallback.js";
11
13
  import { probeHostCapability } from "../host-capability/probe-host-capability.js";
@@ -21,16 +23,137 @@ import { generateHeartbeatDigest, } from "../../observability/services/heartbeat
21
23
  import { queryNarrativeTimeline, queryNarrativeDiff, } from "../../observability/services/narrative-timeline-query-service.js";
22
24
  import { viewSecretAnchor, } from "../../observability/services/runtime-secret-anchor-view.js";
23
25
  import { writeRestoreAudit, } from "../../observability/services/restore-audit-service.js";
26
+ import { createHistoryDigestStore } from "../../storage/services/history-digest-store.js";
24
27
  // T-ROS.C.3: ManualRunDispatcher and its deps
25
28
  import { createManualRunDispatcher, } from "./manual-run-dispatcher.js";
26
29
  import { createExperienceWriter } from "../../core/second-nature/body/tool-experience/experience-writer.js";
27
- import { createToolExperienceStore } from "../../storage/services/tool-experience-store.js";
30
+ import { createCapabilityProbeResultStore, createToolExperienceStore, } from "../../storage/services/tool-experience-store.js";
28
31
  import { createWetProbeRunner } from "../../connectors/base/wet-probe-runner.js";
29
32
  import { CapabilityContractRegistryV7 } from "../../connectors/base/manifest-v7.js";
30
33
  function coerceProbeOnlyFlag(input) {
31
34
  const v = input?.probeOnly;
32
35
  return v === true || v === "true" || v === 1 || v === "1";
33
36
  }
37
+ const SNAPSHOT_TABLE_BY_KIND = {
38
+ identity_profile: "identity_profile",
39
+ agent_goal: "agent_goal",
40
+ tool_experience: "tool_experience",
41
+ daily_diary: "daily_diary_index",
42
+ dream_output: "dream_output_index",
43
+ narrative_timeline: "narrative_timeline",
44
+ };
45
+ const DEFAULT_SNAPSHOT_KINDS = [
46
+ "identity_profile",
47
+ "agent_goal",
48
+ "tool_experience",
49
+ "daily_diary",
50
+ "dream_output",
51
+ "narrative_timeline",
52
+ ];
53
+ function coerceRestorableKinds(value) {
54
+ if (!Array.isArray(value))
55
+ return undefined;
56
+ const valid = new Set(DEFAULT_SNAPSHOT_KINDS);
57
+ return value.filter((item) => typeof item === "string" && valid.has(item));
58
+ }
59
+ function tableExists(state, table) {
60
+ const result = state.sqlite.exec(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1`, [table]);
61
+ return result.length > 0 && result[0].values.length > 0;
62
+ }
63
+ function readRowsFromTable(state, table) {
64
+ const result = state.sqlite.exec(`SELECT * FROM ${table}`);
65
+ if (result.length === 0 || result[0].values.length === 0)
66
+ return [];
67
+ const columns = result[0].columns;
68
+ return result[0].values.map((row) => {
69
+ const out = {};
70
+ columns.forEach((column, index) => {
71
+ out[column] = row[index];
72
+ });
73
+ return out;
74
+ });
75
+ }
76
+ function stringArray(value) {
77
+ return Array.isArray(value)
78
+ ? value.filter((item) => typeof item === "string")
79
+ : [];
80
+ }
81
+ function textInput(input, key) {
82
+ const value = input?.[key];
83
+ if (typeof value !== "string")
84
+ return undefined;
85
+ const trimmed = value.trim();
86
+ return trimmed.length > 0 ? trimmed : undefined;
87
+ }
88
+ function buildSnapshotNarrativeDelta(input, snapshotId, rowCounts) {
89
+ const explicit = input?.narrativeSnapshot &&
90
+ typeof input.narrativeSnapshot === "object" &&
91
+ !Array.isArray(input.narrativeSnapshot)
92
+ ? input.narrativeSnapshot
93
+ : {};
94
+ const from = (key) => input?.[key] ?? explicit[key];
95
+ const sourceRefs = stringArray(from("sourceRefs"));
96
+ return {
97
+ focus: from("focus") ?? "workspace_state",
98
+ progress: from("progress") ??
99
+ `snapshot_captured:${Object.entries(rowCounts)
100
+ .map(([kind, count]) => `${kind}=${count}`)
101
+ .join(",")}`,
102
+ nextIntent: from("nextIntent") ?? "restore_ready",
103
+ toneSignal: from("toneSignal") ?? "system_maintenance",
104
+ acceptedGoalId: from("acceptedGoalId") ?? undefined,
105
+ sourceRefs: sourceRefs.length > 0
106
+ ? sourceRefs
107
+ : [`restore_snapshot:${snapshotId}`, "runtime_ops:snapshot_capture"],
108
+ reasonCode: from("reasonCode") ?? "snapshot_captured",
109
+ summaryText: from("summaryText") ?? `Captured restore snapshot ${snapshotId}`,
110
+ };
111
+ }
112
+ function hashNarrativeSnapshot(input) {
113
+ return createHash("sha256")
114
+ .update(JSON.stringify({
115
+ previousHash: input.previousHash,
116
+ snapshotId: input.snapshotId,
117
+ delta: input.delta,
118
+ createdAt: input.createdAt,
119
+ }))
120
+ .digest("hex");
121
+ }
122
+ function resolveManifestPath(manifestPath, workspaceRoot) {
123
+ if (path.isAbsolute(manifestPath))
124
+ return manifestPath;
125
+ return path.join(workspaceRoot ?? process.cwd(), manifestPath);
126
+ }
127
+ function registerConnectorForWetProbe(input) {
128
+ if (input.entry.manifestPath) {
129
+ try {
130
+ const manifestText = fs.readFileSync(resolveManifestPath(input.entry.manifestPath, input.workspaceRoot), "utf-8");
131
+ const parsed = JSON.parse(manifestText);
132
+ const registered = input.registryV7.register(parsed);
133
+ if (registered.ok && input.registryV7.hasCapability(input.entry.platformId, input.selectedCapabilityId)) {
134
+ return;
135
+ }
136
+ }
137
+ catch {
138
+ // Non-v7 or YAML workspace manifests are projected below.
139
+ }
140
+ }
141
+ input.registryV7.register({
142
+ platformId: input.entry.platformId,
143
+ capabilities: input.entry.capabilities.map((capabilityId) => ({
144
+ capabilityId,
145
+ intent: capabilityId,
146
+ probeConfig: capabilityId === input.selectedCapabilityId && input.safeEndpoint
147
+ ? {
148
+ safeEndpoint: input.safeEndpoint,
149
+ idempotencyClass: "read_only",
150
+ }
151
+ : undefined,
152
+ })),
153
+ channelPriority: ["runtime_ops"],
154
+ credentialTypes: ["runtime_ops_probe"],
155
+ });
156
+ }
34
157
  /**
35
158
  * T1.2.8 — static local adapter: all checks return `unknown` when no real host is available.
36
159
  * Allows `capability_probe` to be called from CLI / workspace bridge without requiring a live host.
@@ -248,8 +371,11 @@ export function createOpsRouter(deps) {
248
371
  });
249
372
  }
250
373
  if (command === "connector_test") {
251
- // v7 T-ROS.C.1: --wet flag (wet=true) sets dryRun=false + marks triggerSource:"manual_run"
252
- const isWet = input?.wet === true || input?.wet === "true";
374
+ // v7 T-V7C.C.1: dryRun=false is the canonical wet probe switch.
375
+ const isWet = input?.wet === true ||
376
+ input?.wet === "true" ||
377
+ input?.dryRun === false ||
378
+ input?.dryRun === "false";
253
379
  const result = await connectorTest(deps.registry, {
254
380
  platformId: typeof input?.platformId === "string" ? input.platformId : "",
255
381
  dryRun: isWet ? false : (input?.dryRun === false ? false : true),
@@ -257,12 +383,76 @@ export function createOpsRouter(deps) {
257
383
  ? input.workspaceRoot
258
384
  : deps.workspaceRoot,
259
385
  });
260
- if (isWet && result.ok) {
261
- // Annotate result with manual trigger context (DR-038 / T-ROS.C.3)
262
- result.triggerSource = "manual_run";
263
- result.affectsHeartbeatCadence = false;
386
+ if (!isWet || !result.ok) {
387
+ return result;
388
+ }
389
+ const data = result.data && typeof result.data === "object"
390
+ ? result.data
391
+ : {};
392
+ const capabilities = Array.isArray(data.capabilities)
393
+ ? data.capabilities.filter((item) => typeof item === "string")
394
+ : [];
395
+ const capabilityId = textInput(input, "capabilityId") ?? capabilities[0] ?? "";
396
+ if (!capabilityId) {
397
+ return {
398
+ ok: false,
399
+ command: "connector_test",
400
+ error: {
401
+ code: "MISSING_CAPABILITY_ID",
402
+ message: "wet connector_test requires capabilityId or at least one connector capability",
403
+ requiredUserInput: ["capabilityId"],
404
+ nextStep: "reinvoke_with_capability_id",
405
+ },
406
+ };
407
+ }
408
+ const platformId = String(data.platformId ?? input?.platformId ?? "");
409
+ const registryEntry = deps.registry?.describeConnector(platformId);
410
+ if (!registryEntry) {
411
+ return result;
412
+ }
413
+ const registryV7 = new CapabilityContractRegistryV7();
414
+ registerConnectorForWetProbe({
415
+ registryV7,
416
+ entry: {
417
+ platformId: registryEntry.platformId,
418
+ capabilities: registryEntry.capabilities,
419
+ manifestPath: registryEntry.manifestPath,
420
+ },
421
+ workspaceRoot: typeof input?.workspaceRoot === "string"
422
+ ? input.workspaceRoot
423
+ : deps.workspaceRoot,
424
+ selectedCapabilityId: capabilityId,
425
+ safeEndpoint: textInput(input, "safeEndpoint"),
426
+ });
427
+ const wetResult = await createWetProbeRunner().runWetProbe(platformId, capabilityId, registryV7);
428
+ const warnings = [];
429
+ let persistedProbeResult = false;
430
+ if (deps.state) {
431
+ await createCapabilityProbeResultStore(deps.state).appendProbeResult(wetResult.probeResult);
432
+ persistedProbeResult = true;
264
433
  }
265
- return result;
434
+ else {
435
+ warnings.push("state_db_unavailable:capability_probe_result_not_persisted");
436
+ }
437
+ return {
438
+ ok: wetResult.probeResult.actualStatus !== "unavailable",
439
+ command: "connector_test",
440
+ data: {
441
+ ...data,
442
+ dryRun: false,
443
+ capabilityId,
444
+ actualStatus: wetResult.probeResult.actualStatus,
445
+ httpStatus: wetResult.probeResult.httpStatus ?? wetResult.httpStatus,
446
+ probeResultId: wetResult.probeResult.probeResultId,
447
+ probeConfigRef: wetResult.probeResult.probeConfigRef,
448
+ sampleResponseRef: wetResult.probeResult.sampleResponseRef,
449
+ persistedProbeResult,
450
+ triggerSource: "manual_run",
451
+ affectsHeartbeatCadence: false,
452
+ note: "wet probe mode: executed safe probe endpoint and persisted capability_probe_result when state DB is available",
453
+ },
454
+ warnings,
455
+ };
266
456
  }
267
457
  if (command === "connector:run") {
268
458
  // T-ROS.C.3: manual connector execution — isolated from heartbeat cadence
@@ -542,6 +732,102 @@ export function createOpsRouter(deps) {
542
732
  return envelope;
543
733
  }
544
734
  }
735
+ /**
736
+ * [G6] snapshot:capture — production capture path for RestoreSnapshot +
737
+ * NarrativeTimeline. This gives restore and narrative:diff real state to consume.
738
+ */
739
+ if (command === "snapshot:capture") {
740
+ const generatedAt = new Date().toISOString();
741
+ if (!deps.state || !deps.restoreSnapshotStore) {
742
+ const envelope = {
743
+ ok: false,
744
+ command: "snapshot:capture",
745
+ runtimeMode: "unavailable",
746
+ surfaceMode: "cli",
747
+ generatedAt,
748
+ error: {
749
+ code: "SNAPSHOT_CAPTURE_DEPS_UNAVAILABLE",
750
+ message: "snapshot:capture requires state DB and RestoreSnapshotStore in OpsRouterDeps",
751
+ nextStep: "wire_state_and_restore_snapshot_store_into_ops_router",
752
+ },
753
+ warnings: [],
754
+ sourceRefs: [],
755
+ };
756
+ return envelope;
757
+ }
758
+ const snapshotId = textInput(input, "snapshotId") ??
759
+ `snapshot:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
760
+ const requestedKinds = coerceRestorableKinds(input?.entityWhitelist) ?? [...DEFAULT_SNAPSHOT_KINDS];
761
+ const rowCounts = {};
762
+ const warnings = [];
763
+ for (const kind of requestedKinds) {
764
+ const table = SNAPSHOT_TABLE_BY_KIND[kind];
765
+ if (!tableExists(deps.state, table)) {
766
+ rowCounts[kind] = 0;
767
+ warnings.push(`table_missing:${kind}:${table}`);
768
+ continue;
769
+ }
770
+ rowCounts[kind] = readRowsFromTable(deps.state, table).length;
771
+ }
772
+ const historyStore = createHistoryDigestStore(deps.state);
773
+ const previousHash = (await historyStore.listNarrativeTimeline({ limit: 1 }))[0]?.currentHash ?? "";
774
+ const delta = buildSnapshotNarrativeDelta(input, snapshotId, rowCounts);
775
+ const currentHash = hashNarrativeSnapshot({
776
+ previousHash,
777
+ snapshotId,
778
+ delta,
779
+ createdAt: generatedAt,
780
+ });
781
+ await historyStore.appendNarrativeTimeline({
782
+ timelineId: snapshotId,
783
+ entryType: "owner.override",
784
+ subjectId: textInput(input, "subjectId") ?? snapshotId,
785
+ delta,
786
+ previousHash,
787
+ currentHash,
788
+ createdAt: generatedAt,
789
+ });
790
+ const payload = {};
791
+ const capturedKinds = [];
792
+ for (const kind of requestedKinds) {
793
+ const table = SNAPSHOT_TABLE_BY_KIND[kind];
794
+ if (!tableExists(deps.state, table))
795
+ continue;
796
+ const rows = readRowsFromTable(deps.state, table);
797
+ rowCounts[kind] = rows.length;
798
+ if (rows.length > 0) {
799
+ payload[kind] = rows;
800
+ capturedKinds.push(kind);
801
+ }
802
+ }
803
+ const snapshot = await deps.restoreSnapshotStore.captureSnapshot({
804
+ snapshotId,
805
+ entityWhitelist: requestedKinds,
806
+ payload,
807
+ capturedAt: generatedAt,
808
+ });
809
+ const envelope = {
810
+ ok: true,
811
+ command: "snapshot:capture",
812
+ runtimeMode: "workspace_full_runtime",
813
+ surfaceMode: "cli",
814
+ generatedAt,
815
+ data: {
816
+ snapshotId: snapshot.snapshotId,
817
+ capturedAt: snapshot.capturedAt,
818
+ entityWhitelist: snapshot.entityWhitelist,
819
+ capturedKinds,
820
+ rowCounts,
821
+ narrativeVersion: snapshotId,
822
+ },
823
+ warnings,
824
+ sourceRefs: [
825
+ "storage/services/restore-snapshot-store.ts",
826
+ "storage/services/history-digest-store.ts",
827
+ ],
828
+ };
829
+ return envelope;
830
+ }
545
831
  /**
546
832
  * [G6] narrative:diff — queryNarrativeDiff between two versions.
547
833
  * Requires narrativeTimelineDeps in OpsRouterDeps.
@@ -32,6 +32,12 @@ export function createHistoryDigestStore(database) {
32
32
  const { sqlite } = database;
33
33
  return {
34
34
  async appendNarrativeTimeline(entry) {
35
+ const deltaGate = validateWritePayload({
36
+ delta: entry.delta,
37
+ sourceRefs: ["narrative:append"],
38
+ });
39
+ if (!deltaGate.ok)
40
+ throw new Error(deltaGate.reason ?? "write_validation_failed");
35
41
  const gate = validateWritePayload({
36
42
  timelineId: entry.timelineId,
37
43
  entryType: entry.entryType,
@@ -40,7 +46,7 @@ export function createHistoryDigestStore(database) {
40
46
  previousHash: entry.previousHash,
41
47
  currentHash: entry.currentHash,
42
48
  sourceRefs: ["narrative:append"],
43
- });
49
+ }, { runSensitivityScan: false });
44
50
  if (!gate.ok)
45
51
  throw new Error(gate.reason ?? "write_validation_failed");
46
52
  sqlite.run(`INSERT INTO narrative_timeline
@@ -26,6 +26,14 @@ const ALL_RESTORABLE_KINDS = [
26
26
  "dream_output",
27
27
  "narrative_timeline",
28
28
  ];
29
+ const TABLE_BY_KIND = {
30
+ identity_profile: "identity_profile",
31
+ agent_goal: "agent_goal",
32
+ tool_experience: "tool_experience",
33
+ daily_diary: "daily_diary_index",
34
+ dream_output: "dream_output_index",
35
+ narrative_timeline: "narrative_timeline",
36
+ };
29
37
  const DEFAULT_EXCLUDED_KINDS = [
30
38
  "credential",
31
39
  "raw_private_message",
@@ -172,7 +180,8 @@ export function createRestoreSnapshotStore(database, options = {}) {
172
180
  const columns = keys.join(", ");
173
181
  const placeholders = keys.map(() => "?").join(", ");
174
182
  const values = keys.map((k) => row[k]);
175
- sqlite.run(`INSERT OR REPLACE INTO ${kind} (${columns}) VALUES (${placeholders})`, values);
183
+ const table = TABLE_BY_KIND[kind];
184
+ sqlite.run(`INSERT OR REPLACE INTO ${table} (${columns}) VALUES (${placeholders})`, values);
176
185
  }
177
186
  completedEntities.push(kind);
178
187
  }