kongbrain 0.1.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.
- package/LICENSE +21 -0
- package/README.md +385 -0
- package/openclaw.plugin.json +66 -0
- package/package.json +65 -0
- package/src/acan.ts +309 -0
- package/src/causal.ts +237 -0
- package/src/cognitive-check.ts +330 -0
- package/src/config.ts +64 -0
- package/src/context-engine.ts +487 -0
- package/src/daemon-manager.ts +148 -0
- package/src/daemon-types.ts +65 -0
- package/src/embeddings.ts +77 -0
- package/src/errors.ts +43 -0
- package/src/graph-context.ts +989 -0
- package/src/hooks/after-tool-call.ts +99 -0
- package/src/hooks/before-prompt-build.ts +44 -0
- package/src/hooks/before-tool-call.ts +86 -0
- package/src/hooks/llm-output.ts +173 -0
- package/src/identity.ts +218 -0
- package/src/index.ts +435 -0
- package/src/intent.ts +190 -0
- package/src/memory-daemon.ts +495 -0
- package/src/orchestrator.ts +348 -0
- package/src/prefetch.ts +200 -0
- package/src/reflection.ts +280 -0
- package/src/retrieval-quality.ts +266 -0
- package/src/schema.surql +387 -0
- package/src/skills.ts +343 -0
- package/src/soul.ts +936 -0
- package/src/state.ts +119 -0
- package/src/surreal.ts +1371 -0
- package/src/tools/core-memory.ts +120 -0
- package/src/tools/introspect.ts +329 -0
- package/src/tools/recall.ts +102 -0
- package/src/wakeup.ts +318 -0
- package/src/workspace-migrate.ts +752 -0
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Migration — ingest OpenClaw's workspace files into SurrealDB.
|
|
3
|
+
*
|
|
4
|
+
* When a user switches from the default context engine to KongBrain, their
|
|
5
|
+
* workspace may contain .md files, skill definitions, session transcripts,
|
|
6
|
+
* and memory logs created by OpenClaw. This module:
|
|
7
|
+
*
|
|
8
|
+
* 1. Collects ONLY known OpenClaw workspace files (allowlist, not recursive)
|
|
9
|
+
* 2. Scans skills/ and .agents/skills/ for SKILL.md → proper `skill` records
|
|
10
|
+
* 3. Scans memory/ for daily logs → `memory` records
|
|
11
|
+
* 4. Ingests identity/user/agent files as memories + artifacts
|
|
12
|
+
* 5. Archives originals into .kongbrain-archive/ so the workspace is clean
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT: This module NEVER touches user project files. A user's README.md,
|
|
15
|
+
* package.json, docs/, test fixtures, scripts, etc. are left completely alone.
|
|
16
|
+
* We only collect files that OpenClaw's default context engine created.
|
|
17
|
+
*
|
|
18
|
+
* SOUL.md is deliberately left in place — it serves as a "nudge" during
|
|
19
|
+
* soul graduation and is read at that time, not ingested.
|
|
20
|
+
*
|
|
21
|
+
* Cross-platform: uses path.join/path.sep throughout, no shell commands,
|
|
22
|
+
* copyFile+unlink instead of rename (cross-filesystem safe).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile, readdir, stat, copyFile, unlink, mkdir, writeFile, rmdir } from "node:fs/promises";
|
|
26
|
+
import { join, basename, extname, relative, dirname, sep } from "node:path";
|
|
27
|
+
import type { SurrealStore } from "./surreal.js";
|
|
28
|
+
import type { EmbeddingService } from "./embeddings.js";
|
|
29
|
+
import { swallow } from "./errors.js";
|
|
30
|
+
|
|
31
|
+
// ── Allowlists ───────────────────────────────────────────────────────────────
|
|
32
|
+
// Only files and directories OpenClaw's default engine creates.
|
|
33
|
+
|
|
34
|
+
/** Top-level files that belong to OpenClaw and are safe to migrate. */
|
|
35
|
+
const OPENCLAW_ROOT_FILES = new Set([
|
|
36
|
+
"IDENTITY.md",
|
|
37
|
+
"USER.md",
|
|
38
|
+
"AGENTS.md",
|
|
39
|
+
"TOOLS.md",
|
|
40
|
+
"MEMORY.md",
|
|
41
|
+
"memory.md",
|
|
42
|
+
"SKILLS.md",
|
|
43
|
+
// HEARTBEAT.md is NOT here — OpenClaw core reads it directly for cron heartbeats
|
|
44
|
+
// SOUL.md is NOT here — stays in place as graduation nudge
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** Files to skip — never ingest, never archive. */
|
|
48
|
+
const SKIP_FILES = new Set([
|
|
49
|
+
"SOUL.md", // Stays for graduation nudge — read during soul graduation
|
|
50
|
+
"BOOTSTRAP.md", // Ephemeral onboarding file — deleted by OpenClaw after setup
|
|
51
|
+
"HEARTBEAT.md", // Actively used by OpenClaw core heartbeat runner — not ours to touch
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Directories that belong to OpenClaw and should be scanned.
|
|
56
|
+
* We only scan one level into these (except skills, which has skill-name/SKILL.md).
|
|
57
|
+
*/
|
|
58
|
+
const OPENCLAW_DIRS = [
|
|
59
|
+
"memory", // memory/YYYY-MM-DD.md daily logs
|
|
60
|
+
"skills", // skills/<name>/SKILL.md
|
|
61
|
+
".agents", // .agents/skills/<name>/SKILL.md
|
|
62
|
+
"sessions", // sessions/*.jsonl transcripts
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** Max file size to ingest (256KB — same as OpenClaw's skill limit). */
|
|
66
|
+
const MAX_FILE_SIZE = 256 * 1024;
|
|
67
|
+
|
|
68
|
+
// ── Types ────────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
interface WorkspaceFile {
|
|
71
|
+
/** Absolute path on disk. */
|
|
72
|
+
absPath: string;
|
|
73
|
+
/** Path relative to workspace root, always forward slashes for DB storage. */
|
|
74
|
+
relPath: string;
|
|
75
|
+
/** File content (utf-8). */
|
|
76
|
+
content: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MigrationResult {
|
|
80
|
+
ingested: number;
|
|
81
|
+
skills: number;
|
|
82
|
+
memories: number;
|
|
83
|
+
skipped: number;
|
|
84
|
+
archived: boolean;
|
|
85
|
+
archivePath?: string;
|
|
86
|
+
details: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check whether a workspace has OpenClaw files that could be migrated.
|
|
93
|
+
* Fast — only checks for known indicators, never recurses into user dirs.
|
|
94
|
+
*/
|
|
95
|
+
export async function hasMigratableFiles(workspaceDir: string): Promise<boolean> {
|
|
96
|
+
// Check for any known root-level OpenClaw files
|
|
97
|
+
for (const file of OPENCLAW_ROOT_FILES) {
|
|
98
|
+
try {
|
|
99
|
+
await stat(join(workspaceDir, file));
|
|
100
|
+
return true;
|
|
101
|
+
} catch { /* doesn't exist */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Check for OpenClaw directories
|
|
105
|
+
for (const dir of OPENCLAW_DIRS) {
|
|
106
|
+
try {
|
|
107
|
+
const s = await stat(join(workspaceDir, dir));
|
|
108
|
+
if (s.isDirectory()) return true;
|
|
109
|
+
} catch { /* doesn't exist */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Ingest OpenClaw workspace files into SurrealDB, then archive originals.
|
|
117
|
+
*
|
|
118
|
+
* Call this after the user confirms migration. Idempotent — checks for
|
|
119
|
+
* a migration marker in the DB before running.
|
|
120
|
+
*
|
|
121
|
+
* Only touches files that belong to OpenClaw. User project files
|
|
122
|
+
* (README.md, package.json, src/, docs/, etc.) are never touched.
|
|
123
|
+
*/
|
|
124
|
+
export async function migrateWorkspace(
|
|
125
|
+
workspaceDir: string,
|
|
126
|
+
store: SurrealStore,
|
|
127
|
+
embeddings: EmbeddingService,
|
|
128
|
+
): Promise<MigrationResult> {
|
|
129
|
+
const result: MigrationResult = {
|
|
130
|
+
ingested: 0,
|
|
131
|
+
skills: 0,
|
|
132
|
+
memories: 0,
|
|
133
|
+
skipped: 0,
|
|
134
|
+
archived: false,
|
|
135
|
+
details: [],
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (!store.isAvailable()) {
|
|
139
|
+
result.details.push("SurrealDB not available — skipping migration");
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if already migrated
|
|
144
|
+
try {
|
|
145
|
+
const marker = await store.queryFirst<{ id: string }>(
|
|
146
|
+
`SELECT id FROM artifact WHERE path = 'workspace-migration' AND type = 'migration-marker' LIMIT 1`,
|
|
147
|
+
);
|
|
148
|
+
if (marker.length > 0) {
|
|
149
|
+
result.details.push("Workspace already migrated — skipping");
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Table might not exist yet, proceed
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Collect only OpenClaw files ──
|
|
157
|
+
const files = await collectOpenClawFiles(workspaceDir);
|
|
158
|
+
|
|
159
|
+
if (files.length === 0) {
|
|
160
|
+
result.details.push("No OpenClaw workspace files found to migrate");
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
result.details.push(`Found ${files.length} OpenClaw files to migrate`);
|
|
165
|
+
|
|
166
|
+
// ── Process each file ──
|
|
167
|
+
for (const file of files) {
|
|
168
|
+
try {
|
|
169
|
+
const name = basename(file.absPath);
|
|
170
|
+
|
|
171
|
+
// SKILL.md files → create skill records in the graph
|
|
172
|
+
if (name === "SKILL.md") {
|
|
173
|
+
const created = await ingestSkill(file, store, embeddings);
|
|
174
|
+
if (created) {
|
|
175
|
+
result.skills++;
|
|
176
|
+
result.details.push(`Skill: ${file.relPath}`);
|
|
177
|
+
} else {
|
|
178
|
+
result.skipped++;
|
|
179
|
+
result.details.push(`Skipped skill (parse failed): ${file.relPath}`);
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Classify and ingest as artifact
|
|
185
|
+
const fileType = categorizeFile(file.relPath, name);
|
|
186
|
+
const description = summarizeFile(file.relPath, name, file.content);
|
|
187
|
+
|
|
188
|
+
let embedding: number[] | null = null;
|
|
189
|
+
if (embeddings.isAvailable()) {
|
|
190
|
+
const textToEmbed = file.content.length < 2000
|
|
191
|
+
? file.content
|
|
192
|
+
: description + "\n" + file.content.slice(0, 1500);
|
|
193
|
+
try { embedding = await embeddings.embed(textToEmbed); }
|
|
194
|
+
catch (e) { swallow("migrate:embed", e); }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await store.queryExec(
|
|
198
|
+
`CREATE artifact CONTENT $record`,
|
|
199
|
+
{
|
|
200
|
+
record: {
|
|
201
|
+
path: file.relPath,
|
|
202
|
+
type: fileType,
|
|
203
|
+
description,
|
|
204
|
+
content: file.content,
|
|
205
|
+
content_hash: simpleHash(file.content),
|
|
206
|
+
embedding,
|
|
207
|
+
tags: ["workspace-migration", fileType],
|
|
208
|
+
migrated_from: "openclaw-default",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
result.ingested++;
|
|
214
|
+
result.details.push(`Ingested: ${file.relPath} (${fileType})`);
|
|
215
|
+
|
|
216
|
+
// Also create memory records for content-rich files
|
|
217
|
+
if (shouldCreateMemories(fileType)) {
|
|
218
|
+
const memCount = await ingestAsMemories(file.content, fileType, store, embeddings);
|
|
219
|
+
result.memories += memCount;
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
result.skipped++;
|
|
223
|
+
result.details.push(`Failed: ${file.relPath} — ${e}`);
|
|
224
|
+
swallow.warn("migrate:ingest", e);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Archive originals ──
|
|
229
|
+
try {
|
|
230
|
+
const archivePath = await archiveFiles(workspaceDir, files);
|
|
231
|
+
result.archived = true;
|
|
232
|
+
result.archivePath = archivePath;
|
|
233
|
+
result.details.push(`Archived to: ${archivePath}`);
|
|
234
|
+
} catch (e) {
|
|
235
|
+
result.details.push(`Archive failed: ${e}`);
|
|
236
|
+
swallow.warn("migrate:archive", e);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Write migration marker ──
|
|
240
|
+
try {
|
|
241
|
+
await store.queryExec(
|
|
242
|
+
`CREATE artifact CONTENT $record`,
|
|
243
|
+
{
|
|
244
|
+
record: {
|
|
245
|
+
path: "workspace-migration",
|
|
246
|
+
type: "migration-marker",
|
|
247
|
+
description: `Migrated ${result.ingested} artifacts, ${result.skills} skills, ${result.memories} memories from workspace`,
|
|
248
|
+
tags: ["workspace-migration"],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
} catch (e) {
|
|
253
|
+
swallow.warn("migrate:marker", e);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── File Collection (Allowlist-based, NOT recursive) ─────────────────────────
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Collect only files that belong to OpenClaw's workspace system.
|
|
263
|
+
* Never touches user project files like README.md, package.json, src/, docs/, etc.
|
|
264
|
+
*/
|
|
265
|
+
async function collectOpenClawFiles(workspaceDir: string): Promise<WorkspaceFile[]> {
|
|
266
|
+
const found: WorkspaceFile[] = [];
|
|
267
|
+
|
|
268
|
+
// 1. Known root-level OpenClaw files
|
|
269
|
+
for (const fileName of OPENCLAW_ROOT_FILES) {
|
|
270
|
+
const absPath = join(workspaceDir, fileName);
|
|
271
|
+
const file = await tryReadFile(absPath, workspaceDir);
|
|
272
|
+
if (file) found.push(file);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 2. memory/ directory — one level of .md files (daily logs)
|
|
276
|
+
await collectFromDir(join(workspaceDir, "memory"), workspaceDir, [".md"], found, false);
|
|
277
|
+
|
|
278
|
+
// 3. skills/ directory — look for <name>/SKILL.md (two levels)
|
|
279
|
+
await collectSkillDirs(join(workspaceDir, "skills"), workspaceDir, found);
|
|
280
|
+
|
|
281
|
+
// 4. .agents/skills/ directory — same pattern
|
|
282
|
+
await collectSkillDirs(join(workspaceDir, ".agents", "skills"), workspaceDir, found);
|
|
283
|
+
|
|
284
|
+
// 5. sessions/ — .jsonl and .json files (one level)
|
|
285
|
+
await collectFromDir(join(workspaceDir, "sessions"), workspaceDir, [".jsonl", ".json"], found, false);
|
|
286
|
+
|
|
287
|
+
return found;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Collect files from a single directory (non-recursive) matching given extensions.
|
|
292
|
+
*/
|
|
293
|
+
async function collectFromDir(
|
|
294
|
+
dirPath: string,
|
|
295
|
+
rootDir: string,
|
|
296
|
+
extensions: string[],
|
|
297
|
+
out: WorkspaceFile[],
|
|
298
|
+
_recursive: boolean,
|
|
299
|
+
): Promise<void> {
|
|
300
|
+
let entries: string[];
|
|
301
|
+
try {
|
|
302
|
+
entries = await readdir(dirPath);
|
|
303
|
+
} catch {
|
|
304
|
+
return; // Directory doesn't exist
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const extSet = new Set(extensions.map(e => e.toLowerCase()));
|
|
308
|
+
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
const absPath = join(dirPath, entry);
|
|
311
|
+
const ext = extname(entry).toLowerCase();
|
|
312
|
+
if (!extSet.has(ext)) continue;
|
|
313
|
+
|
|
314
|
+
const file = await tryReadFile(absPath, rootDir);
|
|
315
|
+
if (file) out.push(file);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Scan a skills directory for the <skill-name>/SKILL.md pattern.
|
|
321
|
+
* Only goes two levels: skills/<name>/SKILL.md — never deeper.
|
|
322
|
+
*/
|
|
323
|
+
async function collectSkillDirs(
|
|
324
|
+
skillsRoot: string,
|
|
325
|
+
workspaceRoot: string,
|
|
326
|
+
out: WorkspaceFile[],
|
|
327
|
+
): Promise<void> {
|
|
328
|
+
let entries: string[];
|
|
329
|
+
try {
|
|
330
|
+
entries = await readdir(skillsRoot);
|
|
331
|
+
} catch {
|
|
332
|
+
return; // Directory doesn't exist
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const entry of entries) {
|
|
336
|
+
const skillDir = join(skillsRoot, entry);
|
|
337
|
+
|
|
338
|
+
let s;
|
|
339
|
+
try { s = await stat(skillDir); }
|
|
340
|
+
catch { continue; }
|
|
341
|
+
|
|
342
|
+
if (!s.isDirectory()) {
|
|
343
|
+
// Might be a top-level .md in skills/ (like SKILLS.md placed inside)
|
|
344
|
+
if (extname(entry).toLowerCase() === ".md") {
|
|
345
|
+
const file = await tryReadFile(skillDir, workspaceRoot);
|
|
346
|
+
if (file) out.push(file);
|
|
347
|
+
}
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Look for SKILL.md inside this skill directory
|
|
352
|
+
const skillMdPath = join(skillDir, "SKILL.md");
|
|
353
|
+
const file = await tryReadFile(skillMdPath, workspaceRoot);
|
|
354
|
+
if (file) out.push(file);
|
|
355
|
+
|
|
356
|
+
// Also pick up any other .md files in the skill dir (README.md for the skill, etc.)
|
|
357
|
+
let skillFiles: string[];
|
|
358
|
+
try { skillFiles = await readdir(skillDir); }
|
|
359
|
+
catch { continue; }
|
|
360
|
+
|
|
361
|
+
for (const sf of skillFiles) {
|
|
362
|
+
if (sf === "SKILL.md") continue; // Already got it
|
|
363
|
+
if (extname(sf).toLowerCase() !== ".md") continue;
|
|
364
|
+
const sfFile = await tryReadFile(join(skillDir, sf), workspaceRoot);
|
|
365
|
+
if (sfFile) out.push(sfFile);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Try to read a single file. Returns null if it doesn't exist, is too large,
|
|
372
|
+
* is empty, or is in the skip list.
|
|
373
|
+
*/
|
|
374
|
+
async function tryReadFile(absPath: string, rootDir: string): Promise<WorkspaceFile | null> {
|
|
375
|
+
const name = basename(absPath);
|
|
376
|
+
if (SKIP_FILES.has(name)) return null;
|
|
377
|
+
|
|
378
|
+
let s;
|
|
379
|
+
try { s = await stat(absPath); }
|
|
380
|
+
catch { return null; }
|
|
381
|
+
|
|
382
|
+
if (!s.isFile()) return null;
|
|
383
|
+
if (s.size === 0 || s.size > MAX_FILE_SIZE) return null;
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const content = await readFile(absPath, "utf-8");
|
|
387
|
+
if (content.trim().length === 0) return null;
|
|
388
|
+
|
|
389
|
+
// Normalize to forward slashes for cross-platform DB storage
|
|
390
|
+
const relPath = relative(rootDir, absPath).split(sep).join("/");
|
|
391
|
+
return { absPath, relPath, content };
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ── Skill Ingestion ──────────────────────────────────────────────────────────
|
|
398
|
+
|
|
399
|
+
interface SkillFrontmatter {
|
|
400
|
+
name?: string;
|
|
401
|
+
description?: string;
|
|
402
|
+
metadata?: {
|
|
403
|
+
openclaw?: {
|
|
404
|
+
emoji?: string;
|
|
405
|
+
skillKey?: string;
|
|
406
|
+
primaryEnv?: string;
|
|
407
|
+
os?: string[];
|
|
408
|
+
requires?: {
|
|
409
|
+
bins?: string[];
|
|
410
|
+
env?: string[];
|
|
411
|
+
};
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Parse a SKILL.md file (YAML frontmatter + markdown body) and create
|
|
418
|
+
* a proper `skill` record in SurrealDB. The skill is immediately usable
|
|
419
|
+
* by the graph retrieval system.
|
|
420
|
+
*/
|
|
421
|
+
async function ingestSkill(
|
|
422
|
+
file: WorkspaceFile,
|
|
423
|
+
store: SurrealStore,
|
|
424
|
+
embeddings: EmbeddingService,
|
|
425
|
+
): Promise<boolean> {
|
|
426
|
+
const { frontmatter, body } = parseFrontmatter(file.content);
|
|
427
|
+
if (!frontmatter && !body) return false;
|
|
428
|
+
|
|
429
|
+
const fm = frontmatter as SkillFrontmatter | null;
|
|
430
|
+
|
|
431
|
+
// Derive skill name from frontmatter or directory name
|
|
432
|
+
const parts = file.relPath.split("/");
|
|
433
|
+
const dirName = parts.length >= 2 ? parts[parts.length - 2] : "unknown";
|
|
434
|
+
const skillName = fm?.name ?? dirName;
|
|
435
|
+
const description = fm?.description ?? body.split("\n").find(l => l.trim().length > 10)?.trim() ?? `Skill: ${skillName}`;
|
|
436
|
+
|
|
437
|
+
// Extract steps from markdown body
|
|
438
|
+
const steps = extractSteps(body);
|
|
439
|
+
|
|
440
|
+
let embedding: number[] | null = null;
|
|
441
|
+
if (embeddings.isAvailable()) {
|
|
442
|
+
const textToEmbed = `${skillName}: ${description}\n${steps.join("\n")}`.slice(0, 6000);
|
|
443
|
+
try { embedding = await embeddings.embed(textToEmbed); }
|
|
444
|
+
catch (e) { swallow("migrate:skillEmbed", e); }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Build preconditions from metadata requirements
|
|
448
|
+
const preconditions: string[] = [];
|
|
449
|
+
const requires = fm?.metadata?.openclaw?.requires;
|
|
450
|
+
if (requires?.bins?.length) preconditions.push(`Requires binaries: ${requires.bins.join(", ")}`);
|
|
451
|
+
if (requires?.env?.length) preconditions.push(`Requires env vars: ${requires.env.join(", ")}`);
|
|
452
|
+
const os = fm?.metadata?.openclaw?.os;
|
|
453
|
+
if (os?.length) preconditions.push(`Supported OS: ${os.join(", ")}`);
|
|
454
|
+
|
|
455
|
+
await store.queryExec(
|
|
456
|
+
`CREATE skill CONTENT $record`,
|
|
457
|
+
{
|
|
458
|
+
record: {
|
|
459
|
+
name: skillName,
|
|
460
|
+
description,
|
|
461
|
+
embedding,
|
|
462
|
+
preconditions: preconditions.length > 0 ? preconditions.join("; ") : null,
|
|
463
|
+
steps: steps.length > 0 ? steps : null,
|
|
464
|
+
postconditions: null,
|
|
465
|
+
success_count: 1,
|
|
466
|
+
failure_count: 0,
|
|
467
|
+
avg_duration_ms: 0,
|
|
468
|
+
last_used: null,
|
|
469
|
+
source: "workspace-migration",
|
|
470
|
+
source_path: file.relPath,
|
|
471
|
+
full_content: file.content,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
// Also create artifact record so it shows up in artifact search
|
|
477
|
+
await store.queryExec(
|
|
478
|
+
`CREATE artifact CONTENT $record`,
|
|
479
|
+
{
|
|
480
|
+
record: {
|
|
481
|
+
path: file.relPath,
|
|
482
|
+
type: "skill-definition",
|
|
483
|
+
description: `Skill: ${skillName} — ${description}`,
|
|
484
|
+
content: file.content,
|
|
485
|
+
content_hash: simpleHash(file.content),
|
|
486
|
+
embedding,
|
|
487
|
+
tags: ["workspace-migration", "skill", skillName],
|
|
488
|
+
migrated_from: "openclaw-default",
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Parse YAML-ish frontmatter from a markdown file.
|
|
498
|
+
* Handles the --- delimited block at the top.
|
|
499
|
+
*/
|
|
500
|
+
function parseFrontmatter(content: string): { frontmatter: Record<string, unknown> | null; body: string } {
|
|
501
|
+
const trimmed = content.trimStart();
|
|
502
|
+
if (!trimmed.startsWith("---")) {
|
|
503
|
+
return { frontmatter: null, body: content };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const endIdx = trimmed.indexOf("---", 3);
|
|
507
|
+
if (endIdx === -1) {
|
|
508
|
+
return { frontmatter: null, body: content };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const fmBlock = trimmed.slice(3, endIdx).trim();
|
|
512
|
+
const body = trimmed.slice(endIdx + 3).trim();
|
|
513
|
+
|
|
514
|
+
// Simple YAML parser for flat key-value pairs + JSON metadata block.
|
|
515
|
+
// Full YAML parsing would need a dependency — this covers SKILL.md format.
|
|
516
|
+
try {
|
|
517
|
+
const result: Record<string, unknown> = {};
|
|
518
|
+
for (const line of fmBlock.split("\n")) {
|
|
519
|
+
const match = line.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
|
|
520
|
+
if (match) {
|
|
521
|
+
const [, key, val] = match;
|
|
522
|
+
result[key] = val.trim();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Try JSON metadata block if present
|
|
527
|
+
const jsonMatch = fmBlock.match(/metadata:\s*\n\s*(\{[\s\S]*\})/);
|
|
528
|
+
if (jsonMatch) {
|
|
529
|
+
try {
|
|
530
|
+
result.metadata = JSON.parse(jsonMatch[1]);
|
|
531
|
+
} catch {
|
|
532
|
+
// Malformed JSON metadata — skip
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return { frontmatter: Object.keys(result).length > 0 ? result : null, body };
|
|
537
|
+
} catch {
|
|
538
|
+
return { frontmatter: null, body };
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Extract procedural steps from markdown body.
|
|
544
|
+
* Looks for ordered/unordered lists, especially under "Steps"/"Usage"/"How" headers.
|
|
545
|
+
*/
|
|
546
|
+
function extractSteps(body: string): string[] {
|
|
547
|
+
const steps: string[] = [];
|
|
548
|
+
|
|
549
|
+
const sectionRe = /^#{1,3}\s+(steps|usage|how|instructions|procedure|workflow)/im;
|
|
550
|
+
const sectionMatch = body.match(sectionRe);
|
|
551
|
+
const searchArea = sectionMatch ? body.slice(sectionMatch.index!) : body;
|
|
552
|
+
|
|
553
|
+
// Ordered list items
|
|
554
|
+
const orderedRe = /^\s*\d+\.\s+(.+)$/gm;
|
|
555
|
+
let m: RegExpExecArray | null;
|
|
556
|
+
while ((m = orderedRe.exec(searchArea)) !== null) {
|
|
557
|
+
steps.push(m[1].trim());
|
|
558
|
+
if (steps.length >= 20) break;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Fallback: bullet points
|
|
562
|
+
if (steps.length === 0) {
|
|
563
|
+
const bulletRe = /^\s*[-*]\s+(.+)$/gm;
|
|
564
|
+
while ((m = bulletRe.exec(searchArea)) !== null) {
|
|
565
|
+
steps.push(m[1].trim());
|
|
566
|
+
if (steps.length >= 20) break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return steps;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ── Memory Ingestion ─────────────────────────────────────────────────────────
|
|
574
|
+
|
|
575
|
+
/** File types that should also be broken into memory records. */
|
|
576
|
+
function shouldCreateMemories(fileType: string): boolean {
|
|
577
|
+
return [
|
|
578
|
+
"identity", "user-profile", "agent-config", "memory-index",
|
|
579
|
+
"daily-memory", "skills-index",
|
|
580
|
+
].includes(fileType);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Extract meaningful chunks from a .md file and store as memory records.
|
|
585
|
+
*/
|
|
586
|
+
async function ingestAsMemories(
|
|
587
|
+
content: string,
|
|
588
|
+
fileType: string,
|
|
589
|
+
store: SurrealStore,
|
|
590
|
+
embeddings: EmbeddingService,
|
|
591
|
+
): Promise<number> {
|
|
592
|
+
const chunks = content
|
|
593
|
+
.split(/\n#{1,3}\s+|\n\n/)
|
|
594
|
+
.map(c => c.trim())
|
|
595
|
+
.filter(c => c.length > 20);
|
|
596
|
+
|
|
597
|
+
const categoryMap: Record<string, string> = {
|
|
598
|
+
"identity": "identity",
|
|
599
|
+
"user-profile": "user-profile",
|
|
600
|
+
"agent-config": "agent-config",
|
|
601
|
+
"memory-index": "general",
|
|
602
|
+
"daily-memory": "daily-memory",
|
|
603
|
+
"skills-index": "skill",
|
|
604
|
+
};
|
|
605
|
+
const category = categoryMap[fileType] ?? "general";
|
|
606
|
+
|
|
607
|
+
let created = 0;
|
|
608
|
+
for (const chunk of chunks.slice(0, 20)) {
|
|
609
|
+
try {
|
|
610
|
+
let embedding: number[] | null = null;
|
|
611
|
+
if (embeddings.isAvailable()) {
|
|
612
|
+
try { embedding = await embeddings.embed(chunk); } catch { /* ok */ }
|
|
613
|
+
}
|
|
614
|
+
await store.createMemory(chunk, embedding, 50, category);
|
|
615
|
+
created++;
|
|
616
|
+
} catch (e) {
|
|
617
|
+
swallow("migrate:createMemory", e);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return created;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// ── File Classification ──────────────────────────────────────────────────────
|
|
624
|
+
|
|
625
|
+
function categorizeFile(relPath: string, name: string): string {
|
|
626
|
+
const upper = name.toUpperCase();
|
|
627
|
+
|
|
628
|
+
if (upper === "IDENTITY.MD") return "identity";
|
|
629
|
+
if (upper === "USER.MD") return "user-profile";
|
|
630
|
+
if (upper === "AGENTS.MD") return "agent-config";
|
|
631
|
+
if (upper === "TOOLS.MD") return "tool-definitions";
|
|
632
|
+
if (upper === "HEARTBEAT.MD") return "heartbeat";
|
|
633
|
+
if (upper === "MEMORY.MD") return "memory-index";
|
|
634
|
+
if (upper === "SKILLS.MD") return "skills-index";
|
|
635
|
+
if (upper === "SKILL.MD") return "skill-definition";
|
|
636
|
+
|
|
637
|
+
if (relPath.startsWith("memory/")) return "daily-memory";
|
|
638
|
+
if (relPath.startsWith("skills/")) return "skill-related";
|
|
639
|
+
if (relPath.startsWith(".agents/")) return "agent-skill";
|
|
640
|
+
if (relPath.startsWith("sessions/")) return "session-transcript";
|
|
641
|
+
|
|
642
|
+
if (name.endsWith(".jsonl")) return "session-transcript";
|
|
643
|
+
if (name.endsWith(".json")) return "config-data";
|
|
644
|
+
|
|
645
|
+
return "workspace-file";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function summarizeFile(relPath: string, name: string, content: string): string {
|
|
649
|
+
const lineCount = content.split("\n").length;
|
|
650
|
+
const upper = name.toUpperCase();
|
|
651
|
+
|
|
652
|
+
if (upper === "IDENTITY.MD") return `Agent identity document (${lineCount} lines) — migrated from workspace`;
|
|
653
|
+
if (upper === "USER.MD") return `User profile and preferences (${lineCount} lines) — migrated from workspace`;
|
|
654
|
+
if (upper === "AGENTS.MD") return `Agent configuration (${lineCount} lines) — migrated from workspace`;
|
|
655
|
+
if (upper === "TOOLS.MD") return `Tool definitions and capabilities (${lineCount} lines) — migrated from workspace`;
|
|
656
|
+
if (upper === "HEARTBEAT.MD") return `Status heartbeat (${lineCount} lines) — migrated from workspace`;
|
|
657
|
+
if (upper === "MEMORY.MD") return `Memory index (${lineCount} lines) — migrated from workspace`;
|
|
658
|
+
if (upper === "SKILLS.MD") return `Skills index (${lineCount} lines) — migrated from workspace`;
|
|
659
|
+
if (relPath.startsWith("memory/")) return `Daily memory log: ${name} (${lineCount} lines)`;
|
|
660
|
+
if (relPath.startsWith("skills/") || relPath.startsWith(".agents/")) return `Skill file: ${relPath} (${lineCount} lines)`;
|
|
661
|
+
if (name.endsWith(".jsonl")) return `Session transcript: ${name} (${content.length} chars)`;
|
|
662
|
+
if (name.endsWith(".json")) return `Config/metadata: ${name} (${lineCount} lines)`;
|
|
663
|
+
|
|
664
|
+
return `Workspace file: ${relPath} (${lineCount} lines)`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Archiving ────────────────────────────────────────────────────────────────
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Move ingested files into .kongbrain-archive/ preserving directory structure.
|
|
671
|
+
* Uses copyFile + unlink (works across filesystems and on all OSes).
|
|
672
|
+
* SOUL.md is never touched.
|
|
673
|
+
*/
|
|
674
|
+
async function archiveFiles(
|
|
675
|
+
workspaceDir: string,
|
|
676
|
+
files: WorkspaceFile[],
|
|
677
|
+
): Promise<string> {
|
|
678
|
+
const archiveDir = join(workspaceDir, ".kongbrain-archive");
|
|
679
|
+
await mkdir(archiveDir, { recursive: true });
|
|
680
|
+
|
|
681
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
682
|
+
const movedPaths: string[] = [];
|
|
683
|
+
|
|
684
|
+
for (const file of files) {
|
|
685
|
+
if (basename(file.absPath).toUpperCase() === "SOUL.MD") continue;
|
|
686
|
+
|
|
687
|
+
const relFromRoot = relative(workspaceDir, file.absPath);
|
|
688
|
+
const destPath = join(archiveDir, relFromRoot);
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
await mkdir(dirname(destPath), { recursive: true });
|
|
692
|
+
await copyFile(file.absPath, destPath);
|
|
693
|
+
await unlink(file.absPath);
|
|
694
|
+
movedPaths.push(relFromRoot);
|
|
695
|
+
} catch (e) {
|
|
696
|
+
// Non-fatal — file might be locked, read-only, etc.
|
|
697
|
+
swallow.warn("migrate:archiveFile", e);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Write manifest
|
|
702
|
+
const manifest = [
|
|
703
|
+
`KongBrain Migration Archive`,
|
|
704
|
+
`Date: ${new Date().toISOString()}`,
|
|
705
|
+
`Platform: ${process.platform}`,
|
|
706
|
+
``,
|
|
707
|
+
`Files migrated to SurrealDB (${movedPaths.length}):`,
|
|
708
|
+
...movedPaths.map(p => ` ${p}`),
|
|
709
|
+
``,
|
|
710
|
+
`SOUL.md was left in place for soul graduation.`,
|
|
711
|
+
`To restore files, copy them back from this directory.`,
|
|
712
|
+
].join("\n");
|
|
713
|
+
|
|
714
|
+
await writeFile(join(archiveDir, `migration-${timestamp}.txt`), manifest, "utf-8");
|
|
715
|
+
|
|
716
|
+
// Clean up empty directories left behind (deepest-first)
|
|
717
|
+
const dirsToCheck = new Set<string>();
|
|
718
|
+
for (const p of movedPaths) {
|
|
719
|
+
let dir = dirname(join(workspaceDir, p));
|
|
720
|
+
while (dir !== workspaceDir && dir.length > workspaceDir.length) {
|
|
721
|
+
dirsToCheck.add(dir);
|
|
722
|
+
dir = dirname(dir);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const sortedDirs = [...dirsToCheck].sort((a, b) => b.length - a.length);
|
|
727
|
+
for (const dir of sortedDirs) {
|
|
728
|
+
try {
|
|
729
|
+
const remaining = await readdir(dir);
|
|
730
|
+
if (remaining.length === 0) {
|
|
731
|
+
await rmdir(dir);
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
// Not empty or doesn't exist — fine
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return archiveDir;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ── Utilities ────────────────────────────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
/** Simple content hash for dedup (not crypto, just fingerprint). */
|
|
744
|
+
function simpleHash(text: string): string {
|
|
745
|
+
let hash = 0;
|
|
746
|
+
for (let i = 0; i < text.length; i++) {
|
|
747
|
+
const char = text.charCodeAt(i);
|
|
748
|
+
hash = ((hash << 5) - hash) + char;
|
|
749
|
+
hash |= 0;
|
|
750
|
+
}
|
|
751
|
+
return `sh-${(hash >>> 0).toString(36)}`;
|
|
752
|
+
}
|