@jonathangu/openclawbrain 0.3.0 → 0.3.1

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 (56) hide show
  1. package/README.md +140 -290
  2. package/docs/END_STATE.md +106 -94
  3. package/docs/EVIDENCE.md +71 -23
  4. package/docs/RELEASE_CONTRACT.md +46 -32
  5. package/docs/agent-tools.md +65 -34
  6. package/docs/architecture.md +128 -142
  7. package/docs/configuration.md +62 -25
  8. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/channels-status.txt +20 -0
  9. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/config-snapshot.json +94 -0
  10. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/doctor.json +14 -0
  11. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/gateway-probe.txt +24 -0
  12. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/gateway-status.txt +31 -0
  13. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/init-capture.json +15 -0
  14. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/logs.txt +357 -0
  15. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/status-all.txt +61 -0
  16. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/status.json +275 -0
  17. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/summary.md +18 -0
  18. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/trace.json +222 -0
  19. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/validation-report.json +1515 -0
  20. package/docs/evidence/2026-03-16/1fc8ee6fd7892e3deb27d111434df948bca2a66b/workspace-inventory.json +4 -0
  21. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/channels-status.txt +20 -0
  22. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/config-snapshot.json +94 -0
  23. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/doctor.json +14 -0
  24. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/gateway-probe.txt +24 -0
  25. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/gateway-status.txt +31 -0
  26. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/init-capture.json +15 -0
  27. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/logs.txt +362 -0
  28. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/status-all.txt +61 -0
  29. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/status.json +275 -0
  30. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/summary.md +21 -0
  31. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/trace.json +222 -0
  32. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/validation-report.json +4400 -0
  33. package/docs/evidence/2026-03-16/4ccd71a22418b9170128b8d948f5a95801a10380/workspace-inventory.json +4 -0
  34. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/channels-status.txt +31 -0
  35. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/config-snapshot.json +94 -0
  36. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/doctor.json +14 -0
  37. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/gateway-probe.txt +34 -0
  38. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/gateway-status.txt +41 -0
  39. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/logs.txt +441 -0
  40. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/status-all.txt +60 -0
  41. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/status.json +276 -0
  42. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/summary.md +13 -0
  43. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/trace.json +4 -0
  44. package/docs/evidence/2026-03-16/d93f09feea123a08d020fcad8a4523b6c1d26507/validation-report.json +387 -0
  45. package/docs/tui.md +11 -4
  46. package/index.ts +194 -1
  47. package/package.json +1 -1
  48. package/src/brain-cli.ts +12 -1
  49. package/src/brain-harvest/scanner.ts +286 -16
  50. package/src/brain-harvest/self.ts +134 -6
  51. package/src/brain-runtime/evidence-detectors.ts +3 -1
  52. package/src/brain-runtime/harvester-extension.ts +3 -0
  53. package/src/brain-runtime/service.ts +2 -0
  54. package/src/brain-store/embedding.ts +29 -8
  55. package/src/brain-worker/worker.ts +40 -0
  56. package/src/engine.ts +1 -0
@@ -32,11 +32,14 @@ function parseJson(value: string | null | undefined): unknown {
32
32
  }
33
33
  }
34
34
 
35
+ function asRecord(value: unknown): Record<string, unknown> | null {
36
+ return value && typeof value === "object" && !Array.isArray(value)
37
+ ? value as Record<string, unknown>
38
+ : null;
39
+ }
40
+
35
41
  function readPartMetadata(part: HarvestMessagePart): Record<string, unknown> {
36
- const parsed = parseJson(part.metadata);
37
- return parsed && typeof parsed === "object" && !Array.isArray(parsed)
38
- ? parsed as Record<string, unknown>
39
- : {};
42
+ return asRecord(parseJson(part.metadata)) ?? {};
40
43
  }
41
44
 
42
45
  function isStructuredToolResultPart(part: HarvestMessagePart): boolean {
@@ -52,11 +55,133 @@ function isStructuredToolResultPart(part: HarvestMessagePart): boolean {
52
55
  || rawType === "function_call_output";
53
56
  }
54
57
 
58
+ function readString(record: Record<string, unknown> | null, keys: string[]): string | undefined {
59
+ if (!record) {
60
+ return undefined;
61
+ }
62
+ for (const key of keys) {
63
+ const value = record[key];
64
+ if (typeof value === "string" && value.trim().length > 0) {
65
+ return value.trim();
66
+ }
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ function readNumber(record: Record<string, unknown> | null, keys: string[]): number | undefined {
72
+ if (!record) {
73
+ return undefined;
74
+ }
75
+ for (const key of keys) {
76
+ const value = record[key];
77
+ if (typeof value === "number" && Number.isFinite(value)) {
78
+ return value;
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function readStringArray(value: unknown): string[] {
85
+ if (Array.isArray(value)) {
86
+ return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
87
+ }
88
+ if (typeof value === "string" && value.trim().length > 0) {
89
+ return [value.trim()];
90
+ }
91
+ return [];
92
+ }
93
+
94
+ function readCommand(value: unknown): string | undefined {
95
+ if (typeof value === "string" && value.trim().length > 0) {
96
+ return value.trim();
97
+ }
98
+ if (Array.isArray(value)) {
99
+ const parts = value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
100
+ return parts.length > 0 ? parts.join(" ") : undefined;
101
+ }
102
+ return undefined;
103
+ }
104
+
105
+ function extractCommand(input: unknown, output: unknown): string | undefined {
106
+ const inputRecord = asRecord(input);
107
+ const outputRecord = asRecord(output);
108
+
109
+ return readString(inputRecord, ["command", "cmd", "shellCommand"])
110
+ ?? readCommand(inputRecord?.args)
111
+ ?? readString(outputRecord, ["command", "cmd", "shellCommand"])
112
+ ?? readCommand(outputRecord?.args)
113
+ ?? (typeof input === "string" && input.trim().length > 0 ? input.trim() : undefined);
114
+ }
115
+
116
+ function extractFilesTouched(input: unknown, output: unknown): string[] | undefined {
117
+ const inputRecord = asRecord(input);
118
+ const outputRecord = asRecord(output);
119
+ const collected = new Set<string>();
120
+
121
+ for (const value of [
122
+ outputRecord?.filesTouched,
123
+ outputRecord?.changedFiles,
124
+ outputRecord?.files,
125
+ outputRecord?.paths,
126
+ inputRecord?.filesTouched,
127
+ inputRecord?.files,
128
+ inputRecord?.paths,
129
+ readString(outputRecord, ["filePath", "path"]),
130
+ readString(inputRecord, ["filePath", "path"]),
131
+ ]) {
132
+ for (const item of readStringArray(value)) {
133
+ collected.add(item);
134
+ }
135
+ }
136
+
137
+ return collected.size > 0 ? Array.from(collected) : undefined;
138
+ }
139
+
140
+ function extractArtifactPath(output: unknown): string | undefined {
141
+ const record = asRecord(output);
142
+ return readString(record, ["artifactPath", "outputPath", "reportPath", "logPath"]);
143
+ }
144
+
145
+ function buildStructuredToolMetadata(part: HarvestMessagePart): Record<string, unknown> {
146
+ const metadata = readPartMetadata(part);
147
+ const rawType = typeof metadata.rawType === "string" ? metadata.rawType : null;
148
+ const parsedInput = parseJson(part.toolInput);
149
+ const parsedOutput = parseJson(part.toolOutput);
150
+ const result: Record<string, unknown> = {
151
+ toolCallId: part.toolCallId ?? null,
152
+ toolName: part.toolName ?? null,
153
+ partOrdinal: part.ordinal ?? null,
154
+ rawType,
155
+ };
156
+
157
+ const exitCode = readNumber(asRecord(parsedOutput), ["exitCode"]);
158
+ if (exitCode !== undefined) {
159
+ result.exitCode = exitCode;
160
+ }
161
+
162
+ const command = extractCommand(parsedInput, parsedOutput);
163
+ if (command) {
164
+ result.command = command;
165
+ }
166
+
167
+ const filesTouched = extractFilesTouched(parsedInput, parsedOutput);
168
+ if (filesTouched) {
169
+ result.filesTouched = filesTouched;
170
+ }
171
+
172
+ const artifactPath = extractArtifactPath(parsedOutput);
173
+ if (artifactPath) {
174
+ result.artifactPath = artifactPath;
175
+ }
176
+
177
+ return result;
178
+ }
179
+
55
180
  function classifyStructuredToolOutput(output: unknown): { ok: boolean; reason: string } | null {
56
- if (!output || typeof output !== "object" || Array.isArray(output)) {
181
+ const record = asRecord(output);
182
+ if (!record) {
57
183
  return null;
58
184
  }
59
- const record = output as Record<string, unknown>;
60
185
 
61
186
  if (record.isError === true || record.error !== undefined || record.errors !== undefined) {
62
187
  return { ok: false, reason: "structured tool output indicates error" };
@@ -89,6 +214,7 @@ export function detectStructuredSelfEvidence(messageParts?: HarvestMessagePart[]
89
214
  }
90
215
 
91
216
  const metadata = readPartMetadata(part);
217
+ const evidenceMetadata = buildStructuredToolMetadata(part);
92
218
  if (metadata.isError === true) {
93
219
  return {
94
220
  value: -0.5,
@@ -97,6 +223,7 @@ export function detectStructuredSelfEvidence(messageParts?: HarvestMessagePart[]
97
223
  confidence: 0.9,
98
224
  kind: "self_result",
99
225
  extractor: "structured_tool_result",
226
+ metadata: evidenceMetadata,
100
227
  };
101
228
  }
102
229
 
@@ -112,6 +239,7 @@ export function detectStructuredSelfEvidence(messageParts?: HarvestMessagePart[]
112
239
  confidence: 0.9,
113
240
  kind: "self_result",
114
241
  extractor: "structured_tool_result",
242
+ metadata: evidenceMetadata,
115
243
  };
116
244
  }
117
245
 
@@ -5,6 +5,7 @@ import { detectSelfEvidence, detectStructuredSelfEvidence } from "../brain-harve
5
5
 
6
6
  export type HarvestMessagePart = {
7
7
  partType: string;
8
+ ordinal?: number;
8
9
  textContent?: string | null;
9
10
  toolCallId?: string | null;
10
11
  toolName?: string | null;
@@ -20,6 +21,7 @@ export interface HarvestResult {
20
21
  confidence?: number;
21
22
  kind: BrainEvidenceKind;
22
23
  extractor?: string;
24
+ metadata?: Record<string, unknown>;
23
25
  }
24
26
 
25
27
  export function detectEvidenceBatch(
@@ -37,7 +39,7 @@ export function detectEvidenceBatch(
37
39
  const self = structuredSelf ?? detectSelfEvidence(content);
38
40
  const results = [
39
41
  self,
40
- detectScannerEvidence(content),
42
+ detectScannerEvidence(content, messageParts),
41
43
  ].filter((result): result is HarvestResult => result !== null);
42
44
 
43
45
  const deduped = new Map<string, HarvestResult>();
@@ -28,6 +28,7 @@ export class LabelHarvester {
28
28
  */
29
29
  async harvestFromMessage(params: {
30
30
  conversationId: number;
31
+ messageId?: number;
31
32
  episodeId?: string;
32
33
  role: string;
33
34
  content: string;
@@ -71,6 +72,7 @@ export class LabelHarvester {
71
72
  contentSnippet: params.content.slice(0, 240),
72
73
  metadata: {
73
74
  harvestedFromRole: params.role,
75
+ messageId: params.messageId ?? null,
74
76
  explicitEpisodeId,
75
77
  resolvedEpisodeId,
76
78
  matchedEpisodeId: matchingEpisode.id,
@@ -79,6 +81,7 @@ export class LabelHarvester {
79
81
  evidenceIndex: index,
80
82
  evidenceCount: results.length,
81
83
  messagePartCount: params.messageParts?.length ?? 0,
84
+ ...(result.metadata ?? {}),
82
85
  },
83
86
  });
84
87
 
@@ -599,11 +599,13 @@ export class BrainService {
599
599
 
600
600
  async harvestFromMessage(params: {
601
601
  conversationId: number;
602
+ messageId?: number;
602
603
  episodeId?: string;
603
604
  role: string;
604
605
  content: string;
605
606
  messageParts?: Array<{
606
607
  partType: string;
608
+ ordinal?: number;
607
609
  textContent?: string | null;
608
610
  toolCallId?: string | null;
609
611
  toolName?: string | null;
@@ -27,6 +27,12 @@ function trimTrailingSlashes(value: string): string {
27
27
  return value.replace(/\/+$/, "");
28
28
  }
29
29
 
30
+ function resolveEmbeddingTimeoutMs(): number {
31
+ const raw = process.env.OPENCLAWBRAIN_EMBEDDING_TIMEOUT_MS?.trim();
32
+ const parsed = raw ? Number.parseInt(raw, 10) : 10000;
33
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 10000;
34
+ }
35
+
30
36
  function resolveExplicitBaseUrl(config: OpenClawBrainRuntimeConfig): string | null {
31
37
  const explicit = config.embeddingBaseUrl.trim();
32
38
  return explicit ? trimTrailingSlashes(explicit) : null;
@@ -152,14 +158,29 @@ export function createEmbeddingClient(options: BrainEmbeddingOptions): BrainEmbe
152
158
  headers.authorization = `Bearer ${apiKey}`;
153
159
  }
154
160
 
155
- const response = await fetch(`${baseUrl}/embeddings`, {
156
- method: "POST",
157
- headers,
158
- body: JSON.stringify({
159
- model: config.embeddingModel,
160
- input: text,
161
- }),
162
- });
161
+ const timeoutMs = resolveEmbeddingTimeoutMs();
162
+ const controller = new AbortController();
163
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
164
+
165
+ let response;
166
+ try {
167
+ response = await fetch(`${baseUrl}/embeddings`, {
168
+ method: "POST",
169
+ headers,
170
+ body: JSON.stringify({
171
+ model: config.embeddingModel,
172
+ input: text,
173
+ }),
174
+ signal: controller.signal,
175
+ });
176
+ } catch (error) {
177
+ if ((error as { name?: string }).name === "AbortError") {
178
+ throw new Error(`Embedding request timed out after ${timeoutMs}ms: ${baseUrl}/embeddings`);
179
+ }
180
+ throw error;
181
+ } finally {
182
+ clearTimeout(timer);
183
+ }
163
184
 
164
185
  if (!response.ok) {
165
186
  const body = await response.text();
@@ -9,12 +9,43 @@ import { computeReinforceUpdates, updateBaseline, applyWeightUpdates } from "../
9
9
  import { decayAllWeights } from "../brain-core/decay.js";
10
10
  import { computeHealth } from "../brain-core/health.js";
11
11
 
12
+ function readExtractor(evidence: BrainEvidence): string | null {
13
+ const extractor = evidence.metadata?.extractor;
14
+ return typeof extractor === "string" && extractor.length > 0 ? extractor : null;
15
+ }
16
+
17
+ function evidenceSpecificityRank(evidence: BrainEvidence): number {
18
+ const extractor = readExtractor(evidence);
19
+ if (evidence.source === "scanner") {
20
+ switch (extractor) {
21
+ case "structured_guidance_parts":
22
+ return 3;
23
+ case "structured_tool_chain":
24
+ return 2;
25
+ case "scanner_marker":
26
+ return 1;
27
+ case "scanner_heuristic":
28
+ default:
29
+ return 0;
30
+ }
31
+ }
32
+
33
+ return 0;
34
+ }
35
+
12
36
  function compareEvidencePriority(left: BrainEvidence, right: BrainEvidence): number {
13
37
  const trustDelta = trustRank(left.source) - trustRank(right.source);
14
38
  if (trustDelta !== 0) {
15
39
  return trustDelta;
16
40
  }
17
41
 
42
+ if (left.value !== right.value) {
43
+ const specificityDelta = evidenceSpecificityRank(left) - evidenceSpecificityRank(right);
44
+ if (specificityDelta !== 0) {
45
+ return specificityDelta;
46
+ }
47
+ }
48
+
18
49
  const confidenceDelta = left.confidence - right.confidence;
19
50
  if (confidenceDelta !== 0) {
20
51
  return confidenceDelta;
@@ -43,6 +74,15 @@ function losingEvidenceResolution(
43
74
  };
44
75
  }
45
76
 
77
+ const winnerSpecificity = evidenceSpecificityRank(winner);
78
+ const loserSpecificity = evidenceSpecificityRank(loser);
79
+ if (winnerSpecificity !== loserSpecificity) {
80
+ return {
81
+ resolution: "discarded_duplicate",
82
+ note: `same-trust evidence superseded by more-structured ${winner.source} evidence`,
83
+ };
84
+ }
85
+
46
86
  if (winner.confidence !== loser.confidence) {
47
87
  return {
48
88
  resolution: "discarded_duplicate",
package/src/engine.ts CHANGED
@@ -1263,6 +1263,7 @@ export class LcmContextEngine implements ContextEngine {
1263
1263
  if (this.brainService) {
1264
1264
  await this.brainService.harvestFromMessage({
1265
1265
  conversationId,
1266
+ messageId: msgRecord.messageId,
1266
1267
  episodeId: params.brainEpisodeId ?? this.pendingBrainEpisodeBySession.get(sessionId),
1267
1268
  role: stored.role,
1268
1269
  content: stored.content,