forge-openclaw-plugin 0.2.20 → 0.2.21

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 (33) hide show
  1. package/dist/assets/{board-DGbXWEuu.js → board-_C6oMy5w.js} +2 -2
  2. package/dist/assets/{board-DGbXWEuu.js.map → board-_C6oMy5w.js.map} +1 -1
  3. package/dist/assets/index-B4A6TooJ.js +63 -0
  4. package/dist/assets/index-B4A6TooJ.js.map +1 -0
  5. package/dist/assets/index-D6Xs_2mo.css +1 -0
  6. package/dist/assets/{motion-B5Qoz2Ci.js → motion-D4sZgCHd.js} +2 -2
  7. package/dist/assets/{motion-B5Qoz2Ci.js.map → motion-D4sZgCHd.js.map} +1 -1
  8. package/dist/assets/{table-D_iurDQu.js → table-BWzTaky1.js} +2 -2
  9. package/dist/assets/{table-D_iurDQu.js.map → table-BWzTaky1.js.map} +1 -1
  10. package/dist/assets/{ui-D5QUYUq4.js → ui-BzK4azQb.js} +2 -2
  11. package/dist/assets/{ui-D5QUYUq4.js.map → ui-BzK4azQb.js.map} +1 -1
  12. package/dist/assets/vendor-De38P6YR.js +729 -0
  13. package/dist/assets/vendor-De38P6YR.js.map +1 -0
  14. package/dist/assets/{viz-BD9WSxHz.js → viz-C6hfyqzu.js} +2 -2
  15. package/dist/assets/{viz-BD9WSxHz.js.map → viz-C6hfyqzu.js.map} +1 -1
  16. package/dist/index.html +8 -8
  17. package/dist/server/app.js +301 -19
  18. package/dist/server/health.js +82 -21
  19. package/dist/server/managers/platform/background-job-manager.js +103 -8
  20. package/dist/server/managers/platform/llm-manager.js +91 -5
  21. package/dist/server/managers/platform/openai-responses-provider.js +683 -70
  22. package/dist/server/repositories/diagnostic-logs.js +243 -0
  23. package/dist/server/repositories/wiki-memory.js +595 -62
  24. package/dist/server/types.js +56 -0
  25. package/openclaw.plugin.json +1 -1
  26. package/package.json +1 -1
  27. package/server/migrations/023_diagnostic_logs.sql +28 -0
  28. package/skills/forge-openclaw/SKILL.md +14 -0
  29. package/dist/assets/index-4-1WI9i7.css +0 -1
  30. package/dist/assets/index-BZbHajNK.js +0 -63
  31. package/dist/assets/index-BZbHajNK.js.map +0 -1
  32. package/dist/assets/vendor-KARp8LAR.js +0 -706
  33. package/dist/assets/vendor-KARp8LAR.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import AdmZip from "adm-zip";
3
- import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, rmSync, writeFileSync } from "node:fs";
4
4
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { z } from "zod";
@@ -8,6 +8,7 @@ import { resolveDataDir, getDatabase } from "../db.js";
8
8
  import { decorateOwnedEntity } from "./entity-ownership.js";
9
9
  import { createNoteLinkSchema, crudEntityTypeSchema, noteKindSchema, noteSchema as persistedNoteSchema, wikiSearchModeSchema, wikiSpaceVisibilitySchema } from "../types.js";
10
10
  import { deleteEncryptedSecret, readEncryptedSecret, storeEncryptedSecret } from "./calendar.js";
11
+ import { recordDiagnosticLog } from "./diagnostic-logs.js";
11
12
  const wikiSpaceSchema = z.object({
12
13
  id: z.string(),
13
14
  slug: z.string(),
@@ -201,13 +202,52 @@ const wikiIngestJobPayloadSchema = z.object({
201
202
  });
202
203
  const listWikiIngestJobsQuerySchema = z.object({
203
204
  spaceId: z.string().trim().optional(),
204
- limit: z.coerce.number().int().positive().max(50).default(20)
205
+ limit: z.coerce.number().int().positive().max(200).default(20)
205
206
  });
206
207
  export const reviewWikiIngestJobSchema = z.object({
207
208
  decisions: z
208
- .array(z.object({
209
+ .array(z
210
+ .object({
209
211
  candidateId: z.string().trim().min(1),
210
- keep: z.boolean()
212
+ keep: z.boolean().optional(),
213
+ action: z
214
+ .enum(["keep", "discard", "map_existing", "merge_existing"])
215
+ .optional(),
216
+ mappedEntityType: crudEntityTypeSchema.optional(),
217
+ mappedEntityId: z.string().trim().min(1).optional(),
218
+ targetNoteId: z.string().trim().min(1).optional()
219
+ })
220
+ .superRefine((value, context) => {
221
+ if (value.action === "map_existing") {
222
+ if (!value.mappedEntityType) {
223
+ context.addIssue({
224
+ code: z.ZodIssueCode.custom,
225
+ path: ["mappedEntityType"],
226
+ message: "mappedEntityType is required when action is map_existing"
227
+ });
228
+ }
229
+ if (!value.mappedEntityId) {
230
+ context.addIssue({
231
+ code: z.ZodIssueCode.custom,
232
+ path: ["mappedEntityId"],
233
+ message: "mappedEntityId is required when action is map_existing"
234
+ });
235
+ }
236
+ }
237
+ if (value.action === "merge_existing" && !value.targetNoteId) {
238
+ context.addIssue({
239
+ code: z.ZodIssueCode.custom,
240
+ path: ["targetNoteId"],
241
+ message: "targetNoteId is required when action is merge_existing"
242
+ });
243
+ }
244
+ if (value.action === undefined && value.keep === undefined) {
245
+ context.addIssue({
246
+ code: z.ZodIssueCode.custom,
247
+ path: ["action"],
248
+ message: "Either keep or action is required"
249
+ });
250
+ }
211
251
  }))
212
252
  .min(1)
213
253
  });
@@ -218,6 +258,14 @@ export const createWikiSpaceSchema = z.object({
218
258
  ownerUserId: z.string().trim().nullable().optional(),
219
259
  visibility: wikiSpaceVisibilitySchema.default("personal")
220
260
  });
261
+ const wikiLlmReasoningEffortSchema = z.enum([
262
+ "none",
263
+ "low",
264
+ "medium",
265
+ "high",
266
+ "xhigh"
267
+ ]);
268
+ const wikiLlmVerbositySchema = z.enum(["low", "medium", "high"]);
221
269
  export const upsertWikiLlmProfileSchema = z.object({
222
270
  id: z.string().trim().optional(),
223
271
  label: z.string().trim().min(1),
@@ -226,9 +274,20 @@ export const upsertWikiLlmProfileSchema = z.object({
226
274
  model: z.string().trim().min(1),
227
275
  apiKey: z.string().trim().optional(),
228
276
  systemPrompt: z.string().trim().default(""),
277
+ reasoningEffort: wikiLlmReasoningEffortSchema.optional(),
278
+ verbosity: wikiLlmVerbositySchema.optional(),
229
279
  enabled: z.boolean().default(true),
230
280
  metadata: z.record(z.string(), z.unknown()).default({})
231
281
  });
282
+ export const testWikiLlmProfileSchema = z.object({
283
+ profileId: z.string().trim().optional(),
284
+ provider: z.string().trim().min(1).default("openai-responses"),
285
+ baseUrl: z.string().trim().default("https://api.openai.com/v1"),
286
+ model: z.string().trim().min(1),
287
+ apiKey: z.string().trim().optional(),
288
+ reasoningEffort: wikiLlmReasoningEffortSchema.optional(),
289
+ verbosity: wikiLlmVerbositySchema.optional()
290
+ });
232
291
  export const upsertWikiEmbeddingProfileSchema = z.object({
233
292
  id: z.string().trim().optional(),
234
293
  label: z.string().trim().min(1),
@@ -390,6 +449,10 @@ function parseJsonStringArray(raw) {
390
449
  return [];
391
450
  }
392
451
  }
452
+ function readStringRecordValue(record, key) {
453
+ const value = record[key];
454
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
455
+ }
393
456
  function listLinkRowsForNotes(noteIds) {
394
457
  if (noteIds.length === 0) {
395
458
  return [];
@@ -497,6 +560,32 @@ function inferSummary(markdown) {
497
560
  const plain = buildContentPlain(markdown).replace(/\s+/g, " ").trim();
498
561
  return plain.slice(0, 240);
499
562
  }
563
+ function stripLeadingHeading(markdown, title) {
564
+ const normalizedTitle = title.trim().toLowerCase();
565
+ const trimmed = markdown.trim();
566
+ const match = trimmed.match(/^#\s+(.+?)\n+/);
567
+ if (!match) {
568
+ return trimmed;
569
+ }
570
+ const heading = match[1]?.trim().toLowerCase() ?? "";
571
+ if (heading !== normalizedTitle) {
572
+ return trimmed;
573
+ }
574
+ return trimmed.slice(match[0].length).trim();
575
+ }
576
+ function mergeWikiPageContent(targetMarkdown, incoming) {
577
+ const mergedBody = stripLeadingHeading(incoming.markdown, incoming.title) ||
578
+ incoming.markdown.trim();
579
+ return [
580
+ targetMarkdown.trim(),
581
+ "",
582
+ `## ${incoming.title}`,
583
+ "",
584
+ mergedBody
585
+ ]
586
+ .filter((part) => part.trim().length > 0)
587
+ .join("\n");
588
+ }
500
589
  function slugify(value) {
501
590
  const normalized = value
502
591
  .toLowerCase()
@@ -1876,6 +1965,21 @@ export function upsertWikiLlmProfile(input, secrets) {
1876
1965
  `wiki_llm_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1877
1966
  storeEncryptedSecret(secretId, secrets.sealJson({ apiKey: parsed.apiKey.trim() }), `${parsed.label} wiki LLM profile`);
1878
1967
  }
1968
+ const metadata = {
1969
+ ...parsed.metadata
1970
+ };
1971
+ if (parsed.reasoningEffort) {
1972
+ metadata.reasoningEffort = parsed.reasoningEffort;
1973
+ }
1974
+ else {
1975
+ delete metadata.reasoningEffort;
1976
+ }
1977
+ if (parsed.verbosity) {
1978
+ metadata.verbosity = parsed.verbosity;
1979
+ }
1980
+ else {
1981
+ delete metadata.verbosity;
1982
+ }
1879
1983
  getDatabase()
1880
1984
  .prepare(`INSERT INTO wiki_llm_profiles (id, label, provider, base_url, model, secret_id, system_prompt, enabled, metadata_json, created_at, updated_at)
1881
1985
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1889,7 +1993,7 @@ export function upsertWikiLlmProfile(input, secrets) {
1889
1993
  enabled = excluded.enabled,
1890
1994
  metadata_json = excluded.metadata_json,
1891
1995
  updated_at = excluded.updated_at`)
1892
- .run(id, parsed.label, parsed.provider, parsed.baseUrl, parsed.model, secretId, parsed.systemPrompt, parsed.enabled ? 1 : 0, JSON.stringify(parsed.metadata), now, now);
1996
+ .run(id, parsed.label, parsed.provider, parsed.baseUrl, parsed.model, secretId, parsed.systemPrompt, parsed.enabled ? 1 : 0, JSON.stringify(metadata), now, now);
1893
1997
  return listWikiLlmProfiles().find((entry) => entry.id === id);
1894
1998
  }
1895
1999
  export function upsertWikiEmbeddingProfile(input, secrets) {
@@ -2088,11 +2192,103 @@ function updateWikiIngestJob(jobId, patch) {
2088
2192
  .run(next.status, next.phase, next.progressPercent, next.totalFiles, next.processedFiles, next.createdPageCount, next.createdEntityCount, next.acceptedCount, next.rejectedCount, next.latestMessage, next.sourceLocator, next.mimeType, next.summary, next.pageNoteId, next.errorMessage, next.completedAt, next.updatedAt, jobId);
2089
2193
  return getWikiIngestJob(jobId);
2090
2194
  }
2091
- function createWikiIngestLog(jobId, message, level = "info", metadata = {}) {
2092
- getDatabase()
2093
- .prepare(`INSERT INTO wiki_ingest_job_logs (id, job_id, level, message, metadata_json, created_at)
2094
- VALUES (?, ?, ?, ?, ?, ?)`)
2095
- .run(`wiki_ingest_log_${randomUUID().replaceAll("-", "").slice(0, 10)}`, jobId, level, message, JSON.stringify(metadata), nowIso());
2195
+ function createWikiIngestLog(jobId, message, level = "info", metadata = {}, options = {}) {
2196
+ const createdAt = nowIso();
2197
+ const logMetadata = options.aggregateKey
2198
+ ? { ...metadata, aggregateKey: options.aggregateKey }
2199
+ : metadata;
2200
+ const aggregateKey = options.aggregateKey?.trim() || null;
2201
+ if (aggregateKey) {
2202
+ const current = [...listWikiIngestJobLogsInternal(jobId)]
2203
+ .reverse()
2204
+ .find((entry) => {
2205
+ const parsed = parseJsonRecord(entry.metadata_json);
2206
+ return parsed.aggregateKey === aggregateKey;
2207
+ });
2208
+ if (current) {
2209
+ getDatabase()
2210
+ .prepare(`UPDATE wiki_ingest_job_logs
2211
+ SET level = ?, message = ?, metadata_json = ?, created_at = ?
2212
+ WHERE id = ?`)
2213
+ .run(level, message, JSON.stringify(logMetadata), createdAt, current.id);
2214
+ }
2215
+ else {
2216
+ getDatabase()
2217
+ .prepare(`INSERT INTO wiki_ingest_job_logs (id, job_id, level, message, metadata_json, created_at)
2218
+ VALUES (?, ?, ?, ?, ?, ?)`)
2219
+ .run(`wiki_ingest_log_${randomUUID().replaceAll("-", "").slice(0, 10)}`, jobId, level, message, JSON.stringify(logMetadata), createdAt);
2220
+ }
2221
+ }
2222
+ else {
2223
+ getDatabase()
2224
+ .prepare(`INSERT INTO wiki_ingest_job_logs (id, job_id, level, message, metadata_json, created_at)
2225
+ VALUES (?, ?, ?, ?, ?, ?)`)
2226
+ .run(`wiki_ingest_log_${randomUUID().replaceAll("-", "").slice(0, 10)}`, jobId, level, message, JSON.stringify(logMetadata), createdAt);
2227
+ }
2228
+ if (options.recordDiagnostic !== false) {
2229
+ recordDiagnosticLog({
2230
+ level,
2231
+ source: "server",
2232
+ scope: typeof metadata.scope === "string" && metadata.scope.trim()
2233
+ ? metadata.scope
2234
+ : "wiki_ingest",
2235
+ eventKey: typeof metadata.eventKey === "string" && metadata.eventKey.trim()
2236
+ ? metadata.eventKey
2237
+ : "wiki_ingest_log",
2238
+ message,
2239
+ functionName: "createWikiIngestLog",
2240
+ entityType: "wiki_ingest_job",
2241
+ entityId: jobId,
2242
+ jobId,
2243
+ details: logMetadata
2244
+ });
2245
+ }
2246
+ }
2247
+ function findOpenAiResponseIdForJobAsset(input) {
2248
+ const normalizedFileName = input.fileName.trim().toLowerCase();
2249
+ const normalizedSourceLocator = input.sourceLocator.trim().toLowerCase();
2250
+ const normalizedChecksum = input.checksum.trim().toLowerCase();
2251
+ const sameNamedAssets = listWikiIngestJobAssetsInternal(input.jobId).filter((asset) => asset.file_name.trim().toLowerCase() === normalizedFileName).length;
2252
+ const logs = [...listWikiIngestJobLogsInternal(input.jobId)].reverse();
2253
+ for (const entry of logs) {
2254
+ const metadata = parseJsonRecord(entry.metadata_json);
2255
+ const responseId = readStringRecordValue(metadata, "responseId");
2256
+ if (!responseId) {
2257
+ continue;
2258
+ }
2259
+ const loggedAssetId = readStringRecordValue(metadata, "sourceAssetId") ??
2260
+ readStringRecordValue(metadata, "assetId");
2261
+ if (loggedAssetId) {
2262
+ if (loggedAssetId === input.assetId) {
2263
+ return responseId;
2264
+ }
2265
+ continue;
2266
+ }
2267
+ const loggedSourceLocator = readStringRecordValue(metadata, "sourceLocator");
2268
+ if (loggedSourceLocator) {
2269
+ if (loggedSourceLocator.trim().toLowerCase() === normalizedSourceLocator) {
2270
+ return responseId;
2271
+ }
2272
+ continue;
2273
+ }
2274
+ const loggedChecksum = readStringRecordValue(metadata, "checksum");
2275
+ if (loggedChecksum) {
2276
+ if (loggedChecksum.trim().toLowerCase() === normalizedChecksum) {
2277
+ return responseId;
2278
+ }
2279
+ continue;
2280
+ }
2281
+ const loggedFileName = readStringRecordValue(metadata, "currentFileName") ??
2282
+ readStringRecordValue(metadata, "fileName");
2283
+ if (!loggedFileName) {
2284
+ return responseId;
2285
+ }
2286
+ if (sameNamedAssets === 1 &&
2287
+ loggedFileName.trim().toLowerCase() === normalizedFileName) {
2288
+ return responseId;
2289
+ }
2290
+ }
2291
+ return null;
2096
2292
  }
2097
2293
  function createWikiIngestAssetRecord(input) {
2098
2294
  const id = `wiki_ingest_asset_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
@@ -2121,6 +2317,15 @@ function updateWikiIngestAsset(assetId, patch) {
2121
2317
  .run(patch.status ?? current.status, patch.filePath ?? current.file_path, patch.mimeType ?? current.mime_type, patch.sizeBytes ?? current.size_bytes, patch.checksum ?? current.checksum, JSON.stringify(patch.metadata ?? parseJsonRecord(current.metadata_json)), nowIso(), assetId);
2122
2318
  }
2123
2319
  function createWikiIngestCandidate(input) {
2320
+ const existing = listWikiIngestCandidatesInternal(input.jobId).find((candidate) => candidate.source_asset_id === (input.sourceAssetId ?? null) &&
2321
+ candidate.candidate_type === input.candidateType &&
2322
+ candidate.title === (input.title ?? "") &&
2323
+ candidate.summary === (input.summary ?? "") &&
2324
+ candidate.target_key === (input.targetKey ?? "") &&
2325
+ candidate.payload_json === JSON.stringify(input.payload));
2326
+ if (existing) {
2327
+ return existing.id;
2328
+ }
2124
2329
  const id = `wiki_ingest_candidate_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
2125
2330
  const now = nowIso();
2126
2331
  getDatabase()
@@ -2338,8 +2543,13 @@ function createPageCandidatePayload(input) {
2338
2543
  summary: input.summary,
2339
2544
  contentMarkdown: input.markdown,
2340
2545
  kind: input.createAsKind,
2546
+ aliases: normalizeTags(input.aliases),
2547
+ parentSlug: typeof input.parentSlug === "string" && input.parentSlug.trim().length > 0
2548
+ ? input.parentSlug.trim()
2549
+ : null,
2341
2550
  sourceLocator: input.sourceLocator,
2342
- links: input.linkedEntityHints
2551
+ links: input.linkedEntityHints,
2552
+ tags: normalizeTags(input.tags)
2343
2553
  };
2344
2554
  }
2345
2555
  export async function processWikiIngestJob(jobId, options) {
@@ -2355,12 +2565,114 @@ export async function processWikiIngestJob(jobId, options) {
2355
2565
  ? (listWikiLlmProfiles().find((entry) => entry.id === parsed.llmProfileId) ?? null)
2356
2566
  : null;
2357
2567
  const space = getWikiSpaceById(job.space_id) ?? ensureSharedWikiSpace();
2568
+ if (parsed.llmProfileId && !llmProfile) {
2569
+ updateWikiIngestJob(jobId, {
2570
+ status: "failed",
2571
+ phase: "failed",
2572
+ latestMessage: "The selected LLM profile could not be found.",
2573
+ errorMessage: "The selected LLM profile could not be found.",
2574
+ completedAt: nowIso()
2575
+ });
2576
+ createWikiIngestLog(jobId, "The selected LLM profile could not be found.", "error");
2577
+ return getWikiIngestJob(jobId);
2578
+ }
2358
2579
  updateWikiIngestJob(jobId, {
2359
2580
  status: "processing",
2360
2581
  phase: "processing",
2361
- latestMessage: "Processing queued wiki ingest sources."
2582
+ latestMessage: "Processing queued wiki ingest sources.",
2583
+ errorMessage: "",
2584
+ completedAt: null
2362
2585
  });
2363
2586
  createWikiIngestLog(jobId, "Started background wiki ingestion.");
2587
+ let currentAssetContext = null;
2588
+ let currentPollCount = 0;
2589
+ const updateCurrentAssetMetadata = (patch) => {
2590
+ if (!currentAssetContext) {
2591
+ return;
2592
+ }
2593
+ const asset = listWikiIngestJobAssetsInternal(jobId).find((entry) => entry.id === currentAssetContext?.assetId);
2594
+ if (!asset) {
2595
+ return;
2596
+ }
2597
+ updateWikiIngestAsset(asset.id, {
2598
+ metadata: {
2599
+ ...parseJsonRecord(asset.metadata_json),
2600
+ ...patch
2601
+ }
2602
+ });
2603
+ };
2604
+ const llmDiagnosticLogger = ({ level, message, details = {} }) => {
2605
+ const enrichedDetails = {
2606
+ ...details,
2607
+ sourceAssetId: currentAssetContext?.assetId ?? null,
2608
+ currentFileName: currentAssetContext?.fileName ?? null,
2609
+ sourceLocator: currentAssetContext?.sourceLocator ?? null,
2610
+ checksum: currentAssetContext?.checksum ?? null,
2611
+ currentFileIndex: currentAssetContext?.fileIndex ?? null,
2612
+ currentFileTotal: currentAssetContext?.totalFiles ?? null,
2613
+ chunkIndex: typeof details.chunkIndex === "number"
2614
+ ? details.chunkIndex
2615
+ : currentAssetContext?.chunkIndex ?? null,
2616
+ chunkCount: typeof details.chunkCount === "number"
2617
+ ? details.chunkCount
2618
+ : currentAssetContext?.chunkCount ?? null
2619
+ };
2620
+ const eventKey = typeof details.eventKey === "string" ? details.eventKey : "";
2621
+ if (eventKey === "llm_compile_background_started") {
2622
+ currentPollCount = 0;
2623
+ updateCurrentAssetMetadata({
2624
+ openAiResponseId: typeof details.responseId === "string" ? details.responseId : null,
2625
+ openAiResponseStatus: typeof details.status === "string" ? details.status : "queued",
2626
+ openAiLastPolledAt: nowIso()
2627
+ });
2628
+ }
2629
+ if (eventKey === "llm_compile_background_polled") {
2630
+ currentPollCount += 1;
2631
+ const pollStatus = typeof details.status === "string" ? details.status : "in_progress";
2632
+ const chunkSuffix = (currentAssetContext?.chunkCount ?? 1) > 1
2633
+ ? ` · chunk ${currentAssetContext?.chunkIndex ?? 1}/${currentAssetContext?.chunkCount ?? 1}`
2634
+ : "";
2635
+ const fileLabel = currentAssetContext?.fileName ?? "current source";
2636
+ const progressMessage = `Waiting for OpenAI on ${fileLabel}${chunkSuffix}. Poll ${currentPollCount} · ${pollStatus}.`;
2637
+ updateWikiIngestJob(jobId, {
2638
+ latestMessage: progressMessage
2639
+ });
2640
+ updateCurrentAssetMetadata({
2641
+ openAiResponseId: typeof details.responseId === "string" ? details.responseId : null,
2642
+ openAiResponseStatus: pollStatus,
2643
+ openAiLastPolledAt: nowIso(),
2644
+ openAiPollCount: currentPollCount
2645
+ });
2646
+ createWikiIngestLog(jobId, progressMessage, "info", {
2647
+ ...enrichedDetails,
2648
+ pollCount: currentPollCount,
2649
+ status: pollStatus
2650
+ }, {
2651
+ aggregateKey: `llm_compile_background_polled:${currentAssetContext?.assetId ?? "job"}`,
2652
+ recordDiagnostic: false
2653
+ });
2654
+ return;
2655
+ }
2656
+ if (eventKey === "llm_compile_success" ||
2657
+ eventKey === "llm_compile_unparseable" ||
2658
+ eventKey === "llm_compile_background_terminal_error") {
2659
+ updateCurrentAssetMetadata({
2660
+ openAiResponseId: typeof details.responseId === "string" ? details.responseId : null,
2661
+ openAiResponseStatus: eventKey === "llm_compile_background_terminal_error"
2662
+ ? typeof details.status === "string"
2663
+ ? details.status
2664
+ : "failed"
2665
+ : "completed",
2666
+ openAiLastPolledAt: nowIso(),
2667
+ openAiPollCount: currentPollCount
2668
+ });
2669
+ }
2670
+ createWikiIngestLog(jobId, message, level === "debug" ? "info" : level, {
2671
+ scope: "wiki_llm",
2672
+ eventKey: "wiki_llm_event",
2673
+ ...enrichedDetails
2674
+ });
2675
+ };
2364
2676
  const refreshCounts = () => {
2365
2677
  const candidates = listWikiIngestCandidatesInternal(jobId);
2366
2678
  const pageCount = candidates.filter((candidate) => candidate.candidate_type === "page").length;
@@ -2368,11 +2680,12 @@ export async function processWikiIngestJob(jobId, options) {
2368
2680
  return { pageCount, entityCount };
2369
2681
  };
2370
2682
  const assetQueue = () => listWikiIngestJobAssetsInternal(jobId).filter((asset) => ["queued", "processing"].includes(asset.status));
2371
- let processedFiles = job.processed_files;
2372
- let totalFiles = job.total_files;
2683
+ const initialAssets = listWikiIngestJobAssetsInternal(jobId);
2684
+ let processedFiles = initialAssets.filter((asset) => asset.status === "completed").length;
2685
+ let totalFiles = Math.max(job.total_files, initialAssets.length);
2373
2686
  let hadSuccess = false;
2374
2687
  while (assetQueue().length > 0) {
2375
- const nextAsset = assetQueue().find((asset) => asset.status === "queued");
2688
+ const nextAsset = assetQueue().find((asset) => ["processing", "queued"].includes(asset.status));
2376
2689
  if (!nextAsset) {
2377
2690
  break;
2378
2691
  }
@@ -2433,7 +2746,27 @@ export async function processWikiIngestJob(jobId, options) {
2433
2746
  continue;
2434
2747
  }
2435
2748
  updateWikiIngestAsset(nextAsset.id, { status: "processing" });
2436
- createWikiIngestLog(jobId, `Processing ${nextAsset.file_name || nextAsset.source_locator}.`);
2749
+ currentAssetContext = {
2750
+ assetId: nextAsset.id,
2751
+ fileName: nextAsset.file_name || nextAsset.source_locator || "Source",
2752
+ sourceLocator: nextAsset.source_locator,
2753
+ checksum: nextAsset.checksum,
2754
+ fileIndex: Math.min(totalFiles, processedFiles + 1),
2755
+ totalFiles,
2756
+ chunkIndex: 1,
2757
+ chunkCount: 1
2758
+ };
2759
+ currentPollCount = 0;
2760
+ createWikiIngestLog(jobId, `Processing ${currentAssetContext.fileName} (${currentAssetContext.fileIndex}/${currentAssetContext.totalFiles}).`, "info", {
2761
+ sourceAssetId: currentAssetContext.assetId,
2762
+ fileName: currentAssetContext.fileName,
2763
+ sourceLocator: currentAssetContext.sourceLocator,
2764
+ checksum: currentAssetContext.checksum,
2765
+ fileIndex: currentAssetContext.fileIndex,
2766
+ totalFiles: currentAssetContext.totalFiles,
2767
+ chunkIndex: currentAssetContext.chunkIndex,
2768
+ chunkCount: currentAssetContext.chunkCount
2769
+ });
2437
2770
  try {
2438
2771
  const fetched = nextAsset.source_kind === "url"
2439
2772
  ? await getFetchedContent("url", {
@@ -2451,6 +2784,15 @@ export async function processWikiIngestJob(jobId, options) {
2451
2784
  jobId,
2452
2785
  fetched
2453
2786
  });
2787
+ const existingAssetMetadata = parseJsonRecord(nextAsset.metadata_json);
2788
+ const resumeResponseId = readStringRecordValue(existingAssetMetadata, "openAiResponseId") ??
2789
+ findOpenAiResponseIdForJobAsset({
2790
+ jobId,
2791
+ assetId: nextAsset.id,
2792
+ fileName: nextAsset.file_name,
2793
+ sourceLocator: nextAsset.source_locator,
2794
+ checksum: nextAsset.checksum
2795
+ });
2454
2796
  const compiled = llmProfile && parsed.parseStrategy !== "text_only"
2455
2797
  ? await options.llm.compileWikiIngest(llmProfile, {
2456
2798
  titleHint: parsed.titleHint,
@@ -2458,7 +2800,9 @@ export async function processWikiIngestJob(jobId, options) {
2458
2800
  binary: fetched.binary,
2459
2801
  mimeType: fetched.mimeType,
2460
2802
  parseStrategy: parsed.parseStrategy
2461
- })
2803
+ }, {
2804
+ resumeResponseId
2805
+ }, llmDiagnosticLogger)
2462
2806
  : llmProfile && fetched.contentText
2463
2807
  ? await options.llm.compileWikiIngest(llmProfile, {
2464
2808
  titleHint: parsed.titleHint,
@@ -2466,8 +2810,13 @@ export async function processWikiIngestJob(jobId, options) {
2466
2810
  binary: fetched.binary,
2467
2811
  mimeType: fetched.mimeType,
2468
2812
  parseStrategy: "text_only"
2469
- })
2813
+ }, {
2814
+ resumeResponseId
2815
+ }, llmDiagnosticLogger)
2470
2816
  : null;
2817
+ if (llmProfile && !compiled) {
2818
+ throw new Error("The LLM did not produce structured draft candidates. Check the OpenAI settings and try again.");
2819
+ }
2471
2820
  const title = compiled?.title ||
2472
2821
  parsed.titleHint ||
2473
2822
  inferTitle(fetched.contentText || fetched.fileName, "Imported source");
@@ -2491,6 +2840,7 @@ export async function processWikiIngestJob(jobId, options) {
2491
2840
  markdown,
2492
2841
  sourceLocator: fetched.locator,
2493
2842
  createAsKind: parsed.createAsKind,
2843
+ tags: compiled?.tags,
2494
2844
  linkedEntityHints: parsed.linkedEntityHints
2495
2845
  })
2496
2846
  });
@@ -2551,11 +2901,23 @@ export async function processWikiIngestJob(jobId, options) {
2551
2901
  payload: createPageCandidatePayload({
2552
2902
  title: articleTitle,
2553
2903
  summary: articleSummary,
2554
- markdown: `# ${articleTitle}\n\n${articleSummary}\n\n${articleRationale
2555
- ? `## Why this page\n\n${articleRationale}\n`
2556
- : ""}`,
2904
+ markdown: typeof candidate.markdown === "string" &&
2905
+ candidate.markdown.trim().length > 0
2906
+ ? candidate.markdown
2907
+ : `# ${articleTitle}\n\n${articleSummary}\n\n${articleRationale
2908
+ ? `## Why this page\n\n${articleRationale}\n`
2909
+ : ""}`,
2557
2910
  sourceLocator: fetched.locator,
2558
2911
  createAsKind: "wiki",
2912
+ aliases: Array.isArray(candidate.aliases)
2913
+ ? candidate.aliases.filter((alias) => typeof alias === "string")
2914
+ : [],
2915
+ parentSlug: typeof candidate.parentSlug === "string"
2916
+ ? candidate.parentSlug
2917
+ : null,
2918
+ tags: Array.isArray(candidate.tags)
2919
+ ? candidate.tags.filter((tag) => typeof tag === "string")
2920
+ : [],
2559
2921
  linkedEntityHints: parsed.linkedEntityHints
2560
2922
  })
2561
2923
  });
@@ -2568,7 +2930,9 @@ export async function processWikiIngestJob(jobId, options) {
2568
2930
  checksum: rawSource.checksum,
2569
2931
  metadata: {
2570
2932
  ...(parseJsonRecord(nextAsset.metadata_json) ?? {}),
2571
- rawSourcePath: rawSource.filePath
2933
+ rawSourcePath: rawSource.filePath,
2934
+ openAiResponseStatus: llmProfile ? "completed" : null,
2935
+ openAiLastPolledAt: llmProfile ? nowIso() : null
2572
2936
  }
2573
2937
  });
2574
2938
  hadSuccess = true;
@@ -2586,20 +2950,38 @@ export async function processWikiIngestJob(jobId, options) {
2586
2950
  latestMessage: `Prepared candidates from ${fetched.fileName}.`
2587
2951
  });
2588
2952
  createWikiIngestLog(jobId, `Prepared candidates from ${fetched.fileName}.`, "info", {
2953
+ fileName: currentAssetContext?.fileName ?? fetched.fileName,
2954
+ sourceAssetId: currentAssetContext?.assetId ?? null,
2955
+ fileIndex: currentAssetContext?.fileIndex ?? processedFiles,
2956
+ totalFiles: currentAssetContext?.totalFiles ?? totalFiles,
2589
2957
  pageCandidates: counts.pageCount,
2590
2958
  entityCandidates: counts.entityCount
2591
2959
  });
2960
+ currentAssetContext = null;
2961
+ currentPollCount = 0;
2592
2962
  }
2593
2963
  catch (error) {
2594
2964
  processedFiles += 1;
2595
2965
  updateWikiIngestAsset(nextAsset.id, { status: "failed" });
2966
+ updateCurrentAssetMetadata({
2967
+ openAiResponseStatus: "failed",
2968
+ openAiLastPolledAt: nowIso(),
2969
+ openAiPollCount: currentPollCount
2970
+ });
2596
2971
  updateWikiIngestJob(jobId, {
2597
2972
  processedFiles,
2598
2973
  totalFiles,
2599
2974
  progressPercent: calculateProgress(totalFiles, processedFiles),
2600
2975
  latestMessage: error instanceof Error ? error.message : "Source ingest failed."
2601
2976
  });
2602
- createWikiIngestLog(jobId, error instanceof Error ? error.message : "Source ingest failed.", "error");
2977
+ createWikiIngestLog(jobId, error instanceof Error ? error.message : "Source ingest failed.", "error", {
2978
+ fileName: currentAssetContext?.fileName ?? null,
2979
+ fileIndex: currentAssetContext?.fileIndex ?? null,
2980
+ totalFiles: currentAssetContext?.totalFiles ?? totalFiles,
2981
+ pollCount: currentPollCount
2982
+ });
2983
+ currentAssetContext = null;
2984
+ currentPollCount = 0;
2603
2985
  }
2604
2986
  }
2605
2987
  const counts = refreshCounts();
@@ -2635,6 +3017,94 @@ export function listWikiIngestJobs(input = {}) {
2635
3017
  .map((row) => getWikiIngestJob(row.id))
2636
3018
  .filter((job) => job !== null);
2637
3019
  }
3020
+ export async function rerunWikiIngestJob(jobId, options = {}) {
3021
+ const job = readWikiIngestJobRow(jobId);
3022
+ if (!job) {
3023
+ return null;
3024
+ }
3025
+ if (["queued", "processing"].includes(job.status)) {
3026
+ throw new Error("Wiki ingest jobs can only be rerun after processing ends.");
3027
+ }
3028
+ const ingestInput = readWikiIngestInput(jobId);
3029
+ if (!ingestInput) {
3030
+ throw new Error("Wiki ingest input could not be restored.");
3031
+ }
3032
+ const rootAssets = listWikiIngestJobAssetsInternal(jobId).filter((asset) => {
3033
+ const metadata = parseJsonRecord(asset.metadata_json);
3034
+ return !metadata?.parentAssetId && asset.source_kind !== "url";
3035
+ });
3036
+ const replayResult = rootAssets.length > 0
3037
+ ? await createUploadedWikiIngestJob({
3038
+ spaceId: ingestInput.spaceId,
3039
+ titleHint: ingestInput.titleHint,
3040
+ llmProfileId: ingestInput.llmProfileId,
3041
+ parseStrategy: ingestInput.parseStrategy,
3042
+ entityProposalMode: ingestInput.entityProposalMode,
3043
+ userId: ingestInput.userId ?? null,
3044
+ createAsKind: ingestInput.createAsKind,
3045
+ linkedEntityHints: ingestInput.linkedEntityHints
3046
+ }, await Promise.all(rootAssets.map(async (asset) => ({
3047
+ fileName: asset.file_name,
3048
+ mimeType: asset.mime_type,
3049
+ payload: await readFile(asset.file_path)
3050
+ }))), {
3051
+ actor: options.actor ?? job.created_by_actor ?? null
3052
+ })
3053
+ : await ingestWikiSource(ingestInput, {
3054
+ actor: options.actor ?? job.created_by_actor ?? null
3055
+ });
3056
+ const nextJobId = replayResult.job?.job.id ?? null;
3057
+ if (nextJobId) {
3058
+ createWikiIngestLog(nextJobId, `Reran ingest from ${jobId}.`, "info", {
3059
+ scope: "wiki_ingest",
3060
+ eventKey: "wiki_ingest_rerun",
3061
+ sourceJobId: jobId
3062
+ });
3063
+ createWikiIngestLog(jobId, `Created rerun job ${nextJobId}.`, "info", {
3064
+ scope: "wiki_ingest",
3065
+ eventKey: "wiki_ingest_rerun_requested",
3066
+ rerunJobId: nextJobId
3067
+ });
3068
+ }
3069
+ return replayResult;
3070
+ }
3071
+ export function deleteWikiIngestJob(jobId) {
3072
+ const job = readWikiIngestJobRow(jobId);
3073
+ if (!job) {
3074
+ return null;
3075
+ }
3076
+ if (["queued", "processing"].includes(job.status)) {
3077
+ throw new Error("Wiki ingest jobs can only be deleted after processing ends.");
3078
+ }
3079
+ const candidates = listWikiIngestCandidatesInternal(jobId);
3080
+ const assets = listWikiIngestJobAssetsInternal(jobId);
3081
+ const preservedAssetIds = new Set(candidates
3082
+ .filter((candidate) => candidate.status === "applied")
3083
+ .map((candidate) => candidate.source_asset_id)
3084
+ .filter((assetId) => typeof assetId === "string"));
3085
+ for (const asset of assets) {
3086
+ if (preservedAssetIds.has(asset.id)) {
3087
+ continue;
3088
+ }
3089
+ const metadata = parseJsonRecord(asset.metadata_json);
3090
+ const rawSourcePath = typeof metadata?.rawSourcePath === "string"
3091
+ ? metadata.rawSourcePath
3092
+ : null;
3093
+ if (rawSourcePath && existsSync(rawSourcePath)) {
3094
+ unlinkSync(rawSourcePath);
3095
+ }
3096
+ }
3097
+ const jobDir = getWikiIngestJobDir(jobId);
3098
+ if (existsSync(jobDir)) {
3099
+ rmSync(jobDir, { recursive: true, force: true });
3100
+ }
3101
+ getDatabase()
3102
+ .prepare(`DELETE FROM wiki_ingest_jobs WHERE id = ?`)
3103
+ .run(jobId);
3104
+ return {
3105
+ id: jobId
3106
+ };
3107
+ }
2638
3108
  export async function reviewWikiIngestJob(jobId, input, options) {
2639
3109
  const parsed = reviewWikiIngestJobSchema.parse(input);
2640
3110
  const job = readWikiIngestJobRow(jobId);
@@ -2647,6 +3117,7 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2647
3117
  }
2648
3118
  const candidates = listWikiIngestCandidatesInternal(jobId);
2649
3119
  let acceptedCount = 0;
3120
+ let mappedCount = 0;
2650
3121
  let rejectedCount = 0;
2651
3122
  let firstPublishedPageId = null;
2652
3123
  for (const decision of parsed.decisions) {
@@ -2654,7 +3125,9 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2654
3125
  if (!candidate) {
2655
3126
  continue;
2656
3127
  }
2657
- if (!decision.keep) {
3128
+ const action = decision.action ??
3129
+ (decision.keep === false ? "discard" : "keep");
3130
+ if (action === "discard") {
2658
3131
  rejectedCount += 1;
2659
3132
  updateWikiIngestCandidate(candidate.id, { status: "rejected" });
2660
3133
  continue;
@@ -2662,36 +3135,77 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2662
3135
  try {
2663
3136
  const payload = parseJsonRecord(candidate.payload_json);
2664
3137
  if (candidate.candidate_type === "page") {
2665
- const note = options.createNote({
2666
- kind: payload.kind === "evidence" ? "evidence" : "wiki",
2667
- title: typeof payload.title === "string" ? payload.title : candidate.title,
2668
- slug: "",
2669
- indexOrder: 0,
2670
- aliases: [],
2671
- summary: typeof payload.summary === "string"
2672
- ? payload.summary
2673
- : candidate.summary,
2674
- sourcePath: "",
2675
- frontmatter: {},
2676
- revisionHash: "",
2677
- spaceId: job.space_id,
2678
- contentMarkdown: typeof payload.contentMarkdown === "string"
3138
+ if (action === "merge_existing") {
3139
+ const target = decision.targetNoteId
3140
+ ? (getWikiPageDetail(decision.targetNoteId)?.page ?? null)
3141
+ : null;
3142
+ if (!target || target.spaceId !== job.space_id) {
3143
+ throw new Error("Merge target page was not found.");
3144
+ }
3145
+ const incomingTitle = typeof payload.title === "string" ? payload.title : candidate.title;
3146
+ const incomingMarkdown = typeof payload.contentMarkdown === "string"
2679
3147
  ? payload.contentMarkdown
2680
- : `# ${candidate.title}\n`,
2681
- author: ingestInput.userId ?? null,
2682
- links: Array.isArray(payload.links)
2683
- ? payload.links
2684
- : ingestInput.linkedEntityHints,
2685
- tags: [],
2686
- destroyAt: null,
2687
- userId: ingestInput.userId ?? null
2688
- });
2689
- acceptedCount += 1;
2690
- firstPublishedPageId = firstPublishedPageId ?? note.id;
2691
- updateWikiIngestCandidate(candidate.id, {
2692
- status: "applied",
2693
- publishedNoteId: note.id
2694
- });
3148
+ : `# ${candidate.title}\n`;
3149
+ const mergedContentMarkdown = mergeWikiPageContent(target.contentMarkdown, {
3150
+ title: incomingTitle,
3151
+ markdown: incomingMarkdown
3152
+ });
3153
+ const mergedSummary = inferSummary(mergedContentMarkdown);
3154
+ const updated = options.updateNote(target.id, {
3155
+ contentMarkdown: mergedContentMarkdown,
3156
+ summary: mergedSummary
3157
+ });
3158
+ if (!updated) {
3159
+ throw new Error("Merge target page could not be updated.");
3160
+ }
3161
+ acceptedCount += 1;
3162
+ firstPublishedPageId = firstPublishedPageId ?? updated.id;
3163
+ updateWikiIngestCandidate(candidate.id, {
3164
+ status: "applied",
3165
+ publishedNoteId: updated.id
3166
+ });
3167
+ }
3168
+ else {
3169
+ const note = options.createNote({
3170
+ kind: payload.kind === "evidence" ? "evidence" : "wiki",
3171
+ title: typeof payload.title === "string"
3172
+ ? payload.title
3173
+ : candidate.title,
3174
+ slug: "",
3175
+ indexOrder: 0,
3176
+ aliases: Array.isArray(payload.aliases)
3177
+ ? payload.aliases.filter((alias) => typeof alias === "string")
3178
+ : [],
3179
+ summary: typeof payload.summary === "string"
3180
+ ? payload.summary
3181
+ : candidate.summary,
3182
+ sourcePath: "",
3183
+ frontmatter: {},
3184
+ revisionHash: "",
3185
+ spaceId: job.space_id,
3186
+ parentSlug: typeof payload.parentSlug === "string"
3187
+ ? payload.parentSlug
3188
+ : null,
3189
+ contentMarkdown: typeof payload.contentMarkdown === "string"
3190
+ ? payload.contentMarkdown
3191
+ : `# ${candidate.title}\n`,
3192
+ author: ingestInput.userId ?? null,
3193
+ links: Array.isArray(payload.links)
3194
+ ? payload.links
3195
+ : ingestInput.linkedEntityHints,
3196
+ tags: Array.isArray(payload.tags)
3197
+ ? payload.tags.filter((tag) => typeof tag === "string")
3198
+ : [],
3199
+ destroyAt: null,
3200
+ userId: ingestInput.userId ?? null
3201
+ });
3202
+ acceptedCount += 1;
3203
+ firstPublishedPageId = firstPublishedPageId ?? note.id;
3204
+ updateWikiIngestCandidate(candidate.id, {
3205
+ status: "applied",
3206
+ publishedNoteId: note.id
3207
+ });
3208
+ }
2695
3209
  }
2696
3210
  else if (candidate.candidate_type === "page_update") {
2697
3211
  const targetSlug = typeof payload.targetSlug === "string"
@@ -2723,13 +3237,32 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2723
3237
  }
2724
3238
  }
2725
3239
  else if (candidate.candidate_type === "entity") {
2726
- const result = options.publishEntity(payload);
2727
- acceptedCount += 1;
2728
- updateWikiIngestCandidate(candidate.id, {
2729
- status: "applied",
2730
- publishedEntityType: result.entityType,
2731
- publishedEntityId: result.entityId
2732
- });
3240
+ if (action === "map_existing") {
3241
+ const proposedType = typeof payload.entityType === "string" ? payload.entityType : "";
3242
+ if (decision.mappedEntityType !== proposedType) {
3243
+ throw new Error("Mapped entity type must match the proposed entity type.");
3244
+ }
3245
+ const mapped = options.resolveMappedEntity(decision.mappedEntityType, decision.mappedEntityId);
3246
+ if (!mapped) {
3247
+ throw new Error("Mapped entity was not found.");
3248
+ }
3249
+ acceptedCount += 1;
3250
+ mappedCount += 1;
3251
+ updateWikiIngestCandidate(candidate.id, {
3252
+ status: "applied",
3253
+ publishedEntityType: mapped.entityType,
3254
+ publishedEntityId: mapped.entityId
3255
+ });
3256
+ }
3257
+ else {
3258
+ const result = options.publishEntity(payload);
3259
+ acceptedCount += 1;
3260
+ updateWikiIngestCandidate(candidate.id, {
3261
+ status: "applied",
3262
+ publishedEntityType: result.entityType,
3263
+ publishedEntityId: result.entityId
3264
+ });
3265
+ }
2733
3266
  }
2734
3267
  }
2735
3268
  catch {
@@ -2742,10 +3275,10 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2742
3275
  rejectedCount,
2743
3276
  pageNoteId: firstPublishedPageId,
2744
3277
  latestMessage: acceptedCount > 0 || rejectedCount > 0
2745
- ? `Review saved with ${acceptedCount} kept and ${rejectedCount} discarded.`
3278
+ ? `Review saved with ${acceptedCount} kept${mappedCount > 0 ? ` (${mappedCount} mapped)` : ""} and ${rejectedCount} discarded.`
2746
3279
  : "Review saved."
2747
3280
  });
2748
- createWikiIngestLog(jobId, `Review saved with ${acceptedCount} kept and ${rejectedCount} discarded.`);
3281
+ createWikiIngestLog(jobId, `Review saved with ${acceptedCount} kept${mappedCount > 0 ? ` (${mappedCount} mapped)` : ""} and ${rejectedCount} discarded.`);
2749
3282
  return getWikiIngestJob(jobId);
2750
3283
  }
2751
3284
  export function getWikiIngestJob(jobId) {