forge-openclaw-plugin 0.2.20 → 0.2.22

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-Ch_xeZ2u.js +63 -0
  4. package/dist/assets/index-Ch_xeZ2u.js.map +1 -0
  5. package/dist/assets/index-DvVM7K6j.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 +328 -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 +619 -66
  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,8 @@ 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 { isEntityDeleted } from "./deleted-entities.js";
12
+ import { recordDiagnosticLog } from "./diagnostic-logs.js";
11
13
  const wikiSpaceSchema = z.object({
12
14
  id: z.string(),
13
15
  slug: z.string(),
@@ -201,13 +203,52 @@ const wikiIngestJobPayloadSchema = z.object({
201
203
  });
202
204
  const listWikiIngestJobsQuerySchema = z.object({
203
205
  spaceId: z.string().trim().optional(),
204
- limit: z.coerce.number().int().positive().max(50).default(20)
206
+ limit: z.coerce.number().int().positive().max(200).default(20)
205
207
  });
206
208
  export const reviewWikiIngestJobSchema = z.object({
207
209
  decisions: z
208
- .array(z.object({
210
+ .array(z
211
+ .object({
209
212
  candidateId: z.string().trim().min(1),
210
- keep: z.boolean()
213
+ keep: z.boolean().optional(),
214
+ action: z
215
+ .enum(["keep", "discard", "map_existing", "merge_existing"])
216
+ .optional(),
217
+ mappedEntityType: crudEntityTypeSchema.optional(),
218
+ mappedEntityId: z.string().trim().min(1).optional(),
219
+ targetNoteId: z.string().trim().min(1).optional()
220
+ })
221
+ .superRefine((value, context) => {
222
+ if (value.action === "map_existing") {
223
+ if (!value.mappedEntityType) {
224
+ context.addIssue({
225
+ code: z.ZodIssueCode.custom,
226
+ path: ["mappedEntityType"],
227
+ message: "mappedEntityType is required when action is map_existing"
228
+ });
229
+ }
230
+ if (!value.mappedEntityId) {
231
+ context.addIssue({
232
+ code: z.ZodIssueCode.custom,
233
+ path: ["mappedEntityId"],
234
+ message: "mappedEntityId is required when action is map_existing"
235
+ });
236
+ }
237
+ }
238
+ if (value.action === "merge_existing" && !value.targetNoteId) {
239
+ context.addIssue({
240
+ code: z.ZodIssueCode.custom,
241
+ path: ["targetNoteId"],
242
+ message: "targetNoteId is required when action is merge_existing"
243
+ });
244
+ }
245
+ if (value.action === undefined && value.keep === undefined) {
246
+ context.addIssue({
247
+ code: z.ZodIssueCode.custom,
248
+ path: ["action"],
249
+ message: "Either keep or action is required"
250
+ });
251
+ }
211
252
  }))
212
253
  .min(1)
213
254
  });
@@ -218,6 +259,14 @@ export const createWikiSpaceSchema = z.object({
218
259
  ownerUserId: z.string().trim().nullable().optional(),
219
260
  visibility: wikiSpaceVisibilitySchema.default("personal")
220
261
  });
262
+ const wikiLlmReasoningEffortSchema = z.enum([
263
+ "none",
264
+ "low",
265
+ "medium",
266
+ "high",
267
+ "xhigh"
268
+ ]);
269
+ const wikiLlmVerbositySchema = z.enum(["low", "medium", "high"]);
221
270
  export const upsertWikiLlmProfileSchema = z.object({
222
271
  id: z.string().trim().optional(),
223
272
  label: z.string().trim().min(1),
@@ -226,9 +275,20 @@ export const upsertWikiLlmProfileSchema = z.object({
226
275
  model: z.string().trim().min(1),
227
276
  apiKey: z.string().trim().optional(),
228
277
  systemPrompt: z.string().trim().default(""),
278
+ reasoningEffort: wikiLlmReasoningEffortSchema.optional(),
279
+ verbosity: wikiLlmVerbositySchema.optional(),
229
280
  enabled: z.boolean().default(true),
230
281
  metadata: z.record(z.string(), z.unknown()).default({})
231
282
  });
283
+ export const testWikiLlmProfileSchema = z.object({
284
+ profileId: z.string().trim().optional(),
285
+ provider: z.string().trim().min(1).default("openai-responses"),
286
+ baseUrl: z.string().trim().default("https://api.openai.com/v1"),
287
+ model: z.string().trim().min(1),
288
+ apiKey: z.string().trim().optional(),
289
+ reasoningEffort: wikiLlmReasoningEffortSchema.optional(),
290
+ verbosity: wikiLlmVerbositySchema.optional()
291
+ });
232
292
  export const upsertWikiEmbeddingProfileSchema = z.object({
233
293
  id: z.string().trim().optional(),
234
294
  label: z.string().trim().min(1),
@@ -390,6 +450,10 @@ function parseJsonStringArray(raw) {
390
450
  return [];
391
451
  }
392
452
  }
453
+ function readStringRecordValue(record, key) {
454
+ const value = record[key];
455
+ return typeof value === "string" && value.trim().length > 0 ? value : null;
456
+ }
393
457
  function listLinkRowsForNotes(noteIds) {
394
458
  if (noteIds.length === 0) {
395
459
  return [];
@@ -465,6 +529,20 @@ function getNoteBySlugRaw(spaceId, slug, exceptNoteId) {
465
529
  .get(...(exceptNoteId ? [spaceId, slug, exceptNoteId] : [spaceId, slug]));
466
530
  return row;
467
531
  }
532
+ function getActiveNoteByIdRaw(noteId) {
533
+ const row = getNoteByIdRaw(noteId);
534
+ if (!row || isEntityDeleted("note", row.id)) {
535
+ return null;
536
+ }
537
+ return row;
538
+ }
539
+ function getActiveNoteBySlugRaw(spaceId, slug, exceptNoteId) {
540
+ const row = getNoteBySlugRaw(spaceId, slug, exceptNoteId);
541
+ if (!row || isEntityDeleted("note", row.id)) {
542
+ return null;
543
+ }
544
+ return row;
545
+ }
468
546
  function buildContentPlain(markdown) {
469
547
  return markdown
470
548
  .replace(/^---[\s\S]*?---\s*/m, "")
@@ -497,6 +575,32 @@ function inferSummary(markdown) {
497
575
  const plain = buildContentPlain(markdown).replace(/\s+/g, " ").trim();
498
576
  return plain.slice(0, 240);
499
577
  }
578
+ function stripLeadingHeading(markdown, title) {
579
+ const normalizedTitle = title.trim().toLowerCase();
580
+ const trimmed = markdown.trim();
581
+ const match = trimmed.match(/^#\s+(.+?)\n+/);
582
+ if (!match) {
583
+ return trimmed;
584
+ }
585
+ const heading = match[1]?.trim().toLowerCase() ?? "";
586
+ if (heading !== normalizedTitle) {
587
+ return trimmed;
588
+ }
589
+ return trimmed.slice(match[0].length).trim();
590
+ }
591
+ function mergeWikiPageContent(targetMarkdown, incoming) {
592
+ const mergedBody = stripLeadingHeading(incoming.markdown, incoming.title) ||
593
+ incoming.markdown.trim();
594
+ return [
595
+ targetMarkdown.trim(),
596
+ "",
597
+ `## ${incoming.title}`,
598
+ "",
599
+ mergedBody
600
+ ]
601
+ .filter((part) => part.trim().length > 0)
602
+ .join("\n");
603
+ }
500
604
  function slugify(value) {
501
605
  const normalized = value
502
606
  .toLowerCase()
@@ -1376,7 +1480,9 @@ function listAllNotes() {
1376
1480
  current.push(link);
1377
1481
  linksByNoteId.set(link.note_id, current);
1378
1482
  }
1379
- return rows.map((row) => mapNoteRow(row, linksByNoteId.get(row.id) ?? []));
1483
+ return rows
1484
+ .filter((row) => !isEntityDeleted("note", row.id))
1485
+ .map((row) => mapNoteRow(row, linksByNoteId.get(row.id) ?? []));
1380
1486
  }
1381
1487
  export function listWikiSpaces() {
1382
1488
  ensureSharedWikiSpace();
@@ -1441,7 +1547,7 @@ export function listWikiPageTree(query) {
1441
1547
  export function getWikiHomePageDetail(input = {}) {
1442
1548
  const spaceId = resolveSpaceId(input.spaceId, null);
1443
1549
  ensureWikiSpaceSeedPages(spaceId);
1444
- const home = getNoteBySlugRaw(spaceId, "index");
1550
+ const home = getActiveNoteBySlugRaw(spaceId, "index");
1445
1551
  if (!home) {
1446
1552
  return null;
1447
1553
  }
@@ -1450,14 +1556,14 @@ export function getWikiHomePageDetail(input = {}) {
1450
1556
  export function getWikiPageDetailBySlug(input) {
1451
1557
  const spaceId = resolveSpaceId(input.spaceId, null);
1452
1558
  ensureWikiSpaceSeedPages(spaceId);
1453
- const row = getNoteBySlugRaw(spaceId, input.slug.trim());
1559
+ const row = getActiveNoteBySlugRaw(spaceId, input.slug.trim());
1454
1560
  if (!row) {
1455
1561
  return null;
1456
1562
  }
1457
1563
  return getWikiPageDetail(row.id);
1458
1564
  }
1459
1565
  export function getWikiPageDetail(noteId) {
1460
- const row = getNoteByIdRaw(noteId);
1566
+ const row = getActiveNoteByIdRaw(noteId);
1461
1567
  if (!row) {
1462
1568
  return null;
1463
1569
  }
@@ -1876,6 +1982,21 @@ export function upsertWikiLlmProfile(input, secrets) {
1876
1982
  `wiki_llm_secret_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1877
1983
  storeEncryptedSecret(secretId, secrets.sealJson({ apiKey: parsed.apiKey.trim() }), `${parsed.label} wiki LLM profile`);
1878
1984
  }
1985
+ const metadata = {
1986
+ ...parsed.metadata
1987
+ };
1988
+ if (parsed.reasoningEffort) {
1989
+ metadata.reasoningEffort = parsed.reasoningEffort;
1990
+ }
1991
+ else {
1992
+ delete metadata.reasoningEffort;
1993
+ }
1994
+ if (parsed.verbosity) {
1995
+ metadata.verbosity = parsed.verbosity;
1996
+ }
1997
+ else {
1998
+ delete metadata.verbosity;
1999
+ }
1879
2000
  getDatabase()
1880
2001
  .prepare(`INSERT INTO wiki_llm_profiles (id, label, provider, base_url, model, secret_id, system_prompt, enabled, metadata_json, created_at, updated_at)
1881
2002
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1889,7 +2010,7 @@ export function upsertWikiLlmProfile(input, secrets) {
1889
2010
  enabled = excluded.enabled,
1890
2011
  metadata_json = excluded.metadata_json,
1891
2012
  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);
2013
+ .run(id, parsed.label, parsed.provider, parsed.baseUrl, parsed.model, secretId, parsed.systemPrompt, parsed.enabled ? 1 : 0, JSON.stringify(metadata), now, now);
1893
2014
  return listWikiLlmProfiles().find((entry) => entry.id === id);
1894
2015
  }
1895
2016
  export function upsertWikiEmbeddingProfile(input, secrets) {
@@ -2088,11 +2209,103 @@ function updateWikiIngestJob(jobId, patch) {
2088
2209
  .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
2210
  return getWikiIngestJob(jobId);
2090
2211
  }
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());
2212
+ function createWikiIngestLog(jobId, message, level = "info", metadata = {}, options = {}) {
2213
+ const createdAt = nowIso();
2214
+ const logMetadata = options.aggregateKey
2215
+ ? { ...metadata, aggregateKey: options.aggregateKey }
2216
+ : metadata;
2217
+ const aggregateKey = options.aggregateKey?.trim() || null;
2218
+ if (aggregateKey) {
2219
+ const current = [...listWikiIngestJobLogsInternal(jobId)]
2220
+ .reverse()
2221
+ .find((entry) => {
2222
+ const parsed = parseJsonRecord(entry.metadata_json);
2223
+ return parsed.aggregateKey === aggregateKey;
2224
+ });
2225
+ if (current) {
2226
+ getDatabase()
2227
+ .prepare(`UPDATE wiki_ingest_job_logs
2228
+ SET level = ?, message = ?, metadata_json = ?, created_at = ?
2229
+ WHERE id = ?`)
2230
+ .run(level, message, JSON.stringify(logMetadata), createdAt, current.id);
2231
+ }
2232
+ else {
2233
+ getDatabase()
2234
+ .prepare(`INSERT INTO wiki_ingest_job_logs (id, job_id, level, message, metadata_json, created_at)
2235
+ VALUES (?, ?, ?, ?, ?, ?)`)
2236
+ .run(`wiki_ingest_log_${randomUUID().replaceAll("-", "").slice(0, 10)}`, jobId, level, message, JSON.stringify(logMetadata), createdAt);
2237
+ }
2238
+ }
2239
+ else {
2240
+ getDatabase()
2241
+ .prepare(`INSERT INTO wiki_ingest_job_logs (id, job_id, level, message, metadata_json, created_at)
2242
+ VALUES (?, ?, ?, ?, ?, ?)`)
2243
+ .run(`wiki_ingest_log_${randomUUID().replaceAll("-", "").slice(0, 10)}`, jobId, level, message, JSON.stringify(logMetadata), createdAt);
2244
+ }
2245
+ if (options.recordDiagnostic !== false) {
2246
+ recordDiagnosticLog({
2247
+ level,
2248
+ source: "server",
2249
+ scope: typeof metadata.scope === "string" && metadata.scope.trim()
2250
+ ? metadata.scope
2251
+ : "wiki_ingest",
2252
+ eventKey: typeof metadata.eventKey === "string" && metadata.eventKey.trim()
2253
+ ? metadata.eventKey
2254
+ : "wiki_ingest_log",
2255
+ message,
2256
+ functionName: "createWikiIngestLog",
2257
+ entityType: "wiki_ingest_job",
2258
+ entityId: jobId,
2259
+ jobId,
2260
+ details: logMetadata
2261
+ });
2262
+ }
2263
+ }
2264
+ function findOpenAiResponseIdForJobAsset(input) {
2265
+ const normalizedFileName = input.fileName.trim().toLowerCase();
2266
+ const normalizedSourceLocator = input.sourceLocator.trim().toLowerCase();
2267
+ const normalizedChecksum = input.checksum.trim().toLowerCase();
2268
+ const sameNamedAssets = listWikiIngestJobAssetsInternal(input.jobId).filter((asset) => asset.file_name.trim().toLowerCase() === normalizedFileName).length;
2269
+ const logs = [...listWikiIngestJobLogsInternal(input.jobId)].reverse();
2270
+ for (const entry of logs) {
2271
+ const metadata = parseJsonRecord(entry.metadata_json);
2272
+ const responseId = readStringRecordValue(metadata, "responseId");
2273
+ if (!responseId) {
2274
+ continue;
2275
+ }
2276
+ const loggedAssetId = readStringRecordValue(metadata, "sourceAssetId") ??
2277
+ readStringRecordValue(metadata, "assetId");
2278
+ if (loggedAssetId) {
2279
+ if (loggedAssetId === input.assetId) {
2280
+ return responseId;
2281
+ }
2282
+ continue;
2283
+ }
2284
+ const loggedSourceLocator = readStringRecordValue(metadata, "sourceLocator");
2285
+ if (loggedSourceLocator) {
2286
+ if (loggedSourceLocator.trim().toLowerCase() === normalizedSourceLocator) {
2287
+ return responseId;
2288
+ }
2289
+ continue;
2290
+ }
2291
+ const loggedChecksum = readStringRecordValue(metadata, "checksum");
2292
+ if (loggedChecksum) {
2293
+ if (loggedChecksum.trim().toLowerCase() === normalizedChecksum) {
2294
+ return responseId;
2295
+ }
2296
+ continue;
2297
+ }
2298
+ const loggedFileName = readStringRecordValue(metadata, "currentFileName") ??
2299
+ readStringRecordValue(metadata, "fileName");
2300
+ if (!loggedFileName) {
2301
+ return responseId;
2302
+ }
2303
+ if (sameNamedAssets === 1 &&
2304
+ loggedFileName.trim().toLowerCase() === normalizedFileName) {
2305
+ return responseId;
2306
+ }
2307
+ }
2308
+ return null;
2096
2309
  }
2097
2310
  function createWikiIngestAssetRecord(input) {
2098
2311
  const id = `wiki_ingest_asset_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
@@ -2121,6 +2334,15 @@ function updateWikiIngestAsset(assetId, patch) {
2121
2334
  .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
2335
  }
2123
2336
  function createWikiIngestCandidate(input) {
2337
+ const existing = listWikiIngestCandidatesInternal(input.jobId).find((candidate) => candidate.source_asset_id === (input.sourceAssetId ?? null) &&
2338
+ candidate.candidate_type === input.candidateType &&
2339
+ candidate.title === (input.title ?? "") &&
2340
+ candidate.summary === (input.summary ?? "") &&
2341
+ candidate.target_key === (input.targetKey ?? "") &&
2342
+ candidate.payload_json === JSON.stringify(input.payload));
2343
+ if (existing) {
2344
+ return existing.id;
2345
+ }
2124
2346
  const id = `wiki_ingest_candidate_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
2125
2347
  const now = nowIso();
2126
2348
  getDatabase()
@@ -2338,8 +2560,13 @@ function createPageCandidatePayload(input) {
2338
2560
  summary: input.summary,
2339
2561
  contentMarkdown: input.markdown,
2340
2562
  kind: input.createAsKind,
2563
+ aliases: normalizeTags(input.aliases),
2564
+ parentSlug: typeof input.parentSlug === "string" && input.parentSlug.trim().length > 0
2565
+ ? input.parentSlug.trim()
2566
+ : null,
2341
2567
  sourceLocator: input.sourceLocator,
2342
- links: input.linkedEntityHints
2568
+ links: input.linkedEntityHints,
2569
+ tags: normalizeTags(input.tags)
2343
2570
  };
2344
2571
  }
2345
2572
  export async function processWikiIngestJob(jobId, options) {
@@ -2355,12 +2582,114 @@ export async function processWikiIngestJob(jobId, options) {
2355
2582
  ? (listWikiLlmProfiles().find((entry) => entry.id === parsed.llmProfileId) ?? null)
2356
2583
  : null;
2357
2584
  const space = getWikiSpaceById(job.space_id) ?? ensureSharedWikiSpace();
2585
+ if (parsed.llmProfileId && !llmProfile) {
2586
+ updateWikiIngestJob(jobId, {
2587
+ status: "failed",
2588
+ phase: "failed",
2589
+ latestMessage: "The selected LLM profile could not be found.",
2590
+ errorMessage: "The selected LLM profile could not be found.",
2591
+ completedAt: nowIso()
2592
+ });
2593
+ createWikiIngestLog(jobId, "The selected LLM profile could not be found.", "error");
2594
+ return getWikiIngestJob(jobId);
2595
+ }
2358
2596
  updateWikiIngestJob(jobId, {
2359
2597
  status: "processing",
2360
2598
  phase: "processing",
2361
- latestMessage: "Processing queued wiki ingest sources."
2599
+ latestMessage: "Processing queued wiki ingest sources.",
2600
+ errorMessage: "",
2601
+ completedAt: null
2362
2602
  });
2363
2603
  createWikiIngestLog(jobId, "Started background wiki ingestion.");
2604
+ let currentAssetContext = null;
2605
+ let currentPollCount = 0;
2606
+ const updateCurrentAssetMetadata = (patch) => {
2607
+ if (!currentAssetContext) {
2608
+ return;
2609
+ }
2610
+ const asset = listWikiIngestJobAssetsInternal(jobId).find((entry) => entry.id === currentAssetContext?.assetId);
2611
+ if (!asset) {
2612
+ return;
2613
+ }
2614
+ updateWikiIngestAsset(asset.id, {
2615
+ metadata: {
2616
+ ...parseJsonRecord(asset.metadata_json),
2617
+ ...patch
2618
+ }
2619
+ });
2620
+ };
2621
+ const llmDiagnosticLogger = ({ level, message, details = {} }) => {
2622
+ const enrichedDetails = {
2623
+ ...details,
2624
+ sourceAssetId: currentAssetContext?.assetId ?? null,
2625
+ currentFileName: currentAssetContext?.fileName ?? null,
2626
+ sourceLocator: currentAssetContext?.sourceLocator ?? null,
2627
+ checksum: currentAssetContext?.checksum ?? null,
2628
+ currentFileIndex: currentAssetContext?.fileIndex ?? null,
2629
+ currentFileTotal: currentAssetContext?.totalFiles ?? null,
2630
+ chunkIndex: typeof details.chunkIndex === "number"
2631
+ ? details.chunkIndex
2632
+ : currentAssetContext?.chunkIndex ?? null,
2633
+ chunkCount: typeof details.chunkCount === "number"
2634
+ ? details.chunkCount
2635
+ : currentAssetContext?.chunkCount ?? null
2636
+ };
2637
+ const eventKey = typeof details.eventKey === "string" ? details.eventKey : "";
2638
+ if (eventKey === "llm_compile_background_started") {
2639
+ currentPollCount = 0;
2640
+ updateCurrentAssetMetadata({
2641
+ openAiResponseId: typeof details.responseId === "string" ? details.responseId : null,
2642
+ openAiResponseStatus: typeof details.status === "string" ? details.status : "queued",
2643
+ openAiLastPolledAt: nowIso()
2644
+ });
2645
+ }
2646
+ if (eventKey === "llm_compile_background_polled") {
2647
+ currentPollCount += 1;
2648
+ const pollStatus = typeof details.status === "string" ? details.status : "in_progress";
2649
+ const chunkSuffix = (currentAssetContext?.chunkCount ?? 1) > 1
2650
+ ? ` · chunk ${currentAssetContext?.chunkIndex ?? 1}/${currentAssetContext?.chunkCount ?? 1}`
2651
+ : "";
2652
+ const fileLabel = currentAssetContext?.fileName ?? "current source";
2653
+ const progressMessage = `Waiting for OpenAI on ${fileLabel}${chunkSuffix}. Poll ${currentPollCount} · ${pollStatus}.`;
2654
+ updateWikiIngestJob(jobId, {
2655
+ latestMessage: progressMessage
2656
+ });
2657
+ updateCurrentAssetMetadata({
2658
+ openAiResponseId: typeof details.responseId === "string" ? details.responseId : null,
2659
+ openAiResponseStatus: pollStatus,
2660
+ openAiLastPolledAt: nowIso(),
2661
+ openAiPollCount: currentPollCount
2662
+ });
2663
+ createWikiIngestLog(jobId, progressMessage, "info", {
2664
+ ...enrichedDetails,
2665
+ pollCount: currentPollCount,
2666
+ status: pollStatus
2667
+ }, {
2668
+ aggregateKey: `llm_compile_background_polled:${currentAssetContext?.assetId ?? "job"}`,
2669
+ recordDiagnostic: false
2670
+ });
2671
+ return;
2672
+ }
2673
+ if (eventKey === "llm_compile_success" ||
2674
+ eventKey === "llm_compile_unparseable" ||
2675
+ eventKey === "llm_compile_background_terminal_error") {
2676
+ updateCurrentAssetMetadata({
2677
+ openAiResponseId: typeof details.responseId === "string" ? details.responseId : null,
2678
+ openAiResponseStatus: eventKey === "llm_compile_background_terminal_error"
2679
+ ? typeof details.status === "string"
2680
+ ? details.status
2681
+ : "failed"
2682
+ : "completed",
2683
+ openAiLastPolledAt: nowIso(),
2684
+ openAiPollCount: currentPollCount
2685
+ });
2686
+ }
2687
+ createWikiIngestLog(jobId, message, level === "debug" ? "info" : level, {
2688
+ scope: "wiki_llm",
2689
+ eventKey: "wiki_llm_event",
2690
+ ...enrichedDetails
2691
+ });
2692
+ };
2364
2693
  const refreshCounts = () => {
2365
2694
  const candidates = listWikiIngestCandidatesInternal(jobId);
2366
2695
  const pageCount = candidates.filter((candidate) => candidate.candidate_type === "page").length;
@@ -2368,11 +2697,12 @@ export async function processWikiIngestJob(jobId, options) {
2368
2697
  return { pageCount, entityCount };
2369
2698
  };
2370
2699
  const assetQueue = () => listWikiIngestJobAssetsInternal(jobId).filter((asset) => ["queued", "processing"].includes(asset.status));
2371
- let processedFiles = job.processed_files;
2372
- let totalFiles = job.total_files;
2700
+ const initialAssets = listWikiIngestJobAssetsInternal(jobId);
2701
+ let processedFiles = initialAssets.filter((asset) => asset.status === "completed").length;
2702
+ let totalFiles = Math.max(job.total_files, initialAssets.length);
2373
2703
  let hadSuccess = false;
2374
2704
  while (assetQueue().length > 0) {
2375
- const nextAsset = assetQueue().find((asset) => asset.status === "queued");
2705
+ const nextAsset = assetQueue().find((asset) => ["processing", "queued"].includes(asset.status));
2376
2706
  if (!nextAsset) {
2377
2707
  break;
2378
2708
  }
@@ -2433,7 +2763,27 @@ export async function processWikiIngestJob(jobId, options) {
2433
2763
  continue;
2434
2764
  }
2435
2765
  updateWikiIngestAsset(nextAsset.id, { status: "processing" });
2436
- createWikiIngestLog(jobId, `Processing ${nextAsset.file_name || nextAsset.source_locator}.`);
2766
+ currentAssetContext = {
2767
+ assetId: nextAsset.id,
2768
+ fileName: nextAsset.file_name || nextAsset.source_locator || "Source",
2769
+ sourceLocator: nextAsset.source_locator,
2770
+ checksum: nextAsset.checksum,
2771
+ fileIndex: Math.min(totalFiles, processedFiles + 1),
2772
+ totalFiles,
2773
+ chunkIndex: 1,
2774
+ chunkCount: 1
2775
+ };
2776
+ currentPollCount = 0;
2777
+ createWikiIngestLog(jobId, `Processing ${currentAssetContext.fileName} (${currentAssetContext.fileIndex}/${currentAssetContext.totalFiles}).`, "info", {
2778
+ sourceAssetId: currentAssetContext.assetId,
2779
+ fileName: currentAssetContext.fileName,
2780
+ sourceLocator: currentAssetContext.sourceLocator,
2781
+ checksum: currentAssetContext.checksum,
2782
+ fileIndex: currentAssetContext.fileIndex,
2783
+ totalFiles: currentAssetContext.totalFiles,
2784
+ chunkIndex: currentAssetContext.chunkIndex,
2785
+ chunkCount: currentAssetContext.chunkCount
2786
+ });
2437
2787
  try {
2438
2788
  const fetched = nextAsset.source_kind === "url"
2439
2789
  ? await getFetchedContent("url", {
@@ -2451,6 +2801,15 @@ export async function processWikiIngestJob(jobId, options) {
2451
2801
  jobId,
2452
2802
  fetched
2453
2803
  });
2804
+ const existingAssetMetadata = parseJsonRecord(nextAsset.metadata_json);
2805
+ const resumeResponseId = readStringRecordValue(existingAssetMetadata, "openAiResponseId") ??
2806
+ findOpenAiResponseIdForJobAsset({
2807
+ jobId,
2808
+ assetId: nextAsset.id,
2809
+ fileName: nextAsset.file_name,
2810
+ sourceLocator: nextAsset.source_locator,
2811
+ checksum: nextAsset.checksum
2812
+ });
2454
2813
  const compiled = llmProfile && parsed.parseStrategy !== "text_only"
2455
2814
  ? await options.llm.compileWikiIngest(llmProfile, {
2456
2815
  titleHint: parsed.titleHint,
@@ -2458,7 +2817,9 @@ export async function processWikiIngestJob(jobId, options) {
2458
2817
  binary: fetched.binary,
2459
2818
  mimeType: fetched.mimeType,
2460
2819
  parseStrategy: parsed.parseStrategy
2461
- })
2820
+ }, {
2821
+ resumeResponseId
2822
+ }, llmDiagnosticLogger)
2462
2823
  : llmProfile && fetched.contentText
2463
2824
  ? await options.llm.compileWikiIngest(llmProfile, {
2464
2825
  titleHint: parsed.titleHint,
@@ -2466,8 +2827,13 @@ export async function processWikiIngestJob(jobId, options) {
2466
2827
  binary: fetched.binary,
2467
2828
  mimeType: fetched.mimeType,
2468
2829
  parseStrategy: "text_only"
2469
- })
2830
+ }, {
2831
+ resumeResponseId
2832
+ }, llmDiagnosticLogger)
2470
2833
  : null;
2834
+ if (llmProfile && !compiled) {
2835
+ throw new Error("The LLM did not produce structured draft candidates. Check the OpenAI settings and try again.");
2836
+ }
2471
2837
  const title = compiled?.title ||
2472
2838
  parsed.titleHint ||
2473
2839
  inferTitle(fetched.contentText || fetched.fileName, "Imported source");
@@ -2491,6 +2857,7 @@ export async function processWikiIngestJob(jobId, options) {
2491
2857
  markdown,
2492
2858
  sourceLocator: fetched.locator,
2493
2859
  createAsKind: parsed.createAsKind,
2860
+ tags: compiled?.tags,
2494
2861
  linkedEntityHints: parsed.linkedEntityHints
2495
2862
  })
2496
2863
  });
@@ -2551,11 +2918,23 @@ export async function processWikiIngestJob(jobId, options) {
2551
2918
  payload: createPageCandidatePayload({
2552
2919
  title: articleTitle,
2553
2920
  summary: articleSummary,
2554
- markdown: `# ${articleTitle}\n\n${articleSummary}\n\n${articleRationale
2555
- ? `## Why this page\n\n${articleRationale}\n`
2556
- : ""}`,
2921
+ markdown: typeof candidate.markdown === "string" &&
2922
+ candidate.markdown.trim().length > 0
2923
+ ? candidate.markdown
2924
+ : `# ${articleTitle}\n\n${articleSummary}\n\n${articleRationale
2925
+ ? `## Why this page\n\n${articleRationale}\n`
2926
+ : ""}`,
2557
2927
  sourceLocator: fetched.locator,
2558
2928
  createAsKind: "wiki",
2929
+ aliases: Array.isArray(candidate.aliases)
2930
+ ? candidate.aliases.filter((alias) => typeof alias === "string")
2931
+ : [],
2932
+ parentSlug: typeof candidate.parentSlug === "string"
2933
+ ? candidate.parentSlug
2934
+ : null,
2935
+ tags: Array.isArray(candidate.tags)
2936
+ ? candidate.tags.filter((tag) => typeof tag === "string")
2937
+ : [],
2559
2938
  linkedEntityHints: parsed.linkedEntityHints
2560
2939
  })
2561
2940
  });
@@ -2568,7 +2947,9 @@ export async function processWikiIngestJob(jobId, options) {
2568
2947
  checksum: rawSource.checksum,
2569
2948
  metadata: {
2570
2949
  ...(parseJsonRecord(nextAsset.metadata_json) ?? {}),
2571
- rawSourcePath: rawSource.filePath
2950
+ rawSourcePath: rawSource.filePath,
2951
+ openAiResponseStatus: llmProfile ? "completed" : null,
2952
+ openAiLastPolledAt: llmProfile ? nowIso() : null
2572
2953
  }
2573
2954
  });
2574
2955
  hadSuccess = true;
@@ -2586,20 +2967,38 @@ export async function processWikiIngestJob(jobId, options) {
2586
2967
  latestMessage: `Prepared candidates from ${fetched.fileName}.`
2587
2968
  });
2588
2969
  createWikiIngestLog(jobId, `Prepared candidates from ${fetched.fileName}.`, "info", {
2970
+ fileName: currentAssetContext?.fileName ?? fetched.fileName,
2971
+ sourceAssetId: currentAssetContext?.assetId ?? null,
2972
+ fileIndex: currentAssetContext?.fileIndex ?? processedFiles,
2973
+ totalFiles: currentAssetContext?.totalFiles ?? totalFiles,
2589
2974
  pageCandidates: counts.pageCount,
2590
2975
  entityCandidates: counts.entityCount
2591
2976
  });
2977
+ currentAssetContext = null;
2978
+ currentPollCount = 0;
2592
2979
  }
2593
2980
  catch (error) {
2594
2981
  processedFiles += 1;
2595
2982
  updateWikiIngestAsset(nextAsset.id, { status: "failed" });
2983
+ updateCurrentAssetMetadata({
2984
+ openAiResponseStatus: "failed",
2985
+ openAiLastPolledAt: nowIso(),
2986
+ openAiPollCount: currentPollCount
2987
+ });
2596
2988
  updateWikiIngestJob(jobId, {
2597
2989
  processedFiles,
2598
2990
  totalFiles,
2599
2991
  progressPercent: calculateProgress(totalFiles, processedFiles),
2600
2992
  latestMessage: error instanceof Error ? error.message : "Source ingest failed."
2601
2993
  });
2602
- createWikiIngestLog(jobId, error instanceof Error ? error.message : "Source ingest failed.", "error");
2994
+ createWikiIngestLog(jobId, error instanceof Error ? error.message : "Source ingest failed.", "error", {
2995
+ fileName: currentAssetContext?.fileName ?? null,
2996
+ fileIndex: currentAssetContext?.fileIndex ?? null,
2997
+ totalFiles: currentAssetContext?.totalFiles ?? totalFiles,
2998
+ pollCount: currentPollCount
2999
+ });
3000
+ currentAssetContext = null;
3001
+ currentPollCount = 0;
2603
3002
  }
2604
3003
  }
2605
3004
  const counts = refreshCounts();
@@ -2635,6 +3034,94 @@ export function listWikiIngestJobs(input = {}) {
2635
3034
  .map((row) => getWikiIngestJob(row.id))
2636
3035
  .filter((job) => job !== null);
2637
3036
  }
3037
+ export async function rerunWikiIngestJob(jobId, options = {}) {
3038
+ const job = readWikiIngestJobRow(jobId);
3039
+ if (!job) {
3040
+ return null;
3041
+ }
3042
+ if (["queued", "processing"].includes(job.status)) {
3043
+ throw new Error("Wiki ingest jobs can only be rerun after processing ends.");
3044
+ }
3045
+ const ingestInput = readWikiIngestInput(jobId);
3046
+ if (!ingestInput) {
3047
+ throw new Error("Wiki ingest input could not be restored.");
3048
+ }
3049
+ const rootAssets = listWikiIngestJobAssetsInternal(jobId).filter((asset) => {
3050
+ const metadata = parseJsonRecord(asset.metadata_json);
3051
+ return !metadata?.parentAssetId && asset.source_kind !== "url";
3052
+ });
3053
+ const replayResult = rootAssets.length > 0
3054
+ ? await createUploadedWikiIngestJob({
3055
+ spaceId: ingestInput.spaceId,
3056
+ titleHint: ingestInput.titleHint,
3057
+ llmProfileId: ingestInput.llmProfileId,
3058
+ parseStrategy: ingestInput.parseStrategy,
3059
+ entityProposalMode: ingestInput.entityProposalMode,
3060
+ userId: ingestInput.userId ?? null,
3061
+ createAsKind: ingestInput.createAsKind,
3062
+ linkedEntityHints: ingestInput.linkedEntityHints
3063
+ }, await Promise.all(rootAssets.map(async (asset) => ({
3064
+ fileName: asset.file_name,
3065
+ mimeType: asset.mime_type,
3066
+ payload: await readFile(asset.file_path)
3067
+ }))), {
3068
+ actor: options.actor ?? job.created_by_actor ?? null
3069
+ })
3070
+ : await ingestWikiSource(ingestInput, {
3071
+ actor: options.actor ?? job.created_by_actor ?? null
3072
+ });
3073
+ const nextJobId = replayResult.job?.job.id ?? null;
3074
+ if (nextJobId) {
3075
+ createWikiIngestLog(nextJobId, `Reran ingest from ${jobId}.`, "info", {
3076
+ scope: "wiki_ingest",
3077
+ eventKey: "wiki_ingest_rerun",
3078
+ sourceJobId: jobId
3079
+ });
3080
+ createWikiIngestLog(jobId, `Created rerun job ${nextJobId}.`, "info", {
3081
+ scope: "wiki_ingest",
3082
+ eventKey: "wiki_ingest_rerun_requested",
3083
+ rerunJobId: nextJobId
3084
+ });
3085
+ }
3086
+ return replayResult;
3087
+ }
3088
+ export function deleteWikiIngestJob(jobId) {
3089
+ const job = readWikiIngestJobRow(jobId);
3090
+ if (!job) {
3091
+ return null;
3092
+ }
3093
+ if (["queued", "processing"].includes(job.status)) {
3094
+ throw new Error("Wiki ingest jobs can only be deleted after processing ends.");
3095
+ }
3096
+ const candidates = listWikiIngestCandidatesInternal(jobId);
3097
+ const assets = listWikiIngestJobAssetsInternal(jobId);
3098
+ const preservedAssetIds = new Set(candidates
3099
+ .filter((candidate) => candidate.status === "applied")
3100
+ .map((candidate) => candidate.source_asset_id)
3101
+ .filter((assetId) => typeof assetId === "string"));
3102
+ for (const asset of assets) {
3103
+ if (preservedAssetIds.has(asset.id)) {
3104
+ continue;
3105
+ }
3106
+ const metadata = parseJsonRecord(asset.metadata_json);
3107
+ const rawSourcePath = typeof metadata?.rawSourcePath === "string"
3108
+ ? metadata.rawSourcePath
3109
+ : null;
3110
+ if (rawSourcePath && existsSync(rawSourcePath)) {
3111
+ unlinkSync(rawSourcePath);
3112
+ }
3113
+ }
3114
+ const jobDir = getWikiIngestJobDir(jobId);
3115
+ if (existsSync(jobDir)) {
3116
+ rmSync(jobDir, { recursive: true, force: true });
3117
+ }
3118
+ getDatabase()
3119
+ .prepare(`DELETE FROM wiki_ingest_jobs WHERE id = ?`)
3120
+ .run(jobId);
3121
+ return {
3122
+ id: jobId
3123
+ };
3124
+ }
2638
3125
  export async function reviewWikiIngestJob(jobId, input, options) {
2639
3126
  const parsed = reviewWikiIngestJobSchema.parse(input);
2640
3127
  const job = readWikiIngestJobRow(jobId);
@@ -2647,6 +3134,7 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2647
3134
  }
2648
3135
  const candidates = listWikiIngestCandidatesInternal(jobId);
2649
3136
  let acceptedCount = 0;
3137
+ let mappedCount = 0;
2650
3138
  let rejectedCount = 0;
2651
3139
  let firstPublishedPageId = null;
2652
3140
  for (const decision of parsed.decisions) {
@@ -2654,7 +3142,12 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2654
3142
  if (!candidate) {
2655
3143
  continue;
2656
3144
  }
2657
- if (!decision.keep) {
3145
+ if (candidate.status === "applied") {
3146
+ continue;
3147
+ }
3148
+ const action = decision.action ??
3149
+ (decision.keep === false ? "discard" : "keep");
3150
+ if (action === "discard") {
2658
3151
  rejectedCount += 1;
2659
3152
  updateWikiIngestCandidate(candidate.id, { status: "rejected" });
2660
3153
  continue;
@@ -2662,36 +3155,77 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2662
3155
  try {
2663
3156
  const payload = parseJsonRecord(candidate.payload_json);
2664
3157
  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"
3158
+ if (action === "merge_existing") {
3159
+ const target = decision.targetNoteId
3160
+ ? (getWikiPageDetail(decision.targetNoteId)?.page ?? null)
3161
+ : null;
3162
+ if (!target || target.spaceId !== job.space_id) {
3163
+ throw new Error("Merge target page was not found.");
3164
+ }
3165
+ const incomingTitle = typeof payload.title === "string" ? payload.title : candidate.title;
3166
+ const incomingMarkdown = typeof payload.contentMarkdown === "string"
2679
3167
  ? 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
- });
3168
+ : `# ${candidate.title}\n`;
3169
+ const mergedContentMarkdown = mergeWikiPageContent(target.contentMarkdown, {
3170
+ title: incomingTitle,
3171
+ markdown: incomingMarkdown
3172
+ });
3173
+ const mergedSummary = inferSummary(mergedContentMarkdown);
3174
+ const updated = options.updateNote(target.id, {
3175
+ contentMarkdown: mergedContentMarkdown,
3176
+ summary: mergedSummary
3177
+ });
3178
+ if (!updated) {
3179
+ throw new Error("Merge target page could not be updated.");
3180
+ }
3181
+ acceptedCount += 1;
3182
+ firstPublishedPageId = firstPublishedPageId ?? updated.id;
3183
+ updateWikiIngestCandidate(candidate.id, {
3184
+ status: "applied",
3185
+ publishedNoteId: updated.id
3186
+ });
3187
+ }
3188
+ else {
3189
+ const note = options.createNote({
3190
+ kind: payload.kind === "evidence" ? "evidence" : "wiki",
3191
+ title: typeof payload.title === "string"
3192
+ ? payload.title
3193
+ : candidate.title,
3194
+ slug: "",
3195
+ indexOrder: 0,
3196
+ aliases: Array.isArray(payload.aliases)
3197
+ ? payload.aliases.filter((alias) => typeof alias === "string")
3198
+ : [],
3199
+ summary: typeof payload.summary === "string"
3200
+ ? payload.summary
3201
+ : candidate.summary,
3202
+ sourcePath: "",
3203
+ frontmatter: {},
3204
+ revisionHash: "",
3205
+ spaceId: job.space_id,
3206
+ parentSlug: typeof payload.parentSlug === "string"
3207
+ ? payload.parentSlug
3208
+ : null,
3209
+ contentMarkdown: typeof payload.contentMarkdown === "string"
3210
+ ? payload.contentMarkdown
3211
+ : `# ${candidate.title}\n`,
3212
+ author: ingestInput.userId ?? null,
3213
+ links: Array.isArray(payload.links)
3214
+ ? payload.links
3215
+ : ingestInput.linkedEntityHints,
3216
+ tags: Array.isArray(payload.tags)
3217
+ ? payload.tags.filter((tag) => typeof tag === "string")
3218
+ : [],
3219
+ destroyAt: null,
3220
+ userId: ingestInput.userId ?? null
3221
+ });
3222
+ acceptedCount += 1;
3223
+ firstPublishedPageId = firstPublishedPageId ?? note.id;
3224
+ updateWikiIngestCandidate(candidate.id, {
3225
+ status: "applied",
3226
+ publishedNoteId: note.id
3227
+ });
3228
+ }
2695
3229
  }
2696
3230
  else if (candidate.candidate_type === "page_update") {
2697
3231
  const targetSlug = typeof payload.targetSlug === "string"
@@ -2723,13 +3257,32 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2723
3257
  }
2724
3258
  }
2725
3259
  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
- });
3260
+ if (action === "map_existing") {
3261
+ const proposedType = typeof payload.entityType === "string" ? payload.entityType : "";
3262
+ if (decision.mappedEntityType !== proposedType) {
3263
+ throw new Error("Mapped entity type must match the proposed entity type.");
3264
+ }
3265
+ const mapped = options.resolveMappedEntity(decision.mappedEntityType, decision.mappedEntityId);
3266
+ if (!mapped) {
3267
+ throw new Error("Mapped entity was not found.");
3268
+ }
3269
+ acceptedCount += 1;
3270
+ mappedCount += 1;
3271
+ updateWikiIngestCandidate(candidate.id, {
3272
+ status: "applied",
3273
+ publishedEntityType: mapped.entityType,
3274
+ publishedEntityId: mapped.entityId
3275
+ });
3276
+ }
3277
+ else {
3278
+ const result = options.publishEntity(payload);
3279
+ acceptedCount += 1;
3280
+ updateWikiIngestCandidate(candidate.id, {
3281
+ status: "applied",
3282
+ publishedEntityType: result.entityType,
3283
+ publishedEntityId: result.entityId
3284
+ });
3285
+ }
2733
3286
  }
2734
3287
  }
2735
3288
  catch {
@@ -2742,10 +3295,10 @@ export async function reviewWikiIngestJob(jobId, input, options) {
2742
3295
  rejectedCount,
2743
3296
  pageNoteId: firstPublishedPageId,
2744
3297
  latestMessage: acceptedCount > 0 || rejectedCount > 0
2745
- ? `Review saved with ${acceptedCount} kept and ${rejectedCount} discarded.`
3298
+ ? `Review saved with ${acceptedCount} kept${mappedCount > 0 ? ` (${mappedCount} mapped)` : ""} and ${rejectedCount} discarded.`
2746
3299
  : "Review saved."
2747
3300
  });
2748
- createWikiIngestLog(jobId, `Review saved with ${acceptedCount} kept and ${rejectedCount} discarded.`);
3301
+ createWikiIngestLog(jobId, `Review saved with ${acceptedCount} kept${mappedCount > 0 ? ` (${mappedCount} mapped)` : ""} and ${rejectedCount} discarded.`);
2749
3302
  return getWikiIngestJob(jobId);
2750
3303
  }
2751
3304
  export function getWikiIngestJob(jobId) {