@equationalapplications/core-llm-wiki 5.4.0 → 5.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -110,26 +110,88 @@ var PrunePartialFailureError = class extends Error {
110
110
  }
111
111
  };
112
112
  var HOOK_TIMEOUT_MARKER = /* @__PURE__ */ Symbol("WikiMemoryHookTimeout");
113
+ var WikiParseError = class extends Error {
114
+ constructor(message, opts) {
115
+ super(message);
116
+ this.name = "WikiParseError";
117
+ this.tier = opts.tier;
118
+ this.position = opts.position ?? null;
119
+ this.slice = opts.slice ?? "";
120
+ }
121
+ };
122
+ var WikiIngestEmptyError = class extends Error {
123
+ constructor(params) {
124
+ const summary = `All ${params.chunks} chunks failed for sourceRef "${params.sourceRef}"; see parseFailures for per-chunk detail`;
125
+ super(summary);
126
+ this.name = "WikiIngestEmptyError";
127
+ this.parseFailures = params.parseFailures;
128
+ this.sourceRef = params.sourceRef;
129
+ this.chunks = params.chunks;
130
+ }
131
+ };
113
132
 
114
133
  // src/utils/pure.ts
134
+ var MAX_REPAIR_CANDIDATES = 5;
135
+ function throwNoJsonFound(text, start) {
136
+ throw new WikiParseError(
137
+ "No JSON object/array found in LLM response",
138
+ { tier: "strict", position: start, slice: text }
139
+ );
140
+ }
115
141
  function parseJsonResponse(text) {
116
142
  const firstBrace = text.indexOf("{");
117
143
  const firstBracket = text.indexOf("[");
118
144
  let start;
119
145
  let openChar;
120
- let closeChar;
121
146
  const useBrace = firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket);
122
147
  if (useBrace) {
123
148
  start = firstBrace;
124
149
  openChar = "{";
125
- closeChar = "}";
126
150
  } else if (firstBracket !== -1) {
127
151
  start = firstBracket;
128
152
  openChar = "[";
129
- closeChar = "]";
130
153
  } else {
131
- throw new SyntaxError("No JSON object/array found in LLM response");
154
+ throwNoJsonFound(text, null);
155
+ }
156
+ const slice = scanJsonSlice(text, start, openChar);
157
+ if (slice !== null) {
158
+ try {
159
+ return JSON.parse(slice);
160
+ } catch {
161
+ }
162
+ }
163
+ const repairOdd = containerAwareRepair(text, start, false);
164
+ if (repairOdd.success !== null && !repairOdd.ambiguous) {
165
+ return repairOdd.success;
132
166
  }
167
+ const repairEven = containerAwareRepair(text, start, true);
168
+ if (repairOdd.success !== null && repairEven.success !== null) {
169
+ const pick = repairEven.escapes < repairOdd.escapes ? repairEven : repairOdd;
170
+ return pick.success;
171
+ }
172
+ if (repairOdd.success !== null) {
173
+ return repairOdd.success;
174
+ }
175
+ if (repairEven.success !== null) {
176
+ return repairEven.success;
177
+ }
178
+ const repair = repairEven.failed !== null ? repairEven : repairOdd;
179
+ if (repair.failed !== null) {
180
+ throw new WikiParseError(
181
+ `Repair produced a candidate but JSON.parse rejected it: ${repair.failed.message}`,
182
+ { tier: "repair", position: repair.failed.position, slice: repair.failed.candidate }
183
+ );
184
+ }
185
+ if (slice === null) {
186
+ throwNoJsonFound(text, start);
187
+ }
188
+ throw new WikiParseError(
189
+ "No parsable JSON candidate found",
190
+ { tier: "all", position: start, slice: text }
191
+ );
192
+ }
193
+ function scanJsonSlice(text, start, openChar) {
194
+ const closeChar = openChar === "{" ? "}" : "]";
133
195
  let depth = 0;
134
196
  let inString = false;
135
197
  let escape = false;
@@ -161,20 +223,248 @@ function parseJsonResponse(text) {
161
223
  }
162
224
  }
163
225
  }
164
- if (end === -1) throw new SyntaxError("No JSON object/array found in LLM response");
165
- return JSON.parse(text.slice(start, end + 1));
226
+ if (end === -1) return null;
227
+ return safeSlice(text, start, end + 1);
228
+ }
229
+ function containerAwareRepair(text, start, closeOnEvenParity) {
230
+ const stack = [];
231
+ let inString = false;
232
+ let stringRole = null;
233
+ let escape = false;
234
+ let i = start;
235
+ let out = "";
236
+ let bareQuoteCount = 0;
237
+ let bestFailed = null;
238
+ let escapes = 0;
239
+ let ambiguous = false;
240
+ let bestSuccess = null;
241
+ let attempts = 0;
242
+ function tryEmitCandidate(candidate) {
243
+ attempts++;
244
+ try {
245
+ bestSuccess = JSON.parse(candidate);
246
+ return true;
247
+ } catch (err) {
248
+ if (bestFailed === null) {
249
+ bestFailed = {
250
+ candidate,
251
+ position: extractParsePosition(err),
252
+ message: safeErrorToString(err)
253
+ };
254
+ }
255
+ return false;
256
+ }
257
+ }
258
+ while (i < text.length) {
259
+ const ch = text[i];
260
+ if (stack.length === 0 && !inString) {
261
+ if (ch === "{" || ch === "[") {
262
+ stack.push({ container: ch === "{" ? "object" : "array", expectKey: true });
263
+ out += ch;
264
+ }
265
+ i++;
266
+ continue;
267
+ }
268
+ if (escape) {
269
+ out += ch;
270
+ escape = false;
271
+ i++;
272
+ continue;
273
+ }
274
+ if (inString && ch === "\\") {
275
+ out += ch;
276
+ escape = true;
277
+ i++;
278
+ continue;
279
+ }
280
+ if (inString) {
281
+ const code = ch.charCodeAt(0);
282
+ if (code < 32) {
283
+ out += controlCharEscape(code);
284
+ i++;
285
+ continue;
286
+ }
287
+ }
288
+ if (ch === '"') {
289
+ let j = i + 1;
290
+ while (j < text.length && (text[j] === " " || text[j] === " " || text[j] === "\n" || text[j] === "\r")) {
291
+ j++;
292
+ }
293
+ const next = j < text.length ? text[j] : "";
294
+ if (!inString) {
295
+ if (next === "}" || next === "]") {
296
+ if (j > i + 1) out += text.slice(i + 1, j);
297
+ out += next;
298
+ i = j + 1;
299
+ stack.pop();
300
+ if (stack.length === 0) {
301
+ if (tryEmitCandidate(out)) return { success: bestSuccess, failed: bestFailed, escapes, ambiguous };
302
+ if (attempts >= MAX_REPAIR_CANDIDATES) break;
303
+ out = "";
304
+ }
305
+ continue;
306
+ }
307
+ const top = stack[stack.length - 1];
308
+ stringRole = top?.container === "object" && top.expectKey ? "key" : "value";
309
+ out += ch;
310
+ inString = true;
311
+ i++;
312
+ continue;
313
+ }
314
+ const commaOnly = next === ",";
315
+ const isKeyClose = stringRole === "key" && (next === ":" || next === '"' && j + 1 < text.length && text[j + 1] === ":");
316
+ const isValueClose = !commaOnly && (next === "," || next === "}" || next === "]");
317
+ if (commaOnly && bareQuoteCount > 0) ambiguous = true;
318
+ const shouldCommaClose = commaOnly && (bareQuoteCount === 0 || (closeOnEvenParity ? bareQuoteCount % 2 === 0 : bareQuoteCount % 2 === 1));
319
+ const isClosing = isKeyClose || isValueClose || shouldCommaClose;
320
+ if (isClosing) {
321
+ out += ch;
322
+ inString = false;
323
+ const topFrame = stack[stack.length - 1];
324
+ if (topFrame && topFrame.container === "object") {
325
+ topFrame.expectKey = stringRole === "value";
326
+ }
327
+ stringRole = null;
328
+ bareQuoteCount = 0;
329
+ i++;
330
+ continue;
331
+ }
332
+ out += "\\" + ch;
333
+ bareQuoteCount++;
334
+ escapes++;
335
+ i++;
336
+ continue;
337
+ }
338
+ if (!inString && (ch === "{" || ch === "[")) {
339
+ stack.push({ container: ch === "{" ? "object" : "array", expectKey: true });
340
+ out += ch;
341
+ i++;
342
+ continue;
343
+ }
344
+ if (!inString && ch === ",") {
345
+ const topFrame = stack[stack.length - 1];
346
+ if (topFrame && topFrame.container === "object") {
347
+ topFrame.expectKey = true;
348
+ }
349
+ out += ch;
350
+ i++;
351
+ continue;
352
+ }
353
+ if (!inString && (ch === "}" || ch === "]")) {
354
+ out += ch;
355
+ stack.pop();
356
+ if (stack.length === 0) {
357
+ if (tryEmitCandidate(out)) return { success: bestSuccess, failed: bestFailed, escapes, ambiguous };
358
+ if (attempts >= MAX_REPAIR_CANDIDATES) break;
359
+ out = "";
360
+ }
361
+ i++;
362
+ continue;
363
+ }
364
+ out += ch;
365
+ i++;
366
+ }
367
+ return { success: null, failed: bestFailed, escapes, ambiguous };
368
+ }
369
+ function controlCharEscape(code) {
370
+ switch (code) {
371
+ case 10:
372
+ return "\\n";
373
+ case 9:
374
+ return "\\t";
375
+ case 13:
376
+ return "\\r";
377
+ case 8:
378
+ return "\\b";
379
+ case 12:
380
+ return "\\f";
381
+ default:
382
+ return "\\u" + code.toString(16).padStart(4, "0");
383
+ }
384
+ }
385
+ function extractParsePosition(err) {
386
+ const match = /position\s+(\d+)/.exec(safeErrorToString(err));
387
+ return match ? Number(match[1]) : null;
388
+ }
389
+ function safeErrorToString(e) {
390
+ let isErrorLike = false;
391
+ try {
392
+ isErrorLike = e instanceof Error;
393
+ } catch {
394
+ }
395
+ if (isErrorLike) {
396
+ const msg = readErrorField(e, "message");
397
+ if (typeof msg === "string" && msg.length > 0) return msg;
398
+ const name = readErrorField(e, "name");
399
+ if (typeof name === "string" && name.length > 0) return name;
400
+ return "[Error]";
401
+ }
402
+ try {
403
+ return String(e);
404
+ } catch {
405
+ try {
406
+ return Object.prototype.toString.call(e);
407
+ } catch {
408
+ return "[unstringifiable error]";
409
+ }
410
+ }
411
+ }
412
+ function readErrorField(e, key) {
413
+ try {
414
+ return e[key];
415
+ } catch {
416
+ return void 0;
417
+ }
166
418
  }
167
419
  function sanitizeRankerError(err, sanitizeRankerErrors) {
420
+ let isErrorLike = false;
421
+ try {
422
+ isErrorLike = err instanceof Error;
423
+ } catch {
424
+ }
168
425
  if (sanitizeRankerErrors === false) {
169
- return err instanceof Error ? err : new Error(String(err));
426
+ return isErrorLike ? err : new Error(safeErrorToString(err));
427
+ }
428
+ let errLike = null;
429
+ if (isErrorLike) errLike = err;
430
+ let typeName;
431
+ try {
432
+ if (errLike) {
433
+ const rawName = errLike.constructor?.name;
434
+ typeName = typeof rawName === "string" ? rawName : "Error";
435
+ } else {
436
+ typeName = typeof err;
437
+ }
438
+ } catch {
439
+ typeName = isErrorLike ? "Error" : typeof err;
440
+ }
441
+ let innerCause;
442
+ if (errLike) {
443
+ let cause;
444
+ try {
445
+ cause = errLike.cause;
446
+ } catch {
447
+ cause = void 0;
448
+ }
449
+ if (cause !== void 0) {
450
+ let causeName;
451
+ try {
452
+ const rawCauseName = cause?.constructor?.name;
453
+ causeName = typeof rawCauseName === "string" ? rawCauseName : typeof cause;
454
+ } catch {
455
+ causeName = typeof cause;
456
+ }
457
+ innerCause = new Error(`Caused by: ${causeName}`);
458
+ }
170
459
  }
171
- const typeName = err instanceof Error ? err.constructor?.name ?? "Error" : typeof err;
172
- const innerCause = err instanceof Error && err.cause !== void 0 ? new Error(`Caused by: ${err.cause?.constructor?.name ?? typeof err.cause}`) : void 0;
173
460
  const sanitized = new Error(
174
461
  `VectorRanker ${typeName} (message scrubbed for security)`,
175
462
  innerCause ? { cause: innerCause } : void 0
176
463
  );
177
- sanitized.name = typeName;
464
+ try {
465
+ sanitized.name = typeName;
466
+ } catch {
467
+ }
178
468
  return sanitized;
179
469
  }
180
470
  function safeSlice(value, start, end) {
@@ -969,7 +1259,7 @@ Return ONLY a valid JSON object matching this schema:
969
1259
  {
970
1260
  "facts": [{ "title": "string (max 80 chars)", "body": "string (max 800 chars)", "tags": ["string"], "confidence": "certain|inferred|tentative" }]
971
1261
  }
972
- Extract verbatim factual content. Do not return markdown, just raw JSON.`;
1262
+ Extract verbatim factual content. JSON escaping rules: every literal " character in the source must be escaped as \\" inside any JSON string body, and every literal newline as \\n. Source prose containing quotes (e.g. a worked example with "...") must still be reproduced exactly \u2014 re-escape, do not omit. Do not return markdown, just raw JSON.`;
973
1263
  var ONTOLOGY_BACKFILL_SYSTEM_PROMPT = `You are a knowledge classification agent. You will receive existing memory facts that currently have no ontology type. For each input fact { "id", "title", "body", "tags" }, assign the best matching okf_type from the ontology manifest and optionally propose edges to related facts by title.
974
1264
  Return ONLY a valid JSON object matching this schema:
975
1265
  {
@@ -978,7 +1268,7 @@ Return ONLY a valid JSON object matching this schema:
978
1268
  ]
979
1269
  }
980
1270
  If no manifest type fits a fact, omit that fact from "classifications" entirely \u2014 do not guess.
981
- Do not return markdown, just raw JSON.`;
1271
+ When echoing an existing fact's title verbatim into "target_title", preserve every JSON escape sequence (\\", \\n, \\\\, \\/) exactly as it appeared in the input body \u2014 do not strip backslashes, do not add unescaped quotes. Do not return markdown, just raw JSON.`;
982
1272
 
983
1273
  // src/services/PromptService.ts
984
1274
  var PromptService = class {
@@ -1091,6 +1381,16 @@ var DEFAULT_MAX_CHUNK_LENGTH = 12e3;
1091
1381
  var DEFAULT_CHUNK_OVERLAP = 400;
1092
1382
 
1093
1383
  // src/services/IngestionService.ts
1384
+ function zeroChunkResult(duplicateOf) {
1385
+ const result = {
1386
+ truncated: false,
1387
+ chunks: 0,
1388
+ ingestedChunks: 0,
1389
+ failedChunks: 0
1390
+ };
1391
+ if (duplicateOf !== void 0) result.duplicateOf = duplicateOf;
1392
+ return result;
1393
+ }
1094
1394
  var IngestionService = class {
1095
1395
  constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, metadataRepo, edgeRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1096
1396
  this.db = db;
@@ -1131,113 +1431,110 @@ var IngestionService = class {
1131
1431
  if (onDuplicateHash === "throw") {
1132
1432
  throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1133
1433
  }
1134
- return { truncated: false, chunks: 0, duplicateOf: canonical };
1434
+ return zeroChunkResult(canonical);
1135
1435
  }
1136
1436
  }
1137
1437
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
1138
- if (chunks.length === 0) return { truncated: false, chunks: 0 };
1438
+ if (chunks.length === 0) return zeroChunkResult();
1439
+ const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
1139
1440
  const chunkResults = await withConcurrency(
1140
- chunks.map((chunk) => async () => {
1141
- const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
1441
+ chunks.map((chunk, chunkIndex) => async () => {
1142
1442
  const { systemPrompt, userPrompt } = this.promptService.buildIngestPrompt(
1143
1443
  chunk,
1144
1444
  params.promptOverride,
1145
1445
  ontologyContext
1146
1446
  );
1147
- const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
1148
- const result = parseJsonResponse(responseText);
1149
- return {
1150
- facts: (Array.isArray(result.facts) ? result.facts : []).map(validateFact).filter((f) => f !== null),
1151
- ontology_updates: result.ontology_updates
1152
- };
1447
+ try {
1448
+ const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
1449
+ const result2 = parseJsonResponse(responseText);
1450
+ return {
1451
+ status: "ok",
1452
+ facts: (Array.isArray(result2.facts) ? result2.facts : []).map(validateFact).filter((f) => f !== null),
1453
+ ontology_updates: result2.ontology_updates
1454
+ };
1455
+ } catch (e) {
1456
+ const failure = e instanceof WikiParseError ? {
1457
+ chunkIndex,
1458
+ sourceRef,
1459
+ source: "parse",
1460
+ tier: e.tier,
1461
+ position: e.position,
1462
+ message: e.message
1463
+ } : {
1464
+ chunkIndex,
1465
+ sourceRef,
1466
+ source: "llm",
1467
+ // `safeErrorToString` is a non-throwing coercion: an LLM
1468
+ // provider may reject with a non-Error value, and even
1469
+ // `String(e)` itself can throw when `e.toString()` throws.
1470
+ // Letting such an exception escape this catch would cause
1471
+ // `withConcurrency` to reject, discarding every sibling
1472
+ // chunk's results — the exact failure mode this per-chunk
1473
+ // try/catch exists to prevent. Keeping the value in the
1474
+ // `ChunkFailure` preserves the typed diagnostic for
1475
+ // callers (see parseFailures) without unwinding the loop.
1476
+ position: null,
1477
+ message: safeErrorToString(e)
1478
+ };
1479
+ const total = chunks.length;
1480
+ const tags = [];
1481
+ if (failure.tier) tags.push(`tier=${failure.tier}`);
1482
+ if (failure.position !== null) tags.push(`position=${failure.position}`);
1483
+ const tagsSuffix = tags.length > 0 ? `; ${tags.join(" ")}` : "";
1484
+ console.warn(
1485
+ `[WikiMemory] ingest chunk ${chunkIndex + 1}/${total} ${failure.source} failed (sourceRef=${sourceRef}${tagsSuffix})`
1486
+ );
1487
+ return { status: "failed", error: failure };
1488
+ }
1153
1489
  }),
1154
1490
  chunkConcurrency
1155
1491
  );
1492
+ let ingestedChunks = 0;
1493
+ let failedChunks = 0;
1494
+ const failures = [];
1156
1495
  const seen = /* @__PURE__ */ new Set();
1157
1496
  const orderedChunkFacts = [];
1158
- for (const chunkResult of chunkResults) {
1497
+ for (const slot of chunkResults) {
1498
+ if (slot.status === "failed") {
1499
+ failedChunks++;
1500
+ failures.push(slot.error);
1501
+ continue;
1502
+ }
1503
+ ingestedChunks++;
1159
1504
  const dedupedFacts = [];
1160
- for (const fact of chunkResult.facts) {
1505
+ for (const fact of slot.facts) {
1161
1506
  const normalizedTitle = normalizeTitleKey(fact.title);
1162
1507
  if (!seen.has(normalizedTitle)) {
1163
1508
  seen.add(normalizedTitle);
1164
1509
  dedupedFacts.push(fact);
1165
1510
  }
1166
1511
  }
1167
- orderedChunkFacts.push({ facts: dedupedFacts, ontology_updates: chunkResult.ontology_updates });
1512
+ orderedChunkFacts.push({ facts: dedupedFacts, ontology_updates: slot.ontology_updates });
1513
+ }
1514
+ if (failedChunks === chunks.length) {
1515
+ throw new WikiIngestEmptyError({
1516
+ parseFailures: failures,
1517
+ sourceRef,
1518
+ chunks: chunks.length
1519
+ });
1168
1520
  }
1169
- const now = Date.now();
1170
1521
  const insertedFacts = [];
1171
1522
  const deletedSourceFactIds = [];
1172
1523
  try {
1173
- await this.db.withTransactionAsync(async (tx) => {
1174
- deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1175
- const titleIndex = /* @__PURE__ */ new Map();
1176
- const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx, sourceRef);
1177
- for (const existing of existingFacts) {
1178
- titleIndex.set(normalizeTitleKey(existing.title), {
1179
- id: existing.id,
1180
- okf_type: existing.okf_type ?? null
1181
- });
1182
- }
1183
- let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1184
- let { mode, manifest } = ontologyState;
1185
- const hostNodes = [];
1186
- const rawEdgeRequests = [];
1187
- for (const { facts, ontology_updates } of orderedChunkFacts) {
1188
- if (mode === "emergent" && ontology_updates && this.ontologyService) {
1189
- manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1190
- ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1191
- mode = ontologyState.mode;
1192
- }
1193
- for (const fact of facts) {
1194
- const ontologyFact = fact;
1195
- const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest, { strict: false }) ?? { okf_type: null, edges: [] };
1196
- const id = generateId("fact_");
1197
- hostNodes.push({
1198
- id,
1199
- type: ontologyFact.okf_type ?? "",
1200
- title: fact.title,
1201
- body: fact.body,
1202
- // Forward the LLM-extracted tags/confidence through the
1203
- // extracted-shape upsertGraphCore path so the entries row
1204
- // stores them (search filterability, heal-candidate
1205
- // selection, runReembed embedding-text signal). The public
1206
- // WikiMemory.upsertGraph API doesn't accept tags/confidence
1207
- // — host-supplied deterministic nodes default to [] and
1208
- // 'certain' as documented.
1209
- tags: fact.tags,
1210
- confidence: fact.confidence
1211
- });
1212
- insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1213
- titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1214
- if (normalized.edges.length > 0) {
1215
- rawEdgeRequests.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1216
- }
1217
- }
1218
- }
1219
- const hostEdges = [];
1220
- for (const req of rawEdgeRequests) {
1221
- const resolved = this.ontologyService?.resolveEdges(
1222
- entityId,
1223
- req.sourceId,
1224
- req.sourceType,
1225
- req.edges,
1226
- manifest,
1227
- titleIndex,
1228
- now
1229
- ) ?? [];
1230
- for (const e of resolved) {
1231
- hostEdges.push({ type: e.edge_type, sourceId: e.source_id, targetId: e.target_id });
1232
- }
1233
- }
1234
- await this.upsertGraphCore(
1235
- entityId,
1236
- { sourceRef, sourceHash, nodes: hostNodes, edges: hostEdges },
1237
- tx,
1238
- { strict: false }
1239
- );
1240
- });
1524
+ if (failedChunks === 0) {
1525
+ const fullResult = await this.db.withTransactionAsync(async (tx) => {
1526
+ return await this.runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx);
1527
+ });
1528
+ deletedSourceFactIds.push(...fullResult.deletedSourceFactIds);
1529
+ insertedFacts.push(...fullResult.insertedFacts);
1530
+ } else {
1531
+ const partialResult = await this.db.withTransactionAsync(async (tx) => {
1532
+ const flat = [];
1533
+ for (const slot of orderedChunkFacts) flat.push(...slot.facts);
1534
+ return await this.appendPartialFacts(entityId, sourceRef, flat, tx);
1535
+ });
1536
+ insertedFacts.push(...partialResult.insertedDescriptors);
1537
+ }
1241
1538
  } catch (err) {
1242
1539
  const sqliteCode = err instanceof WikiTransactionError ? err.sqliteErrorCode : extractSqliteCode(err);
1243
1540
  if (sqliteCode !== "SQLITE_CONSTRAINT_UNIQUE") throw err;
@@ -1246,22 +1543,31 @@ var IngestionService = class {
1246
1543
  if (onDuplicateHash === "throw" || onDuplicateHash === "ingest") {
1247
1544
  throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1248
1545
  }
1249
- return { truncated: false, chunks: 0, duplicateOf: canonical };
1546
+ return zeroChunkResult(canonical);
1250
1547
  }
1251
1548
  await this.searchService.sync(entityId);
1252
- const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
1253
- for (const factId of uniqueDeletedSourceFactIds) {
1254
- try {
1255
- await this.embeddingService.notifyEmbeddingPersisted(entityId, factId, null);
1256
- } catch (hookErr) {
1257
- console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during ingest for ${factId}:`, hookErr);
1549
+ if (failedChunks === 0) {
1550
+ const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
1551
+ for (const factId of uniqueDeletedSourceFactIds) {
1552
+ try {
1553
+ await this.embeddingService.notifyEmbeddingPersisted(entityId, factId, null);
1554
+ } catch (hookErr) {
1555
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during ingest for ${factId}:`, hookErr);
1556
+ }
1258
1557
  }
1259
1558
  }
1260
1559
  for (const fact of insertedFacts) {
1261
1560
  await this.embeddingService.embedFact(fact);
1262
1561
  }
1263
1562
  this.searchService.evictCache(entityId);
1264
- return { truncated, chunks: chunks.length };
1563
+ const result = {
1564
+ truncated,
1565
+ chunks: chunks.length,
1566
+ ingestedChunks,
1567
+ failedChunks
1568
+ };
1569
+ if (failedChunks > 0) result.parseFailures = failures;
1570
+ return result;
1265
1571
  } finally {
1266
1572
  releaseIngestLocks();
1267
1573
  }
@@ -1386,6 +1692,156 @@ var IngestionService = class {
1386
1692
  superseded: deletedFactIds.length + deletedEdgeCount
1387
1693
  };
1388
1694
  }
1695
+ /**
1696
+ * Full supersession + ownership path: identical to the pre-issue-#92 behavior
1697
+ * for the happy path. Called from the `failedChunks === 0` branch of
1698
+ * `ingestDocument`. Runs INSIDE the caller's tx.
1699
+ *
1700
+ * Returns `{ deletedSourceFactIds, insertedFacts }` so the caller can fire
1701
+ * post-commit hooks (embedding lifecycle) AFTER the transaction commits.
1702
+ * On the partial path the caller never invokes this method; the empty
1703
+ * arrays stay empty.
1704
+ */
1705
+ async runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx) {
1706
+ const deletedSourceFactIds = [];
1707
+ const insertedFacts = [];
1708
+ deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1709
+ const titleIndex = /* @__PURE__ */ new Map();
1710
+ const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx, sourceRef);
1711
+ for (const existing of existingFacts) {
1712
+ titleIndex.set(normalizeTitleKey(existing.title), {
1713
+ id: existing.id,
1714
+ okf_type: existing.okf_type ?? null
1715
+ });
1716
+ }
1717
+ let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1718
+ let { mode, manifest } = ontologyState;
1719
+ const hostNodes = [];
1720
+ const rawEdgeRequests = [];
1721
+ const now = Date.now();
1722
+ for (const { facts, ontology_updates } of orderedChunkFacts) {
1723
+ if (mode === "emergent" && ontology_updates && this.ontologyService) {
1724
+ manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1725
+ ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1726
+ mode = ontologyState.mode;
1727
+ }
1728
+ for (const fact of facts) {
1729
+ const ontologyFact = fact;
1730
+ const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest, { strict: false }) ?? { okf_type: null, edges: [] };
1731
+ const id = generateId("fact_");
1732
+ hostNodes.push({
1733
+ id,
1734
+ type: ontologyFact.okf_type ?? "",
1735
+ title: fact.title,
1736
+ body: fact.body,
1737
+ // Forward the LLM-extracted tags/confidence through the
1738
+ // extracted-shape upsertGraphCore path so the entries row
1739
+ // stores them (search filterability, heal-candidate
1740
+ // selection, runReembed embedding-text signal). The public
1741
+ // WikiMemory.upsertGraph API doesn't accept tags/confidence
1742
+ // — host-supplied deterministic nodes default to [] and
1743
+ // 'certain' as documented.
1744
+ tags: fact.tags,
1745
+ confidence: fact.confidence
1746
+ });
1747
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1748
+ titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1749
+ if (normalized.edges.length > 0) {
1750
+ rawEdgeRequests.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1751
+ }
1752
+ }
1753
+ }
1754
+ const hostEdges = [];
1755
+ for (const req of rawEdgeRequests) {
1756
+ const resolved = this.ontologyService?.resolveEdges(
1757
+ entityId,
1758
+ req.sourceId,
1759
+ req.sourceType,
1760
+ req.edges,
1761
+ manifest,
1762
+ titleIndex,
1763
+ now
1764
+ ) ?? [];
1765
+ for (const e of resolved) {
1766
+ hostEdges.push({ type: e.edge_type, sourceId: e.source_id, targetId: e.target_id });
1767
+ }
1768
+ }
1769
+ await this.upsertGraphCore(
1770
+ entityId,
1771
+ { sourceRef, sourceHash, nodes: hostNodes, edges: hostEdges },
1772
+ tx,
1773
+ { strict: false }
1774
+ );
1775
+ return { deletedSourceFactIds, insertedFacts };
1776
+ }
1777
+ /**
1778
+ * Partial-commit path for `ingestDocument`. Inserts ONLY facts whose
1779
+ * normalized title is not already in the live set for this (entityId,
1780
+ * sourceRef). Does NOT call `entryRepo.softDeleteBySource` (no
1781
+ * supersession) and does NOT call `sourceRefIndexRepo.upsert` (no
1782
+ * ownership). Edges are NOT resolved on this path (no ontology-context
1783
+ * build, no `resolveEdges`, no `mergeEmergentUpdates`). Stale edges from
1784
+ * prior attempts are recovered on the next full run's supersession.
1785
+ *
1786
+ * Source-hash is stored as NULL on partial rows. `findLatestSourceHash`
1787
+ * reads from the most recently updated live row for the sourceRef, so
1788
+ * storing the incoming hash here would cause `hasChanged` to return
1789
+ * `false` on a same-hash retry — the failed chunks would never get a
1790
+ * second chance. With NULL, a retry sees `storedHash === null`, `hasChanged`
1791
+ * returns `true`, and the partial commit's sibling rows remain live
1792
+ * (deduped, not superseded) for the next attempt to extend.
1793
+ *
1794
+ * Runs INSIDE the caller's `tx`. Does not open a nested transaction.
1795
+ * Returns `{ inserted, skippedDuplicate }` for observability.
1796
+ */
1797
+ async appendPartialFacts(entityId, sourceRef, dedupedFacts, tx) {
1798
+ const liveIds = await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false);
1799
+ const liveFacts = liveIds.length === 0 ? [] : await this.entryRepo.findByIds(liveIds, void 0, tx);
1800
+ const liveTitles = new Set(liveFacts.map((f) => normalizeTitleKey(f.title)));
1801
+ let inserted = 0;
1802
+ let skippedDuplicate = 0;
1803
+ const now = Date.now();
1804
+ const insertedDescriptors = [];
1805
+ for (const fact of dedupedFacts) {
1806
+ const normalizedTitle = normalizeTitleKey(fact.title);
1807
+ if (liveTitles.has(normalizedTitle)) {
1808
+ skippedDuplicate++;
1809
+ continue;
1810
+ }
1811
+ liveTitles.add(normalizedTitle);
1812
+ const id = generateId("fact_");
1813
+ const wikiFact = {
1814
+ id,
1815
+ entity_id: entityId,
1816
+ title: fact.title,
1817
+ body: fact.body,
1818
+ tags: fact.tags,
1819
+ confidence: fact.confidence,
1820
+ source_type: "immutable_document",
1821
+ // source_hash: null on partial rows — see method docstring. The
1822
+ // full path stamps the actual hash; partial rows stay hash-less so
1823
+ // `hasChanged` keeps returning true for retries.
1824
+ source_hash: null,
1825
+ source_ref: sourceRef,
1826
+ created_at: now,
1827
+ updated_at: now,
1828
+ last_accessed_at: null,
1829
+ access_count: 0,
1830
+ deleted_at: null,
1831
+ okf_type: null
1832
+ };
1833
+ await this.entryRepo.upsert(wikiFact, tx);
1834
+ insertedDescriptors.push({
1835
+ id,
1836
+ entity_id: entityId,
1837
+ title: fact.title,
1838
+ body: fact.body,
1839
+ tags: JSON.stringify(fact.tags)
1840
+ });
1841
+ inserted++;
1842
+ }
1843
+ return { inserted, skippedDuplicate, insertedDescriptors };
1844
+ }
1389
1845
  };
1390
1846
 
1391
1847
  // src/utils/embedding.ts
@@ -1430,7 +1886,12 @@ var TRUNCATION_PATTERNS = [
1430
1886
  ];
1431
1887
  var EXCEEDS_LIMIT_PATTERN = /exceed[a-z]*[^.]{0,40}\b(model|context)?[ _-]?limit/i;
1432
1888
  function isTruncationError(err) {
1433
- const message = err instanceof Error ? err.message : String(err ?? "");
1889
+ let message;
1890
+ try {
1891
+ message = err instanceof Error ? err.message : String(err ?? "");
1892
+ } catch {
1893
+ return false;
1894
+ }
1434
1895
  if (EXCEEDS_LIMIT_PATTERN.test(message)) return false;
1435
1896
  return TRUNCATION_PATTERNS.some((pattern) => pattern.test(message));
1436
1897
  }
@@ -1534,6 +1995,30 @@ var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
1534
1995
  var HEAL_MAX_PROMPT_CHARS = 4e4;
1535
1996
  var HEAL_BATCH_SIZE = 25;
1536
1997
  var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1998
+ var SKIP_ERROR_LOG_CHARS = 4096;
1999
+ var formatSkipError = (err) => {
2000
+ let base;
2001
+ let isErrorLike = false;
2002
+ try {
2003
+ isErrorLike = err instanceof Error;
2004
+ } catch {
2005
+ }
2006
+ if (isErrorLike || typeof err !== "object" && typeof err !== "function") {
2007
+ base = safeErrorToString(err);
2008
+ } else {
2009
+ try {
2010
+ const json = JSON.stringify(err);
2011
+ base = typeof json === "string" ? json : safeErrorToString(err);
2012
+ } catch {
2013
+ base = safeErrorToString(err);
2014
+ }
2015
+ }
2016
+ if (base.length > SKIP_ERROR_LOG_CHARS) {
2017
+ const truncated = safeSlice(base, 0, SKIP_ERROR_LOG_CHARS);
2018
+ return `${truncated}\u2026[+${base.length - SKIP_ERROR_LOG_CHARS} chars truncated]`;
2019
+ }
2020
+ return base;
2021
+ };
1537
2022
  var MaintenanceService = class {
1538
2023
  constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1539
2024
  this.db = db;
@@ -2014,8 +2499,7 @@ var MaintenanceService = class {
2014
2499
  maxPromptChars: HEAL_MAX_PROMPT_CHARS,
2015
2500
  onSkip: (fact, err) => {
2016
2501
  console.warn(
2017
- `[WikiMemory] heal skipped ${entityId}/${fact.id}: response could not be bounded`,
2018
- err
2502
+ `[WikiMemory] heal skipped ${entityId}/${fact.id}: response could not be bounded: ${formatSkipError(err)}`
2019
2503
  );
2020
2504
  }
2021
2505
  });
@@ -2153,8 +2637,7 @@ var MaintenanceService = class {
2153
2637
  maxPromptChars: ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS,
2154
2638
  onSkip: (fact, err) => {
2155
2639
  console.warn(
2156
- `[WikiMemory] ontology backfill skipped ${entityId}/${fact.id}: response could not be bounded`,
2157
- err
2640
+ `[WikiMemory] ontology backfill skipped ${entityId}/${fact.id}: response could not be bounded: ${formatSkipError(err)}`
2158
2641
  );
2159
2642
  }
2160
2643
  });
@@ -2613,7 +3096,12 @@ var RetrievalService = class {
2613
3096
  }
2614
3097
  }
2615
3098
  } catch (rankerErr) {
2616
- const rankerError = rankerErr instanceof Error ? rankerErr : new Error(String(rankerErr));
3099
+ let isErrorLike = false;
3100
+ try {
3101
+ isErrorLike = rankerErr instanceof Error;
3102
+ } catch {
3103
+ }
3104
+ const rankerError = isErrorLike ? rankerErr : new Error(safeErrorToString(rankerErr));
2617
3105
  const policy = this.options.vectorRankerFallback ?? "js-cosine";
2618
3106
  this.options.onVectorRankerFallback?.({
2619
3107
  error: this._sanitizeRankerError(rankerError),
@@ -2725,12 +3213,21 @@ var RetrievalService = class {
2725
3213
  }
2726
3214
  }
2727
3215
  } catch (err) {
2728
- const error = err instanceof Error ? err : new Error(String(err));
3216
+ let isErrorLike = false;
3217
+ try {
3218
+ isErrorLike = err instanceof Error;
3219
+ } catch {
3220
+ }
3221
+ const error = isErrorLike ? err : new Error(safeErrorToString(err));
2729
3222
  if (rankerShouldRethrow) {
2730
3223
  throw error;
2731
3224
  }
2732
3225
  if (pendingRankerFallbackError) {
2733
- error.cause = pendingRankerFallbackError;
3226
+ try {
3227
+ error.cause = pendingRankerFallbackError;
3228
+ } catch {
3229
+ this.options.onRetrievalFallback?.(pendingRankerFallbackError);
3230
+ }
2734
3231
  pendingRankerFallbackError = void 0;
2735
3232
  }
2736
3233
  this.options.onRetrievalFallback?.(error);