@equationalapplications/core-llm-wiki 5.3.1 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var coreOkf = require('@equationalapplications/core-okf');
3
4
  var MiniSearch = require('minisearch');
4
5
 
5
6
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
@@ -109,26 +110,88 @@ var PrunePartialFailureError = class extends Error {
109
110
  }
110
111
  };
111
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
+ };
112
132
 
113
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
+ }
114
141
  function parseJsonResponse(text) {
115
142
  const firstBrace = text.indexOf("{");
116
143
  const firstBracket = text.indexOf("[");
117
144
  let start;
118
145
  let openChar;
119
- let closeChar;
120
146
  const useBrace = firstBrace !== -1 && (firstBracket === -1 || firstBrace < firstBracket);
121
147
  if (useBrace) {
122
148
  start = firstBrace;
123
149
  openChar = "{";
124
- closeChar = "}";
125
150
  } else if (firstBracket !== -1) {
126
151
  start = firstBracket;
127
152
  openChar = "[";
128
- closeChar = "]";
129
153
  } else {
130
- 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
+ }
131
162
  }
163
+ const repairOdd = containerAwareRepair(text, start, false);
164
+ if (repairOdd.success !== null && !repairOdd.ambiguous) {
165
+ return repairOdd.success;
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 === "{" ? "}" : "]";
132
195
  let depth = 0;
133
196
  let inString = false;
134
197
  let escape = false;
@@ -160,8 +223,193 @@ function parseJsonResponse(text) {
160
223
  }
161
224
  }
162
225
  }
163
- if (end === -1) throw new SyntaxError("No JSON object/array found in LLM response");
164
- 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
+ if (e instanceof Error) {
391
+ const msg = readErrorField(e, "message");
392
+ if (typeof msg === "string" && msg.length > 0) return msg;
393
+ const name = readErrorField(e, "name");
394
+ if (typeof name === "string" && name.length > 0) return name;
395
+ return "[Error]";
396
+ }
397
+ try {
398
+ return String(e);
399
+ } catch {
400
+ try {
401
+ return Object.prototype.toString.call(e);
402
+ } catch {
403
+ return "[unstringifiable error]";
404
+ }
405
+ }
406
+ }
407
+ function readErrorField(e, key) {
408
+ try {
409
+ return e[key];
410
+ } catch {
411
+ return void 0;
412
+ }
165
413
  }
166
414
  function sanitizeRankerError(err, sanitizeRankerErrors) {
167
415
  if (sanitizeRankerErrors === false) {
@@ -459,8 +707,6 @@ function normalizeTitleKey(title) {
459
707
  function entitySummaryMetaKey(entityId) {
460
708
  return `entity_summary:${entityId}`;
461
709
  }
462
-
463
- // src/services/ImportExportService.ts
464
710
  var MAX_EMBEDDING_BLOB_BYTES = 32 * 1024;
465
711
  var IMPORT_TITLE_MAX = 500;
466
712
  var IMPORT_BODY_MAX = 8e3;
@@ -680,7 +926,23 @@ var ImportExportService = class {
680
926
  access_count: fact.access_count,
681
927
  deleted_at: fact.deleted_at,
682
928
  embedding_blob: blobData ?? void 0,
683
- okf_type: fact.okf_type ?? null
929
+ okf_type: fact.okf_type ?? null,
930
+ // OKF v0.2 — forwarded as-is; parseOkfBundle / the caller is responsible for
931
+ // populating these per spec, importDump does not derive or validate them.
932
+ lifecycle_status: fact.lifecycle_status ?? "stable",
933
+ stale_after: fact.stale_after ?? null,
934
+ generated_by: fact.generated_by ?? null,
935
+ okf_sources: fact.okf_sources ?? [],
936
+ okf_verified: fact.okf_verified ?? [],
937
+ okf_usage_window: fact.okf_usage_window ?? null,
938
+ // last_verified_at/_by are DERIVED mirrors of `okf_verified` per spec
939
+ // §5.3. Recomputing here (instead of trusting the payload field) closes
940
+ // the invariant that a raw importDump cannot carry a
941
+ // last_verified_by that contradicts its own okf_verified list.
942
+ ...(() => {
943
+ const latest = coreOkf.latestVerified(fact.okf_verified ?? [], Date.now());
944
+ return { last_verified_at: latest?.at ?? null, last_verified_by: latest?.by ?? null };
945
+ })()
684
946
  };
685
947
  await this.entryRepo.upsertForImport(factObj, tx);
686
948
  if (blobData != null) {
@@ -730,7 +992,19 @@ var ImportExportService = class {
730
992
  updated_at: safeUpdatedAt,
731
993
  resolved_at: task.resolved_at,
732
994
  deleted_at: task.deleted_at,
733
- okf_type: task.okf_type ?? null
995
+ okf_type: task.okf_type ?? null,
996
+ // OKF v0.2
997
+ lifecycle_status: task.lifecycle_status ?? "stable",
998
+ stale_after: task.stale_after ?? null,
999
+ generated_by: task.generated_by ?? null,
1000
+ okf_sources: task.okf_sources ?? [],
1001
+ okf_verified: task.okf_verified ?? [],
1002
+ okf_usage_window: task.okf_usage_window ?? null,
1003
+ // Derived mirrors — see fact branch above; same invariant.
1004
+ ...(() => {
1005
+ const latest = coreOkf.latestVerified(task.okf_verified ?? [], Date.now());
1006
+ return { last_verified_at: latest?.at ?? null, last_verified_by: latest?.by ?? null };
1007
+ })()
734
1008
  },
735
1009
  tx,
736
1010
  safeUpdatedAt
@@ -942,7 +1216,7 @@ Return ONLY a valid JSON object matching this schema:
942
1216
  {
943
1217
  "facts": [{ "title": "string (max 80 chars)", "body": "string (max 800 chars)", "tags": ["string"], "confidence": "certain|inferred|tentative" }]
944
1218
  }
945
- Extract verbatim factual content. Do not return markdown, just raw JSON.`;
1219
+ 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.`;
946
1220
  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.
947
1221
  Return ONLY a valid JSON object matching this schema:
948
1222
  {
@@ -951,7 +1225,7 @@ Return ONLY a valid JSON object matching this schema:
951
1225
  ]
952
1226
  }
953
1227
  If no manifest type fits a fact, omit that fact from "classifications" entirely \u2014 do not guess.
954
- Do not return markdown, just raw JSON.`;
1228
+ 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.`;
955
1229
 
956
1230
  // src/services/PromptService.ts
957
1231
  var PromptService = class {
@@ -1064,6 +1338,16 @@ var DEFAULT_MAX_CHUNK_LENGTH = 12e3;
1064
1338
  var DEFAULT_CHUNK_OVERLAP = 400;
1065
1339
 
1066
1340
  // src/services/IngestionService.ts
1341
+ function zeroChunkResult(duplicateOf) {
1342
+ const result = {
1343
+ truncated: false,
1344
+ chunks: 0,
1345
+ ingestedChunks: 0,
1346
+ failedChunks: 0
1347
+ };
1348
+ if (duplicateOf !== void 0) result.duplicateOf = duplicateOf;
1349
+ return result;
1350
+ }
1067
1351
  var IngestionService = class {
1068
1352
  constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, metadataRepo, edgeRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1069
1353
  this.db = db;
@@ -1104,113 +1388,110 @@ var IngestionService = class {
1104
1388
  if (onDuplicateHash === "throw") {
1105
1389
  throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1106
1390
  }
1107
- return { truncated: false, chunks: 0, duplicateOf: canonical };
1391
+ return zeroChunkResult(canonical);
1108
1392
  }
1109
1393
  }
1110
1394
  const { chunks, truncated } = chunkText(params.documentChunk, maxChunkLength, chunkOverlap);
1111
- if (chunks.length === 0) return { truncated: false, chunks: 0 };
1395
+ if (chunks.length === 0) return zeroChunkResult();
1396
+ const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
1112
1397
  const chunkResults = await withConcurrency(
1113
- chunks.map((chunk) => async () => {
1114
- const ontologyContext = await this.ontologyService?.buildPromptContext(entityId) ?? null;
1398
+ chunks.map((chunk, chunkIndex) => async () => {
1115
1399
  const { systemPrompt, userPrompt } = this.promptService.buildIngestPrompt(
1116
1400
  chunk,
1117
1401
  params.promptOverride,
1118
1402
  ontologyContext
1119
1403
  );
1120
- const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
1121
- const result = parseJsonResponse(responseText);
1122
- return {
1123
- facts: (Array.isArray(result.facts) ? result.facts : []).map(validateFact).filter((f) => f !== null),
1124
- ontology_updates: result.ontology_updates
1125
- };
1404
+ try {
1405
+ const responseText = await this.options.llmProvider.generateText({ systemPrompt, userPrompt });
1406
+ const result2 = parseJsonResponse(responseText);
1407
+ return {
1408
+ status: "ok",
1409
+ facts: (Array.isArray(result2.facts) ? result2.facts : []).map(validateFact).filter((f) => f !== null),
1410
+ ontology_updates: result2.ontology_updates
1411
+ };
1412
+ } catch (e) {
1413
+ const failure = e instanceof WikiParseError ? {
1414
+ chunkIndex,
1415
+ sourceRef,
1416
+ source: "parse",
1417
+ tier: e.tier,
1418
+ position: e.position,
1419
+ message: e.message
1420
+ } : {
1421
+ chunkIndex,
1422
+ sourceRef,
1423
+ source: "llm",
1424
+ // `safeErrorToString` is a non-throwing coercion: an LLM
1425
+ // provider may reject with a non-Error value, and even
1426
+ // `String(e)` itself can throw when `e.toString()` throws.
1427
+ // Letting such an exception escape this catch would cause
1428
+ // `withConcurrency` to reject, discarding every sibling
1429
+ // chunk's results — the exact failure mode this per-chunk
1430
+ // try/catch exists to prevent. Keeping the value in the
1431
+ // `ChunkFailure` preserves the typed diagnostic for
1432
+ // callers (see parseFailures) without unwinding the loop.
1433
+ position: null,
1434
+ message: safeErrorToString(e)
1435
+ };
1436
+ const total = chunks.length;
1437
+ const tags = [];
1438
+ if (failure.tier) tags.push(`tier=${failure.tier}`);
1439
+ if (failure.position !== null) tags.push(`position=${failure.position}`);
1440
+ const tagsSuffix = tags.length > 0 ? `; ${tags.join(" ")}` : "";
1441
+ console.warn(
1442
+ `[WikiMemory] ingest chunk ${chunkIndex + 1}/${total} ${failure.source} failed (sourceRef=${sourceRef}${tagsSuffix})`
1443
+ );
1444
+ return { status: "failed", error: failure };
1445
+ }
1126
1446
  }),
1127
1447
  chunkConcurrency
1128
1448
  );
1449
+ let ingestedChunks = 0;
1450
+ let failedChunks = 0;
1451
+ const failures = [];
1129
1452
  const seen = /* @__PURE__ */ new Set();
1130
1453
  const orderedChunkFacts = [];
1131
- for (const chunkResult of chunkResults) {
1454
+ for (const slot of chunkResults) {
1455
+ if (slot.status === "failed") {
1456
+ failedChunks++;
1457
+ failures.push(slot.error);
1458
+ continue;
1459
+ }
1460
+ ingestedChunks++;
1132
1461
  const dedupedFacts = [];
1133
- for (const fact of chunkResult.facts) {
1462
+ for (const fact of slot.facts) {
1134
1463
  const normalizedTitle = normalizeTitleKey(fact.title);
1135
1464
  if (!seen.has(normalizedTitle)) {
1136
1465
  seen.add(normalizedTitle);
1137
1466
  dedupedFacts.push(fact);
1138
1467
  }
1139
1468
  }
1140
- orderedChunkFacts.push({ facts: dedupedFacts, ontology_updates: chunkResult.ontology_updates });
1469
+ orderedChunkFacts.push({ facts: dedupedFacts, ontology_updates: slot.ontology_updates });
1470
+ }
1471
+ if (failedChunks === chunks.length) {
1472
+ throw new WikiIngestEmptyError({
1473
+ parseFailures: failures,
1474
+ sourceRef,
1475
+ chunks: chunks.length
1476
+ });
1141
1477
  }
1142
- const now = Date.now();
1143
1478
  const insertedFacts = [];
1144
1479
  const deletedSourceFactIds = [];
1145
1480
  try {
1146
- await this.db.withTransactionAsync(async (tx) => {
1147
- deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1148
- const titleIndex = /* @__PURE__ */ new Map();
1149
- const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx, sourceRef);
1150
- for (const existing of existingFacts) {
1151
- titleIndex.set(normalizeTitleKey(existing.title), {
1152
- id: existing.id,
1153
- okf_type: existing.okf_type ?? null
1154
- });
1155
- }
1156
- let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1157
- let { mode, manifest } = ontologyState;
1158
- const hostNodes = [];
1159
- const rawEdgeRequests = [];
1160
- for (const { facts, ontology_updates } of orderedChunkFacts) {
1161
- if (mode === "emergent" && ontology_updates && this.ontologyService) {
1162
- manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1163
- ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1164
- mode = ontologyState.mode;
1165
- }
1166
- for (const fact of facts) {
1167
- const ontologyFact = fact;
1168
- const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest, { strict: false }) ?? { okf_type: null, edges: [] };
1169
- const id = generateId("fact_");
1170
- hostNodes.push({
1171
- id,
1172
- type: ontologyFact.okf_type ?? "",
1173
- title: fact.title,
1174
- body: fact.body,
1175
- // Forward the LLM-extracted tags/confidence through the
1176
- // extracted-shape upsertGraphCore path so the entries row
1177
- // stores them (search filterability, heal-candidate
1178
- // selection, runReembed embedding-text signal). The public
1179
- // WikiMemory.upsertGraph API doesn't accept tags/confidence
1180
- // — host-supplied deterministic nodes default to [] and
1181
- // 'certain' as documented.
1182
- tags: fact.tags,
1183
- confidence: fact.confidence
1184
- });
1185
- insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1186
- titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1187
- if (normalized.edges.length > 0) {
1188
- rawEdgeRequests.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1189
- }
1190
- }
1191
- }
1192
- const hostEdges = [];
1193
- for (const req of rawEdgeRequests) {
1194
- const resolved = this.ontologyService?.resolveEdges(
1195
- entityId,
1196
- req.sourceId,
1197
- req.sourceType,
1198
- req.edges,
1199
- manifest,
1200
- titleIndex,
1201
- now
1202
- ) ?? [];
1203
- for (const e of resolved) {
1204
- hostEdges.push({ type: e.edge_type, sourceId: e.source_id, targetId: e.target_id });
1205
- }
1206
- }
1207
- await this.upsertGraphCore(
1208
- entityId,
1209
- { sourceRef, sourceHash, nodes: hostNodes, edges: hostEdges },
1210
- tx,
1211
- { strict: false }
1212
- );
1213
- });
1481
+ if (failedChunks === 0) {
1482
+ const fullResult = await this.db.withTransactionAsync(async (tx) => {
1483
+ return await this.runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx);
1484
+ });
1485
+ deletedSourceFactIds.push(...fullResult.deletedSourceFactIds);
1486
+ insertedFacts.push(...fullResult.insertedFacts);
1487
+ } else {
1488
+ const partialResult = await this.db.withTransactionAsync(async (tx) => {
1489
+ const flat = [];
1490
+ for (const slot of orderedChunkFacts) flat.push(...slot.facts);
1491
+ return await this.appendPartialFacts(entityId, sourceRef, flat, tx);
1492
+ });
1493
+ insertedFacts.push(...partialResult.insertedDescriptors);
1494
+ }
1214
1495
  } catch (err) {
1215
1496
  const sqliteCode = err instanceof WikiTransactionError ? err.sqliteErrorCode : extractSqliteCode(err);
1216
1497
  if (sqliteCode !== "SQLITE_CONSTRAINT_UNIQUE") throw err;
@@ -1219,22 +1500,31 @@ var IngestionService = class {
1219
1500
  if (onDuplicateHash === "throw" || onDuplicateHash === "ingest") {
1220
1501
  throw new WikiDuplicateHashError({ canonical, sourceHash, entityId });
1221
1502
  }
1222
- return { truncated: false, chunks: 0, duplicateOf: canonical };
1503
+ return zeroChunkResult(canonical);
1223
1504
  }
1224
1505
  await this.searchService.sync(entityId);
1225
- const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
1226
- for (const factId of uniqueDeletedSourceFactIds) {
1227
- try {
1228
- await this.embeddingService.notifyEmbeddingPersisted(entityId, factId, null);
1229
- } catch (hookErr) {
1230
- console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during ingest for ${factId}:`, hookErr);
1506
+ if (failedChunks === 0) {
1507
+ const uniqueDeletedSourceFactIds = Array.from(new Set(deletedSourceFactIds));
1508
+ for (const factId of uniqueDeletedSourceFactIds) {
1509
+ try {
1510
+ await this.embeddingService.notifyEmbeddingPersisted(entityId, factId, null);
1511
+ } catch (hookErr) {
1512
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed during ingest for ${factId}:`, hookErr);
1513
+ }
1231
1514
  }
1232
1515
  }
1233
1516
  for (const fact of insertedFacts) {
1234
1517
  await this.embeddingService.embedFact(fact);
1235
1518
  }
1236
1519
  this.searchService.evictCache(entityId);
1237
- return { truncated, chunks: chunks.length };
1520
+ const result = {
1521
+ truncated,
1522
+ chunks: chunks.length,
1523
+ ingestedChunks,
1524
+ failedChunks
1525
+ };
1526
+ if (failedChunks > 0) result.parseFailures = failures;
1527
+ return result;
1238
1528
  } finally {
1239
1529
  releaseIngestLocks();
1240
1530
  }
@@ -1359,6 +1649,156 @@ var IngestionService = class {
1359
1649
  superseded: deletedFactIds.length + deletedEdgeCount
1360
1650
  };
1361
1651
  }
1652
+ /**
1653
+ * Full supersession + ownership path: identical to the pre-issue-#92 behavior
1654
+ * for the happy path. Called from the `failedChunks === 0` branch of
1655
+ * `ingestDocument`. Runs INSIDE the caller's tx.
1656
+ *
1657
+ * Returns `{ deletedSourceFactIds, insertedFacts }` so the caller can fire
1658
+ * post-commit hooks (embedding lifecycle) AFTER the transaction commits.
1659
+ * On the partial path the caller never invokes this method; the empty
1660
+ * arrays stay empty.
1661
+ */
1662
+ async runFullUpsertGraph(entityId, sourceRef, sourceHash, orderedChunkFacts, tx) {
1663
+ const deletedSourceFactIds = [];
1664
+ const insertedFacts = [];
1665
+ deletedSourceFactIds.push(...await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false));
1666
+ const titleIndex = /* @__PURE__ */ new Map();
1667
+ const existingFacts = await this.entryRepo.findRecentByEntityId(entityId, 500, tx, sourceRef);
1668
+ for (const existing of existingFacts) {
1669
+ titleIndex.set(normalizeTitleKey(existing.title), {
1670
+ id: existing.id,
1671
+ okf_type: existing.okf_type ?? null
1672
+ });
1673
+ }
1674
+ let ontologyState = await this.ontologyService?.getEffectiveState(entityId, tx) ?? { mode: "off", manifest: { node_types: [], edge_types: [] } };
1675
+ let { mode, manifest } = ontologyState;
1676
+ const hostNodes = [];
1677
+ const rawEdgeRequests = [];
1678
+ const now = Date.now();
1679
+ for (const { facts, ontology_updates } of orderedChunkFacts) {
1680
+ if (mode === "emergent" && ontology_updates && this.ontologyService) {
1681
+ manifest = await this.ontologyService.mergeEmergentUpdates(entityId, ontology_updates, tx);
1682
+ ontologyState = await this.ontologyService.getEffectiveState(entityId, tx);
1683
+ mode = ontologyState.mode;
1684
+ }
1685
+ for (const fact of facts) {
1686
+ const ontologyFact = fact;
1687
+ const normalized = this.ontologyService?.validateAndNormalizeFact(ontologyFact, manifest, { strict: false }) ?? { okf_type: null, edges: [] };
1688
+ const id = generateId("fact_");
1689
+ hostNodes.push({
1690
+ id,
1691
+ type: ontologyFact.okf_type ?? "",
1692
+ title: fact.title,
1693
+ body: fact.body,
1694
+ // Forward the LLM-extracted tags/confidence through the
1695
+ // extracted-shape upsertGraphCore path so the entries row
1696
+ // stores them (search filterability, heal-candidate
1697
+ // selection, runReembed embedding-text signal). The public
1698
+ // WikiMemory.upsertGraph API doesn't accept tags/confidence
1699
+ // — host-supplied deterministic nodes default to [] and
1700
+ // 'certain' as documented.
1701
+ tags: fact.tags,
1702
+ confidence: fact.confidence
1703
+ });
1704
+ insertedFacts.push({ id, entity_id: entityId, title: fact.title, body: fact.body, tags: JSON.stringify(fact.tags) });
1705
+ titleIndex.set(normalizeTitleKey(fact.title), { id, okf_type: normalized.okf_type });
1706
+ if (normalized.edges.length > 0) {
1707
+ rawEdgeRequests.push({ sourceId: id, sourceType: normalized.okf_type, edges: normalized.edges });
1708
+ }
1709
+ }
1710
+ }
1711
+ const hostEdges = [];
1712
+ for (const req of rawEdgeRequests) {
1713
+ const resolved = this.ontologyService?.resolveEdges(
1714
+ entityId,
1715
+ req.sourceId,
1716
+ req.sourceType,
1717
+ req.edges,
1718
+ manifest,
1719
+ titleIndex,
1720
+ now
1721
+ ) ?? [];
1722
+ for (const e of resolved) {
1723
+ hostEdges.push({ type: e.edge_type, sourceId: e.source_id, targetId: e.target_id });
1724
+ }
1725
+ }
1726
+ await this.upsertGraphCore(
1727
+ entityId,
1728
+ { sourceRef, sourceHash, nodes: hostNodes, edges: hostEdges },
1729
+ tx,
1730
+ { strict: false }
1731
+ );
1732
+ return { deletedSourceFactIds, insertedFacts };
1733
+ }
1734
+ /**
1735
+ * Partial-commit path for `ingestDocument`. Inserts ONLY facts whose
1736
+ * normalized title is not already in the live set for this (entityId,
1737
+ * sourceRef). Does NOT call `entryRepo.softDeleteBySource` (no
1738
+ * supersession) and does NOT call `sourceRefIndexRepo.upsert` (no
1739
+ * ownership). Edges are NOT resolved on this path (no ontology-context
1740
+ * build, no `resolveEdges`, no `mergeEmergentUpdates`). Stale edges from
1741
+ * prior attempts are recovered on the next full run's supersession.
1742
+ *
1743
+ * Source-hash is stored as NULL on partial rows. `findLatestSourceHash`
1744
+ * reads from the most recently updated live row for the sourceRef, so
1745
+ * storing the incoming hash here would cause `hasChanged` to return
1746
+ * `false` on a same-hash retry — the failed chunks would never get a
1747
+ * second chance. With NULL, a retry sees `storedHash === null`, `hasChanged`
1748
+ * returns `true`, and the partial commit's sibling rows remain live
1749
+ * (deduped, not superseded) for the next attempt to extend.
1750
+ *
1751
+ * Runs INSIDE the caller's `tx`. Does not open a nested transaction.
1752
+ * Returns `{ inserted, skippedDuplicate }` for observability.
1753
+ */
1754
+ async appendPartialFacts(entityId, sourceRef, dedupedFacts, tx) {
1755
+ const liveIds = await this.entryRepo.findIdsBySource(entityId, sourceRef, null, tx, false);
1756
+ const liveFacts = liveIds.length === 0 ? [] : await this.entryRepo.findByIds(liveIds, void 0, tx);
1757
+ const liveTitles = new Set(liveFacts.map((f) => normalizeTitleKey(f.title)));
1758
+ let inserted = 0;
1759
+ let skippedDuplicate = 0;
1760
+ const now = Date.now();
1761
+ const insertedDescriptors = [];
1762
+ for (const fact of dedupedFacts) {
1763
+ const normalizedTitle = normalizeTitleKey(fact.title);
1764
+ if (liveTitles.has(normalizedTitle)) {
1765
+ skippedDuplicate++;
1766
+ continue;
1767
+ }
1768
+ liveTitles.add(normalizedTitle);
1769
+ const id = generateId("fact_");
1770
+ const wikiFact = {
1771
+ id,
1772
+ entity_id: entityId,
1773
+ title: fact.title,
1774
+ body: fact.body,
1775
+ tags: fact.tags,
1776
+ confidence: fact.confidence,
1777
+ source_type: "immutable_document",
1778
+ // source_hash: null on partial rows — see method docstring. The
1779
+ // full path stamps the actual hash; partial rows stay hash-less so
1780
+ // `hasChanged` keeps returning true for retries.
1781
+ source_hash: null,
1782
+ source_ref: sourceRef,
1783
+ created_at: now,
1784
+ updated_at: now,
1785
+ last_accessed_at: null,
1786
+ access_count: 0,
1787
+ deleted_at: null,
1788
+ okf_type: null
1789
+ };
1790
+ await this.entryRepo.upsert(wikiFact, tx);
1791
+ insertedDescriptors.push({
1792
+ id,
1793
+ entity_id: entityId,
1794
+ title: fact.title,
1795
+ body: fact.body,
1796
+ tags: JSON.stringify(fact.tags)
1797
+ });
1798
+ inserted++;
1799
+ }
1800
+ return { inserted, skippedDuplicate, insertedDescriptors };
1801
+ }
1362
1802
  };
1363
1803
 
1364
1804
  // src/utils/embedding.ts
@@ -1507,6 +1947,25 @@ var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
1507
1947
  var HEAL_MAX_PROMPT_CHARS = 4e4;
1508
1948
  var HEAL_BATCH_SIZE = 25;
1509
1949
  var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
1950
+ var SKIP_ERROR_LOG_CHARS = 4096;
1951
+ var formatSkipError = (err) => {
1952
+ let base;
1953
+ if (err instanceof Error || typeof err !== "object" && typeof err !== "function") {
1954
+ base = safeErrorToString(err);
1955
+ } else {
1956
+ try {
1957
+ const json = JSON.stringify(err);
1958
+ base = typeof json === "string" ? json : safeErrorToString(err);
1959
+ } catch {
1960
+ base = safeErrorToString(err);
1961
+ }
1962
+ }
1963
+ if (base.length > SKIP_ERROR_LOG_CHARS) {
1964
+ const truncated = safeSlice(base, 0, SKIP_ERROR_LOG_CHARS);
1965
+ return `${truncated}\u2026[+${base.length - SKIP_ERROR_LOG_CHARS} chars truncated]`;
1966
+ }
1967
+ return base;
1968
+ };
1510
1969
  var MaintenanceService = class {
1511
1970
  constructor(db, prefix, options, entryRepo, sourceRefIndexRepo, taskRepo, eventRepo, metadataRepo, searchService, jobManager, embeddingService, promptService, ontologyService) {
1512
1971
  this.db = db;
@@ -1987,8 +2446,7 @@ var MaintenanceService = class {
1987
2446
  maxPromptChars: HEAL_MAX_PROMPT_CHARS,
1988
2447
  onSkip: (fact, err) => {
1989
2448
  console.warn(
1990
- `[WikiMemory] heal skipped ${entityId}/${fact.id}: response could not be bounded`,
1991
- err
2449
+ `[WikiMemory] heal skipped ${entityId}/${fact.id}: response could not be bounded: ${formatSkipError(err)}`
1992
2450
  );
1993
2451
  }
1994
2452
  });
@@ -2126,8 +2584,7 @@ var MaintenanceService = class {
2126
2584
  maxPromptChars: ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS,
2127
2585
  onSkip: (fact, err) => {
2128
2586
  console.warn(
2129
- `[WikiMemory] ontology backfill skipped ${entityId}/${fact.id}: response could not be bounded`,
2130
- err
2587
+ `[WikiMemory] ontology backfill skipped ${entityId}/${fact.id}: response could not be bounded: ${formatSkipError(err)}`
2131
2588
  );
2132
2589
  }
2133
2590
  });