forge-openclaw-plugin 0.2.50 → 0.2.52

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/dist/index.html CHANGED
@@ -13,14 +13,14 @@
13
13
  />
14
14
  <link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
15
15
  <link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
16
- <script type="module" crossorigin src="/forge/assets/index-C9_gJvi6.js"></script>
16
+ <script type="module" crossorigin src="/forge/assets/index-DX8RiahO.js"></script>
17
17
  <link rel="modulepreload" crossorigin href="/forge/assets/vendor-D_NZFJze.js">
18
18
  <link rel="modulepreload" crossorigin href="/forge/assets/board-CAszQU7Y.js">
19
19
  <link rel="modulepreload" crossorigin href="/forge/assets/ui-B5MjRjKe.js">
20
20
  <link rel="modulepreload" crossorigin href="/forge/assets/motion-CU5aNClV.js">
21
21
  <link rel="modulepreload" crossorigin href="/forge/assets/table-CK0KcPYW.js">
22
22
  <link rel="stylesheet" crossorigin href="/forge/assets/vendor-DT3pnAKJ.css">
23
- <link rel="stylesheet" crossorigin href="/forge/assets/index-2_tuemtU.css">
23
+ <link rel="stylesheet" crossorigin href="/forge/assets/index-gthTrgvO.css">
24
24
  </head>
25
25
  <body class="bg-canvas text-ink antialiased">
26
26
  <div id="root"></div>
@@ -79,6 +79,7 @@ export function resolveDefaultDataRoot(currentWorkingDir = process.cwd()) {
79
79
  }
80
80
  let dataRoot = resolveDefaultDataRoot();
81
81
  let seedDemoDataEnabled = false;
82
+ let legacyWikiAutoImportEnabled = true;
82
83
  let db = null;
83
84
  let transactionDepth = 0;
84
85
  let savepointCounter = 0;
@@ -161,6 +162,9 @@ export function configureDatabase(options = {}) {
161
162
  seedDemoDataEnabled = options.seedDemoData;
162
163
  }
163
164
  }
165
+ export function configureLegacyWikiAutoImport(enabled) {
166
+ legacyWikiAutoImportEnabled = enabled;
167
+ }
164
168
  async function listMigrationFiles() {
165
169
  const files = await readdir(migrationsDir);
166
170
  return files.filter((file) => file.endsWith(".sql")).sort();
@@ -389,6 +393,13 @@ export async function initializeDatabase() {
389
393
  seedData();
390
394
  }
391
395
  ensureQuestionnaireSeeds();
396
+ if (legacyWikiAutoImportEnabled) {
397
+ const { importLegacyWikiMarkdownOnStartup } = await import("./services/legacy-wiki-markdown-import.js");
398
+ const legacyWikiImport = await importLegacyWikiMarkdownOnStartup(getDataDir());
399
+ if (legacyWikiImport.scanned > 0) {
400
+ logForgeDebug(`[forge-db] imported legacy wiki markdown scanned=${legacyWikiImport.scanned} inserted=${legacyWikiImport.inserted} updated=${legacyWikiImport.updated} backed_up=${legacyWikiImport.backedUp} backup_path=${legacyWikiImport.backupPath}`);
401
+ }
402
+ }
392
403
  }
393
404
  export function configureDatabaseSeeding(enabled) {
394
405
  seedDemoDataEnabled = enabled;
@@ -115,7 +115,7 @@ const API_TAGS = [
115
115
  },
116
116
  {
117
117
  name: "Wiki",
118
- description: "File-first wiki settings, pages, ingest, sync, health, and search."
118
+ description: "SQLite-backed wiki settings, pages, ingest, sync, health, and search."
119
119
  },
120
120
  {
121
121
  name: "Preferences",
@@ -1481,7 +1481,7 @@ export function listWikiSpaces() {
1481
1481
  const rows = getDatabase()
1482
1482
  .prepare(`SELECT id, slug, label, description, owner_user_id, visibility, created_at, updated_at
1483
1483
  FROM wiki_spaces
1484
- ORDER BY visibility ASC, updated_at DESC`)
1484
+ ORDER BY CASE WHEN visibility = 'shared' THEN 0 ELSE 1 END, updated_at DESC`)
1485
1485
  .all();
1486
1486
  const spaces = rows.map(mapWikiSpace);
1487
1487
  for (const space of spaces) {
@@ -0,0 +1,364 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, readdir, readFile, rm } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { getDatabase, getEffectiveDataRoot } from "../db.js";
6
+ import { getNoteById } from "../repositories/notes.js";
7
+ import { syncNoteWikiArtifacts } from "../repositories/wiki-memory.js";
8
+ const startupImportMarkerId = "runtime:legacy-wiki-markdown-import:v1";
9
+ function parseFrontmatter(markdown) {
10
+ const normalized = markdown.replace(/\r\n/g, "\n");
11
+ const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
12
+ if (!match) {
13
+ return { frontmatter: {}, body: normalized };
14
+ }
15
+ const frontmatter = {};
16
+ for (const line of match[1].split("\n")) {
17
+ const separatorIndex = line.indexOf(":");
18
+ if (separatorIndex <= 0) {
19
+ throw new Error(`Malformed frontmatter line: ${line}`);
20
+ }
21
+ const key = line.slice(0, separatorIndex).trim();
22
+ const rawValue = line.slice(separatorIndex + 1).trim();
23
+ if (!key) {
24
+ throw new Error(`Malformed empty frontmatter key: ${line}`);
25
+ }
26
+ try {
27
+ frontmatter[key] = JSON.parse(rawValue);
28
+ }
29
+ catch {
30
+ frontmatter[key] = rawValue.replace(/^"(.*)"$/, "$1");
31
+ }
32
+ }
33
+ return { frontmatter, body: match[2] };
34
+ }
35
+ function slugify(value) {
36
+ const normalized = value
37
+ .toLowerCase()
38
+ .normalize("NFKD")
39
+ .replace(/[^\w\s-]/g, "")
40
+ .trim()
41
+ .replace(/[\s_]+/g, "-")
42
+ .replace(/-+/g, "-");
43
+ return normalized || "imported-page";
44
+ }
45
+ function stripMarkdown(markdown) {
46
+ return markdown
47
+ .replace(/```[\s\S]*?```/g, " ")
48
+ .replace(/`([^`]+)`/g, "$1")
49
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, " ")
50
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
51
+ .replace(/[#>*_\-~]/g, " ")
52
+ .replace(/\s+/g, " ")
53
+ .trim();
54
+ }
55
+ function inferTitle(markdown, fallback) {
56
+ const heading = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim();
57
+ return heading || fallback.trim() || "Imported wiki page";
58
+ }
59
+ function inferSummary(markdown) {
60
+ return stripMarkdown(markdown).slice(0, 240);
61
+ }
62
+ function normalizeStringArray(value) {
63
+ return Array.isArray(value)
64
+ ? value.filter((entry) => typeof entry === "string")
65
+ : [];
66
+ }
67
+ function normalizeLinkedEntities(value) {
68
+ if (!Array.isArray(value)) {
69
+ return [];
70
+ }
71
+ return value.flatMap((entry) => {
72
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
73
+ return [];
74
+ }
75
+ const record = entry;
76
+ if (typeof record.entityType !== "string" ||
77
+ typeof record.entityId !== "string") {
78
+ return [];
79
+ }
80
+ return [
81
+ {
82
+ entityType: record.entityType,
83
+ entityId: record.entityId,
84
+ anchorKey: typeof record.anchorKey === "string" ? record.anchorKey : ""
85
+ }
86
+ ];
87
+ });
88
+ }
89
+ async function walkMarkdownFiles(root) {
90
+ const results = [];
91
+ async function visit(directory) {
92
+ if (!existsSync(directory)) {
93
+ return;
94
+ }
95
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
96
+ const entryPath = path.join(directory, entry.name);
97
+ if (entry.isDirectory()) {
98
+ await visit(entryPath);
99
+ continue;
100
+ }
101
+ if (entry.isFile() && entry.name.endsWith(".md")) {
102
+ results.push(entryPath);
103
+ }
104
+ }
105
+ }
106
+ await visit(root);
107
+ return results.sort();
108
+ }
109
+ function findSpaceForFile(dataRoot, filePath, parsed) {
110
+ const explicitSpaceId = typeof parsed.frontmatter.spaceId === "string"
111
+ ? parsed.frontmatter.spaceId
112
+ : "";
113
+ if (explicitSpaceId) {
114
+ const row = getDatabase()
115
+ .prepare("SELECT id FROM wiki_spaces WHERE id = ?")
116
+ .get(explicitSpaceId);
117
+ if (row) {
118
+ return row.id;
119
+ }
120
+ }
121
+ const relative = path.relative(path.join(dataRoot, "wiki"), filePath);
122
+ const parts = relative.split(path.sep);
123
+ if (parts[0] === "shared" && parts[1]) {
124
+ const row = getDatabase()
125
+ .prepare("SELECT id FROM wiki_spaces WHERE visibility = 'shared' AND slug = ?")
126
+ .get(parts[1]);
127
+ if (row) {
128
+ return row.id;
129
+ }
130
+ }
131
+ if (parts[0] === "users" && parts[1]) {
132
+ const row = getDatabase()
133
+ .prepare("SELECT id FROM wiki_spaces WHERE owner_user_id = ? OR slug = ?")
134
+ .get(parts[1], parts[1]);
135
+ if (row) {
136
+ return row.id;
137
+ }
138
+ return ensurePersonalWikiSpaceForLegacyUser(parts[1]);
139
+ }
140
+ return "wiki_space_shared";
141
+ }
142
+ function findExistingNote(input) {
143
+ if (input.id) {
144
+ const byId = getDatabase()
145
+ .prepare("SELECT id FROM notes WHERE id = ?")
146
+ .get(input.id);
147
+ if (byId) {
148
+ return byId.id;
149
+ }
150
+ }
151
+ const bySlug = getDatabase()
152
+ .prepare("SELECT id FROM notes WHERE space_id = ? AND slug = ?")
153
+ .get(input.spaceId, input.slug);
154
+ return bySlug?.id ?? null;
155
+ }
156
+ function ensurePersonalWikiSpaceForLegacyUser(userId) {
157
+ const row = getDatabase()
158
+ .prepare(`SELECT id FROM wiki_spaces
159
+ WHERE owner_user_id = ? OR slug = ? OR slug = ? OR id = ?
160
+ ORDER BY created_at ASC
161
+ LIMIT 1`)
162
+ .get(userId, userId, `user-${slugify(userId)}`, `wiki_space_user_${slugify(userId)}`);
163
+ if (row) {
164
+ return row.id;
165
+ }
166
+ const now = new Date().toISOString();
167
+ const id = `wiki_space_user_${slugify(userId)}`;
168
+ getDatabase()
169
+ .prepare(`INSERT INTO wiki_spaces (id, slug, label, description, owner_user_id, visibility, created_at, updated_at)
170
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
171
+ ON CONFLICT(id) DO NOTHING`)
172
+ .run(id, `user-${slugify(userId)}`, `${userId} Wiki`, "Personal Forge wiki space recovered from legacy wiki files.", userId, "personal", now, now);
173
+ const inserted = getDatabase()
174
+ .prepare("SELECT id FROM wiki_spaces WHERE id = ?")
175
+ .get(id);
176
+ return inserted?.id ?? "wiki_space_shared";
177
+ }
178
+ function upsertLinks(noteId, links) {
179
+ getDatabase().prepare("DELETE FROM note_links WHERE note_id = ?").run(noteId);
180
+ const createdAt = new Date().toISOString();
181
+ const statement = getDatabase().prepare(`INSERT OR IGNORE INTO note_links (note_id, entity_type, entity_id, anchor_key, created_at)
182
+ VALUES (?, ?, ?, ?, ?)`);
183
+ for (const link of links) {
184
+ statement.run(noteId, link.entityType, link.entityId, link.anchorKey, createdAt);
185
+ }
186
+ }
187
+ function legacyBackupPath(dataRoot, backupLabel) {
188
+ return path.join(dataRoot, "backups", backupLabel, "wiki");
189
+ }
190
+ async function backupWikiRootOnce(input) {
191
+ const backupPath = legacyBackupPath(input.dataRoot, input.backupLabel);
192
+ if (existsSync(backupPath)) {
193
+ return { backupPath, backedUp: false };
194
+ }
195
+ await mkdir(path.dirname(backupPath), { recursive: true });
196
+ await cp(input.wikiRoot, backupPath, { recursive: true, force: false });
197
+ return { backupPath, backedUp: true };
198
+ }
199
+ export async function importLegacyWikiMarkdownToSqlite(input = {}) {
200
+ const options = {
201
+ dataRoot: path.resolve(input.dataRoot ?? getEffectiveDataRoot()),
202
+ apply: input.apply ?? false,
203
+ deleteFiles: input.deleteFiles ?? false,
204
+ backupBeforeApply: input.backupBeforeApply ?? false,
205
+ backupLabel: input.backupLabel ?? "legacy-wiki-markdown-pre-sqlite-import",
206
+ preserveExistingNotes: input.preserveExistingNotes ?? false
207
+ };
208
+ const wikiRoot = path.join(options.dataRoot, "wiki");
209
+ const files = await walkMarkdownFiles(wikiRoot);
210
+ let scanned = 0;
211
+ let inserted = 0;
212
+ let updated = 0;
213
+ let deleted = 0;
214
+ let backupPath = legacyBackupPath(options.dataRoot, options.backupLabel);
215
+ let backedUp = false;
216
+ if (options.apply && options.backupBeforeApply && files.length > 0) {
217
+ const backup = await backupWikiRootOnce({
218
+ wikiRoot,
219
+ dataRoot: options.dataRoot,
220
+ backupLabel: options.backupLabel
221
+ });
222
+ backupPath = backup.backupPath;
223
+ backedUp = backup.backedUp;
224
+ }
225
+ for (const filePath of files) {
226
+ scanned += 1;
227
+ const parsed = parseFrontmatter(await readFile(filePath, "utf8"));
228
+ const kind = filePath.includes(`${path.sep}pages${path.sep}`)
229
+ ? "wiki"
230
+ : "evidence";
231
+ const spaceId = findSpaceForFile(options.dataRoot, filePath, parsed);
232
+ const markdown = parsed.body.trim();
233
+ const id = typeof parsed.frontmatter.id === "string" && parsed.frontmatter.id.trim()
234
+ ? parsed.frontmatter.id.trim()
235
+ : `note_${createHash("sha1").update(filePath).digest("hex").slice(0, 10)}`;
236
+ const title = typeof parsed.frontmatter.title === "string"
237
+ ? parsed.frontmatter.title
238
+ : inferTitle(markdown, path.basename(filePath, ".md"));
239
+ const slug = typeof parsed.frontmatter.slug === "string"
240
+ ? parsed.frontmatter.slug
241
+ : slugify(path.basename(filePath, ".md"));
242
+ const aliases = normalizeStringArray(parsed.frontmatter.aliases);
243
+ const tags = normalizeStringArray(parsed.frontmatter.tags);
244
+ const summary = typeof parsed.frontmatter.summary === "string"
245
+ ? parsed.frontmatter.summary
246
+ : inferSummary(markdown);
247
+ const contentPlain = stripMarkdown(markdown);
248
+ const links = normalizeLinkedEntities(parsed.frontmatter.linkedEntities);
249
+ const noteId = findExistingNote({ id, spaceId, slug });
250
+ const now = new Date().toISOString();
251
+ if (!options.apply) {
252
+ if (noteId) {
253
+ updated += 1;
254
+ }
255
+ else {
256
+ inserted += 1;
257
+ }
258
+ continue;
259
+ }
260
+ if (noteId) {
261
+ const existingNote = getNoteById(noteId, { skipCleanup: true });
262
+ if (options.preserveExistingNotes &&
263
+ existingNote &&
264
+ existingNote.contentMarkdown.trim().length > 0) {
265
+ getDatabase()
266
+ .prepare("UPDATE notes SET source_path = '' WHERE id = ? AND source_path <> ''")
267
+ .run(noteId);
268
+ syncNoteWikiArtifacts(existingNote);
269
+ updated += 1;
270
+ continue;
271
+ }
272
+ getDatabase()
273
+ .prepare(`UPDATE notes
274
+ SET kind = ?, title = ?, slug = ?, space_id = ?, parent_slug = ?, index_order = ?, show_in_index = ?,
275
+ aliases_json = ?, summary = ?, content_markdown = ?, content_plain = ?, tags_json = ?,
276
+ source_path = '', frontmatter_json = ?, updated_at = ?
277
+ WHERE id = ?`)
278
+ .run(kind, title, slug, spaceId, typeof parsed.frontmatter.parentSlug === "string"
279
+ ? parsed.frontmatter.parentSlug
280
+ : null, typeof parsed.frontmatter.indexOrder === "number"
281
+ ? Math.trunc(parsed.frontmatter.indexOrder)
282
+ : 0, parsed.frontmatter.showInIndex === false ? 0 : 1, JSON.stringify(aliases), summary, markdown, contentPlain, JSON.stringify(tags), JSON.stringify(parsed.frontmatter), now, noteId);
283
+ upsertLinks(noteId, links);
284
+ const note = getNoteById(noteId, { skipCleanup: true });
285
+ if (note) {
286
+ syncNoteWikiArtifacts(note);
287
+ }
288
+ updated += 1;
289
+ }
290
+ else {
291
+ getDatabase()
292
+ .prepare(`INSERT INTO notes (
293
+ id, kind, title, slug, space_id, parent_slug, index_order, show_in_index, aliases_json, summary,
294
+ content_markdown, content_plain, author, source, tags_json, destroy_at, source_path, frontmatter_json,
295
+ revision_hash, last_synced_at, created_at, updated_at
296
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, '', NULL, ?, ?)`)
297
+ .run(id, kind, title, slug, spaceId, typeof parsed.frontmatter.parentSlug === "string"
298
+ ? parsed.frontmatter.parentSlug
299
+ : null, typeof parsed.frontmatter.indexOrder === "number"
300
+ ? Math.trunc(parsed.frontmatter.indexOrder)
301
+ : 0, parsed.frontmatter.showInIndex === false ? 0 : 1, JSON.stringify(aliases), summary, markdown, contentPlain, typeof parsed.frontmatter.author === "string"
302
+ ? parsed.frontmatter.author
303
+ : null, "system", JSON.stringify(tags), null, JSON.stringify(parsed.frontmatter), now, now);
304
+ upsertLinks(id, links);
305
+ const note = getNoteById(id, { skipCleanup: true });
306
+ if (note) {
307
+ syncNoteWikiArtifacts(note);
308
+ }
309
+ inserted += 1;
310
+ }
311
+ if (options.deleteFiles) {
312
+ await rm(filePath, { force: true });
313
+ deleted += 1;
314
+ }
315
+ }
316
+ return {
317
+ dataRoot: options.dataRoot,
318
+ scanned,
319
+ wouldApply: options.apply,
320
+ wouldDelete: options.deleteFiles,
321
+ inserted,
322
+ updated,
323
+ deleted,
324
+ backupPath,
325
+ backedUp,
326
+ skippedAlreadyImported: false
327
+ };
328
+ }
329
+ function hasStartupImportMarker() {
330
+ const row = getDatabase()
331
+ .prepare("SELECT id FROM migrations WHERE id = ?")
332
+ .get(startupImportMarkerId);
333
+ return Boolean(row);
334
+ }
335
+ function markStartupImportComplete() {
336
+ getDatabase()
337
+ .prepare("INSERT OR IGNORE INTO migrations (id, applied_at) VALUES (?, ?)")
338
+ .run(startupImportMarkerId, new Date().toISOString());
339
+ }
340
+ export async function importLegacyWikiMarkdownOnStartup(dataRoot = getEffectiveDataRoot()) {
341
+ if (hasStartupImportMarker()) {
342
+ return {
343
+ dataRoot: path.resolve(dataRoot),
344
+ scanned: 0,
345
+ wouldApply: true,
346
+ wouldDelete: false,
347
+ inserted: 0,
348
+ updated: 0,
349
+ deleted: 0,
350
+ backupPath: legacyBackupPath(path.resolve(dataRoot), "legacy-wiki-markdown-pre-sqlite-import"),
351
+ backedUp: false,
352
+ skippedAlreadyImported: true
353
+ };
354
+ }
355
+ const result = await importLegacyWikiMarkdownToSqlite({
356
+ dataRoot,
357
+ apply: true,
358
+ deleteFiles: false,
359
+ backupBeforeApply: true,
360
+ preserveExistingNotes: true
361
+ });
362
+ markStartupImportComplete();
363
+ return result;
364
+ }
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.50",
5
+ "version": "0.2.52",
6
6
  "skills": [
7
7
  "./skills"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.50",
3
+ "version": "0.2.52",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "MIT",