@remnic/core 9.63.0 → 9.63.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.
Files changed (184) hide show
  1. package/dist/access-admin-ops-surface.d.ts +1 -1
  2. package/dist/access-admin-ops-surface.js +13 -13
  3. package/dist/access-authorization-probe.d.ts +1 -1
  4. package/dist/access-authorization-probe.js +14 -14
  5. package/dist/access-boundary.d.ts +1 -1
  6. package/dist/access-boundary.js +13 -13
  7. package/dist/access-cli.js +43 -43
  8. package/dist/access-extraction-force-flush.d.ts +1 -1
  9. package/dist/access-extraction-force-flush.js +13 -13
  10. package/dist/access-http-lcm-compaction.d.ts +1 -1
  11. package/dist/access-http-lifecycle-flush.d.ts +1 -1
  12. package/dist/access-http-offline-stream.d.ts +1 -1
  13. package/dist/access-http-query.js +13 -13
  14. package/dist/access-http.d.ts +2 -2
  15. package/dist/access-http.js +22 -22
  16. package/dist/access-identity-continuity-surface.js +13 -13
  17. package/dist/access-lcm-surface.d.ts +1 -1
  18. package/dist/access-lcm-surface.js +13 -13
  19. package/dist/access-mcp.d.ts +1 -1
  20. package/dist/access-mcp.js +18 -18
  21. package/dist/access-namespace-preflight.js +13 -13
  22. package/dist/access-observe-write-surface.d.ts +1 -1
  23. package/dist/access-observe-write-surface.js +13 -13
  24. package/dist/access-operations-batch.js +14 -14
  25. package/dist/access-operations.d.ts +4 -4
  26. package/dist/access-operations.js +17 -17
  27. package/dist/access-recall-concurrency.d.ts +1 -1
  28. package/dist/access-recall-concurrency.js +13 -13
  29. package/dist/access-recall-response.d.ts +1 -1
  30. package/dist/access-recall-response.js +13 -13
  31. package/dist/access-recall-surface.d.ts +1 -1
  32. package/dist/access-recall-surface.js +13 -13
  33. package/dist/access-schema.d.ts +88 -88
  34. package/dist/access-service-helpers.d.ts +1 -1
  35. package/dist/access-service.d.ts +1 -1
  36. package/dist/access-service.js +13 -13
  37. package/dist/access-surface-catalog.d.ts +1 -1
  38. package/dist/access-surface-catalog.js +15 -15
  39. package/dist/briefing.js +5 -5
  40. package/dist/buffer.js +3 -3
  41. package/dist/causal-consolidation.js +6 -6
  42. package/dist/{chunk-CH627RRV.js → chunk-2AZBPQLK.js} +3 -3
  43. package/dist/{chunk-WFLBP5GC.js → chunk-4NZHHGVL.js} +25 -6
  44. package/dist/chunk-4NZHHGVL.js.map +1 -0
  45. package/dist/{chunk-LTJTP5YQ.js → chunk-4WW5FMUD.js} +2 -2
  46. package/dist/{chunk-HSBQJRV2.js → chunk-5LVSFKQB.js} +2 -2
  47. package/dist/{chunk-RFH7U5LT.js → chunk-5S7Y7AC6.js} +2 -2
  48. package/dist/{chunk-7BJYTX6Q.js → chunk-6VX2ZZH2.js} +2 -2
  49. package/dist/{chunk-4FFROSFG.js → chunk-7CJO52ET.js} +8 -8
  50. package/dist/{chunk-JFLKSD5V.js → chunk-A5BYPCDI.js} +2 -2
  51. package/dist/{chunk-EFWHCTYJ.js → chunk-ALKRPLWB.js} +2 -2
  52. package/dist/{chunk-JDI7G6EA.js → chunk-AUIAHNO7.js} +2 -2
  53. package/dist/{chunk-BHO6RHE5.js → chunk-BE5SVRFC.js} +2 -2
  54. package/dist/{chunk-QCECHYZL.js → chunk-E2GYBTSP.js} +24 -4
  55. package/dist/chunk-E2GYBTSP.js.map +1 -0
  56. package/dist/{chunk-3MA7JVXN.js → chunk-EJYAE2U7.js} +4 -4
  57. package/dist/{chunk-IZRNVNPV.js → chunk-F2FZEVDE.js} +2 -2
  58. package/dist/{chunk-AYIHDC5S.js → chunk-FNYIMSKI.js} +2 -2
  59. package/dist/{chunk-ZPGHA7EO.js → chunk-FU7OICXE.js} +7 -7
  60. package/dist/{chunk-4Y2RHVCV.js → chunk-GL4RHVHX.js} +2 -2
  61. package/dist/{chunk-HVKMJAEF.js → chunk-GUBT77YC.js} +2 -2
  62. package/dist/{chunk-4IZ2IT7D.js → chunk-J5AFG5JN.js} +3 -3
  63. package/dist/{chunk-QCI73Z7C.js → chunk-KP36WQBJ.js} +2 -2
  64. package/dist/{chunk-H23GIXJC.js → chunk-LAX6S5WS.js} +2 -2
  65. package/dist/{chunk-DTLF2SLZ.js → chunk-MMM7SDOJ.js} +2 -2
  66. package/dist/{chunk-KPML4Q3M.js → chunk-MQFFR6QB.js} +2 -2
  67. package/dist/{chunk-BBHFAVMP.js → chunk-NVSTZ2PN.js} +7 -7
  68. package/dist/{chunk-CALURYWR.js → chunk-OF6KQJ35.js} +2 -2
  69. package/dist/{chunk-JCJGYNIS.js → chunk-ORB56L3Y.js} +3 -3
  70. package/dist/{chunk-YIIC5IDU.js → chunk-P2BT5ALE.js} +1 -1
  71. package/dist/{chunk-BP6WMYLP.js → chunk-R7R4FSV2.js} +2 -2
  72. package/dist/{chunk-CLJ5QS6J.js → chunk-RBBYKXXK.js} +30 -24
  73. package/dist/{chunk-CLJ5QS6J.js.map → chunk-RBBYKXXK.js.map} +1 -1
  74. package/dist/{chunk-FF6E6HOT.js → chunk-RBJKE5LV.js} +16 -16
  75. package/dist/{chunk-PSNN25DW.js → chunk-S4MF77NV.js} +2 -2
  76. package/dist/{chunk-4HO3542O.js → chunk-S52C2DEM.js} +2 -2
  77. package/dist/{chunk-WMOJIOIG.js → chunk-SHSJP4D6.js} +2 -2
  78. package/dist/{chunk-CK3NQUSQ.js → chunk-U4CCRHN6.js} +2 -2
  79. package/dist/{chunk-TGSMU3F2.js → chunk-U5LYM2NS.js} +2 -2
  80. package/dist/{chunk-X6E7TT3A.js → chunk-UDL4DNBM.js} +2 -2
  81. package/dist/{chunk-WR2YKUIW.js → chunk-UKBIB3FP.js} +2 -2
  82. package/dist/{chunk-347ZR6D4.js → chunk-UM7A4CPT.js} +2 -2
  83. package/dist/{chunk-TIRJV2LW.js → chunk-USKANB65.js} +2 -2
  84. package/dist/{chunk-UDTME32W.js → chunk-UW74LP76.js} +2 -2
  85. package/dist/{chunk-YSR3DXGC.js → chunk-WLITCQAC.js} +2 -2
  86. package/dist/{chunk-LLGDVG52.js → chunk-ZSH6GJ2V.js} +3 -3
  87. package/dist/{chunk-DROAPP6N.js → chunk-ZXMNT75M.js} +3 -3
  88. package/dist/{cli-Bw9z-KqX.d.ts → cli-DJwNNDL1.d.ts} +1 -1
  89. package/dist/cli.d.ts +2 -2
  90. package/dist/cli.js +32 -32
  91. package/dist/compounding/engine.js +5 -5
  92. package/dist/connectors/codex-materialize-runner.js +5 -5
  93. package/dist/connectors/index.js +5 -5
  94. package/dist/convergence-refresh.js +7 -7
  95. package/dist/corpus-watermark.js +7 -7
  96. package/dist/entity-retrieval.js +5 -5
  97. package/dist/external-wiki-access.d.ts +1 -1
  98. package/dist/external-wiki-access.js +14 -14
  99. package/dist/external-wiki-mcp-tools.d.ts +1 -1
  100. package/dist/extraction.js +9 -9
  101. package/dist/harmonic-retrieval.js +1 -1
  102. package/dist/index.d.ts +681 -681
  103. package/dist/index.js +43 -43
  104. package/dist/maintenance/memory-governance.js +5 -5
  105. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +5 -5
  106. package/dist/maintenance/rebuild-memory-projection.js +6 -6
  107. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  108. package/dist/namespaces/migrate.js +6 -6
  109. package/dist/namespaces/search.js +3 -3
  110. package/dist/namespaces/storage.js +5 -5
  111. package/dist/operator-doctor-corpus.js +8 -8
  112. package/dist/operator-toolkit.js +12 -12
  113. package/dist/orchestration/maintenance.js +10 -10
  114. package/dist/orchestrator.js +43 -43
  115. package/dist/{public-http-DK-QwaBu.d.ts → public-http-43z1YT-k.d.ts} +1 -1
  116. package/dist/{qmd-D_62bYNl.d.ts → qmd-CBSxevyb.d.ts} +12 -1
  117. package/dist/qmd-preflight.d.ts +1 -1
  118. package/dist/qmd.d.ts +1 -1
  119. package/dist/qmd.js +3 -1
  120. package/dist/retrieval-agents.d.ts +1 -1
  121. package/dist/schemas.d.ts +117 -434
  122. package/dist/schemas.js +1 -1
  123. package/dist/search/factory.js +2 -2
  124. package/dist/search/index.js +2 -2
  125. package/dist/semantic-consolidation.js +6 -6
  126. package/dist/semantic-rule-promotion.js +5 -5
  127. package/dist/semantic-rule-verifier.js +5 -5
  128. package/dist/shared-context/manager.d.ts +8 -8
  129. package/dist/source-agent-qualifier.js +7 -7
  130. package/dist/storage.js +4 -4
  131. package/dist/support-passport/index.d.ts +4 -4
  132. package/dist/support-passport/index.js +16 -16
  133. package/dist/transfer/types.d.ts +66 -66
  134. package/dist/verified-recall.js +5 -5
  135. package/package.json +2 -2
  136. package/src/orchestration/turn-ingestion.ts +24 -1
  137. package/src/orchestrator-flush.test.ts +142 -1
  138. package/src/qmd-process-group.test.ts +125 -0
  139. package/src/qmd.ts +30 -2
  140. package/src/schemas.test.ts +158 -0
  141. package/src/schemas.ts +45 -22
  142. package/dist/chunk-QCECHYZL.js.map +0 -1
  143. package/dist/chunk-WFLBP5GC.js.map +0 -1
  144. package/dist/{access-service-M4LgO141.d.ts → access-service-DBpEnlzA.d.ts} +64 -64
  145. /package/dist/{chunk-CH627RRV.js.map → chunk-2AZBPQLK.js.map} +0 -0
  146. /package/dist/{chunk-LTJTP5YQ.js.map → chunk-4WW5FMUD.js.map} +0 -0
  147. /package/dist/{chunk-HSBQJRV2.js.map → chunk-5LVSFKQB.js.map} +0 -0
  148. /package/dist/{chunk-RFH7U5LT.js.map → chunk-5S7Y7AC6.js.map} +0 -0
  149. /package/dist/{chunk-7BJYTX6Q.js.map → chunk-6VX2ZZH2.js.map} +0 -0
  150. /package/dist/{chunk-4FFROSFG.js.map → chunk-7CJO52ET.js.map} +0 -0
  151. /package/dist/{chunk-JFLKSD5V.js.map → chunk-A5BYPCDI.js.map} +0 -0
  152. /package/dist/{chunk-EFWHCTYJ.js.map → chunk-ALKRPLWB.js.map} +0 -0
  153. /package/dist/{chunk-JDI7G6EA.js.map → chunk-AUIAHNO7.js.map} +0 -0
  154. /package/dist/{chunk-BHO6RHE5.js.map → chunk-BE5SVRFC.js.map} +0 -0
  155. /package/dist/{chunk-3MA7JVXN.js.map → chunk-EJYAE2U7.js.map} +0 -0
  156. /package/dist/{chunk-IZRNVNPV.js.map → chunk-F2FZEVDE.js.map} +0 -0
  157. /package/dist/{chunk-AYIHDC5S.js.map → chunk-FNYIMSKI.js.map} +0 -0
  158. /package/dist/{chunk-ZPGHA7EO.js.map → chunk-FU7OICXE.js.map} +0 -0
  159. /package/dist/{chunk-4Y2RHVCV.js.map → chunk-GL4RHVHX.js.map} +0 -0
  160. /package/dist/{chunk-HVKMJAEF.js.map → chunk-GUBT77YC.js.map} +0 -0
  161. /package/dist/{chunk-4IZ2IT7D.js.map → chunk-J5AFG5JN.js.map} +0 -0
  162. /package/dist/{chunk-QCI73Z7C.js.map → chunk-KP36WQBJ.js.map} +0 -0
  163. /package/dist/{chunk-H23GIXJC.js.map → chunk-LAX6S5WS.js.map} +0 -0
  164. /package/dist/{chunk-DTLF2SLZ.js.map → chunk-MMM7SDOJ.js.map} +0 -0
  165. /package/dist/{chunk-KPML4Q3M.js.map → chunk-MQFFR6QB.js.map} +0 -0
  166. /package/dist/{chunk-BBHFAVMP.js.map → chunk-NVSTZ2PN.js.map} +0 -0
  167. /package/dist/{chunk-CALURYWR.js.map → chunk-OF6KQJ35.js.map} +0 -0
  168. /package/dist/{chunk-JCJGYNIS.js.map → chunk-ORB56L3Y.js.map} +0 -0
  169. /package/dist/{chunk-YIIC5IDU.js.map → chunk-P2BT5ALE.js.map} +0 -0
  170. /package/dist/{chunk-BP6WMYLP.js.map → chunk-R7R4FSV2.js.map} +0 -0
  171. /package/dist/{chunk-FF6E6HOT.js.map → chunk-RBJKE5LV.js.map} +0 -0
  172. /package/dist/{chunk-PSNN25DW.js.map → chunk-S4MF77NV.js.map} +0 -0
  173. /package/dist/{chunk-4HO3542O.js.map → chunk-S52C2DEM.js.map} +0 -0
  174. /package/dist/{chunk-WMOJIOIG.js.map → chunk-SHSJP4D6.js.map} +0 -0
  175. /package/dist/{chunk-CK3NQUSQ.js.map → chunk-U4CCRHN6.js.map} +0 -0
  176. /package/dist/{chunk-TGSMU3F2.js.map → chunk-U5LYM2NS.js.map} +0 -0
  177. /package/dist/{chunk-X6E7TT3A.js.map → chunk-UDL4DNBM.js.map} +0 -0
  178. /package/dist/{chunk-WR2YKUIW.js.map → chunk-UKBIB3FP.js.map} +0 -0
  179. /package/dist/{chunk-347ZR6D4.js.map → chunk-UM7A4CPT.js.map} +0 -0
  180. /package/dist/{chunk-TIRJV2LW.js.map → chunk-USKANB65.js.map} +0 -0
  181. /package/dist/{chunk-UDTME32W.js.map → chunk-UW74LP76.js.map} +0 -0
  182. /package/dist/{chunk-YSR3DXGC.js.map → chunk-WLITCQAC.js.map} +0 -0
  183. /package/dist/{chunk-LLGDVG52.js.map → chunk-ZSH6GJ2V.js.map} +0 -0
  184. /package/dist/{chunk-DROAPP6N.js.map → chunk-ZXMNT75M.js.map} +0 -0
@@ -0,0 +1,125 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { runCommandWithTimeoutForTest } from "./qmd.js";
8
+
9
+ // Regression for issue #2456: on timeout/abort the bounded qmd subprocess
10
+ // launcher must be killed together with its descendants. The launcher runs
11
+ // detached (own process group) so a negative-pid signal reaches grandchildren
12
+ // the old child.kill("SIGKILL") left behind. POSIX only — Windows falls back
13
+ // to a direct child kill by design.
14
+ //
15
+ // Real timers by design: this suite kills real OS processes, so the timeout
16
+ // trigger and the exit polls run against the platform clock. Fake timers
17
+ // cannot deliver signals to a spawned `sleep`. Every poll awaits a condition
18
+ // (pid file exists, process gone), never a fixed sleep.
19
+ const isPosix = process.platform !== "win32";
20
+
21
+ function isAlive(pid: number): boolean {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ } catch (err) {
26
+ return (err as NodeJS.ErrnoException).code === "EPERM";
27
+ }
28
+ }
29
+
30
+ async function waitForExit(pid: number, deadlineMs = 10_000): Promise<boolean> {
31
+ const deadline = Date.now() + deadlineMs;
32
+ while (Date.now() < deadline) {
33
+ if (!isAlive(pid)) return true;
34
+ await new Promise((resolve) => setTimeout(resolve, 50));
35
+ }
36
+ return !isAlive(pid);
37
+ }
38
+
39
+ async function setupFakeLauncher(): Promise<{ script: string; pidFile: string; dir: string }> {
40
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "remnic-qmd-kill-"));
41
+ const script = path.join(dir, "fake-qmd");
42
+ const pidFile = path.join(dir, "grandchild.pid");
43
+ // Launcher spawns a grandchild that outlives it, then hangs until killed.
44
+ await fs.promises.writeFile(
45
+ script,
46
+ `#!/bin/sh\nsleep 30 &\necho $! > "${pidFile}"\nsleep 30\n`,
47
+ { mode: 0o755 },
48
+ );
49
+ return { script, pidFile, dir };
50
+ }
51
+
52
+ async function readPidFile(pidFile: string, deadlineMs = 5_000): Promise<number> {
53
+ const deadline = Date.now() + deadlineMs;
54
+ for (;;) {
55
+ try {
56
+ const text = await fs.promises.readFile(pidFile, "utf8");
57
+ const pid = Number.parseInt(text.trim(), 10);
58
+ assert.ok(Number.isInteger(pid) && pid > 0, `bad grandchild pid: ${text}`);
59
+ return pid;
60
+ } catch (err) {
61
+ if (Date.now() >= deadline) throw err;
62
+ await new Promise((resolve) => setTimeout(resolve, 25));
63
+ }
64
+ }
65
+ }
66
+
67
+ async function cleanupSurvivors(pids: Array<number | undefined>, dir?: string): Promise<void> {
68
+ for (const pid of pids) {
69
+ if (pid === undefined || !isAlive(pid)) continue;
70
+ try {
71
+ process.kill(pid, "SIGKILL");
72
+ } catch {
73
+ // Already gone.
74
+ }
75
+ }
76
+ if (dir) await fs.promises.rm(dir, { recursive: true, force: true });
77
+ }
78
+
79
+ test(
80
+ "timeout kills qmd launcher process group including grandchildren",
81
+ { skip: isPosix ? false : "POSIX process groups do not exist on Windows" },
82
+ async () => {
83
+ const { script, pidFile, dir } = await setupFakeLauncher();
84
+ let grandchildPid: number | undefined;
85
+ try {
86
+ await assert.rejects(
87
+ runCommandWithTimeoutForTest(script, [], { timeoutMs: 500, label: "qmd fake" }),
88
+ (err: Error & { timedOut?: boolean }) => err.timedOut === true,
89
+ );
90
+ grandchildPid = await readPidFile(pidFile);
91
+ assert.ok(
92
+ await waitForExit(grandchildPid),
93
+ `grandchild ${grandchildPid} survived the group kill`,
94
+ );
95
+ } finally {
96
+ await cleanupSurvivors([grandchildPid], dir);
97
+ }
98
+ },
99
+ );
100
+
101
+ test(
102
+ "abort kills qmd launcher process group including grandchildren",
103
+ { skip: isPosix ? false : "POSIX process groups do not exist on Windows" },
104
+ async () => {
105
+ const { script, pidFile, dir } = await setupFakeLauncher();
106
+ let grandchildPid: number | undefined;
107
+ const controller = new AbortController();
108
+ try {
109
+ const run = runCommandWithTimeoutForTest(script, [], {
110
+ timeoutMs: 60_000,
111
+ signal: controller.signal,
112
+ label: "qmd fake",
113
+ });
114
+ setTimeout(() => controller.abort(), 300);
115
+ await assert.rejects(run, /aborted/);
116
+ grandchildPid = await readPidFile(pidFile);
117
+ assert.ok(
118
+ await waitForExit(grandchildPid),
119
+ `grandchild ${grandchildPid} survived the group kill`,
120
+ );
121
+ } finally {
122
+ await cleanupSurvivors([grandchildPid], dir);
123
+ }
124
+ },
125
+ );
package/src/qmd.ts CHANGED
@@ -562,6 +562,11 @@ function runCommandWithTimeout(
562
562
  const child = launchProcess(command, args, {
563
563
  env: mergeEnv({ NO_COLOR: "1", ...options.env }),
564
564
  stdio: ["ignore", "pipe", "pipe"],
565
+ // POSIX: own process group so a timeout/abort kill can take down qmd's
566
+ // grandchildren too (launcher scripts spawn workers that outlive the
567
+ // launcher itself). Issue #2456. Windows ignores this option here —
568
+ // kill falls back to a direct child.kill.
569
+ detached: process.platform !== "win32",
565
570
  });
566
571
  if (!child.stdout || !child.stderr) {
567
572
  reject(new Error(`${label} failed to open stdio pipes`));
@@ -575,7 +580,7 @@ function runCommandWithTimeout(
575
580
  const timer = setTimeout(() => {
576
581
  settled = true;
577
582
  cleanup();
578
- child.kill("SIGKILL");
583
+ killBoundedChild(child);
579
584
  // Flag the deadline breach so classifyProbeFailure can tell a genuine
580
585
  // timeout from a non-zero-exit error whose message merely embeds stderr.
581
586
  reject(
@@ -589,7 +594,7 @@ function runCommandWithTimeout(
589
594
  settled = true;
590
595
  clearTimeout(timer);
591
596
  cleanup();
592
- child.kill("SIGKILL");
597
+ killBoundedChild(child);
593
598
  reject(abortError(`${label} aborted`));
594
599
  };
595
600
  const cleanup = () => {
@@ -636,6 +641,29 @@ function runProcessCommand(
636
641
  });
637
642
  }
638
643
 
644
+ // Kill a bounded subprocess and its descendants. On POSIX the child runs
645
+ // detached (own process group), so a negative-pid signal reaches the whole
646
+ // tree. Fall back to a direct kill on Windows, missing pid, or when the
647
+ // group is already gone. Never throws — callers use it on best-effort paths.
648
+ function killBoundedChild(child: CommandChildProcess): void {
649
+ const pid = child.pid;
650
+ if (pid !== undefined && process.platform !== "win32") {
651
+ try {
652
+ process.kill(-pid, "SIGKILL");
653
+ return;
654
+ } catch {
655
+ // Group already reaped or never became a leader — fall through.
656
+ }
657
+ }
658
+ try {
659
+ child.kill("SIGKILL");
660
+ } catch {
661
+ // Already exited between the check and the kill.
662
+ }
663
+ }
664
+
665
+ export { runCommandWithTimeout as runCommandWithTimeoutForTest };
666
+
639
667
  // ---------------------------------------------------------------------------
640
668
  // QMD Stdio Daemon Session (MCP over stdio child process)
641
669
  // ---------------------------------------------------------------------------
@@ -0,0 +1,158 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { ConsolidationResultSchema, ExtractionResultSchema } from "./schemas.js";
5
+
6
+ // Synthetic fixtures only — no real memory content (public repo).
7
+ const validFact = {
8
+ category: "fact",
9
+ content: "The API rate limit is 1000 requests per minute.",
10
+ confidence: 0.8,
11
+ tags: ["api"],
12
+ };
13
+
14
+ const validEntity = {
15
+ name: "acme-corp",
16
+ type: "company",
17
+ facts: ["Uses PostgreSQL for the main database."],
18
+ };
19
+
20
+ const validQuestion = {
21
+ question: "Which service enforces the rate limit?",
22
+ context: "The limit shape suggests an upstream gateway.",
23
+ priority: 0.4,
24
+ };
25
+
26
+ const validRelationship = {
27
+ source: "person-jane-doe",
28
+ target: "acme-corp",
29
+ label: "works at",
30
+ };
31
+
32
+ function baseResult() {
33
+ return {
34
+ facts: [validFact],
35
+ profileUpdates: ["Prefers concise status updates."],
36
+ entities: [validEntity],
37
+ questions: [validQuestion],
38
+ relationships: [validRelationship],
39
+ };
40
+ }
41
+
42
+ test("ExtractionResultSchema salvages valid facts from a mixed-invalid array (#2455)", () => {
43
+ const parsed = ExtractionResultSchema.safeParse({
44
+ ...baseResult(),
45
+ facts: [
46
+ validFact,
47
+ { category: "not-a-category", content: "x", confidence: 0.5, tags: [] },
48
+ { category: "fact", content: "missing confidence", tags: [] },
49
+ // procedure facts without steps fail the superRefine — salvage must
50
+ // respect refinement issues, not just base-shape issues
51
+ { category: "procedure", content: "Deploy the service.", confidence: 0.9, tags: ["deploy"] },
52
+ "bare string is not a fact",
53
+ ],
54
+ });
55
+
56
+ assert.equal(parsed.success, true);
57
+ assert.deepEqual(parsed.data?.facts, [validFact]);
58
+ });
59
+
60
+ test("ExtractionResultSchema fails when a non-empty facts array is wholly invalid (#2455)", () => {
61
+ const parsed = ExtractionResultSchema.safeParse({
62
+ ...baseResult(),
63
+ facts: [
64
+ { category: "not-a-category", content: "x", confidence: 0.5, tags: [] },
65
+ 42,
66
+ ],
67
+ });
68
+
69
+ assert.equal(parsed.success, false);
70
+ assert.ok(
71
+ parsed.error.issues.some((issue) => issue.message.includes("no items matching the schema")),
72
+ );
73
+ });
74
+
75
+ test("ExtractionResultSchema keeps explicitly empty arrays valid (#2455)", () => {
76
+ const parsed = ExtractionResultSchema.safeParse({
77
+ facts: [],
78
+ profileUpdates: [],
79
+ entities: [],
80
+ questions: [],
81
+ relationships: [],
82
+ });
83
+
84
+ assert.equal(parsed.success, true);
85
+ assert.deepEqual(parsed.data?.facts, []);
86
+ assert.deepEqual(parsed.data?.questions, []);
87
+ });
88
+
89
+ test("ExtractionResultSchema salvages valid entities from a mixed-invalid array (#2455)", () => {
90
+ const parsed = ExtractionResultSchema.safeParse({
91
+ ...baseResult(),
92
+ entities: [
93
+ validEntity,
94
+ { name: "bad-type", type: "organization", facts: ["nope"] },
95
+ { name: "bad-facts", type: "person", facts: [17] },
96
+ null,
97
+ ],
98
+ });
99
+
100
+ assert.equal(parsed.success, true);
101
+ assert.deepEqual(parsed.data?.entities, [validEntity]);
102
+ });
103
+
104
+ test("ExtractionResultSchema fails when a non-empty entities array is wholly invalid (#2455)", () => {
105
+ const parsed = ExtractionResultSchema.safeParse({
106
+ ...baseResult(),
107
+ entities: [{ name: "bad-type", type: "organization", facts: ["nope"] }],
108
+ });
109
+
110
+ assert.equal(parsed.success, false);
111
+ });
112
+
113
+ test("ExtractionResultSchema salvages valid questions from a mixed-invalid array (#2455)", () => {
114
+ const parsed = ExtractionResultSchema.safeParse({
115
+ ...baseResult(),
116
+ questions: [
117
+ validQuestion,
118
+ { question: "no context", priority: 0.3 },
119
+ { question: "bad priority", context: "out of range", priority: 5 },
120
+ ],
121
+ });
122
+
123
+ assert.equal(parsed.success, true);
124
+ assert.deepEqual(parsed.data?.questions, [validQuestion]);
125
+ });
126
+
127
+ test("ExtractionResultSchema salvages valid profileUpdates and relationships (#2455)", () => {
128
+ const parsed = ExtractionResultSchema.safeParse({
129
+ ...baseResult(),
130
+ profileUpdates: ["Prefers concise status updates.", 42, null],
131
+ relationships: [validRelationship, { source: "jane-doe", label: "missing target" }],
132
+ });
133
+
134
+ assert.equal(parsed.success, true);
135
+ assert.deepEqual(parsed.data?.profileUpdates, ["Prefers concise status updates."]);
136
+ assert.deepEqual(parsed.data?.relationships, [validRelationship]);
137
+ });
138
+
139
+ test("ExtractionResultSchema still accepts null/omitted relationships (#2455)", () => {
140
+ const nullParsed = ExtractionResultSchema.safeParse({ ...baseResult(), relationships: null });
141
+ assert.equal(nullParsed.success, true);
142
+
143
+ const omitted: Record<string, unknown> = { ...baseResult() };
144
+ delete omitted.relationships;
145
+ const omittedParsed = ExtractionResultSchema.safeParse(omitted);
146
+ assert.equal(omittedParsed.success, true);
147
+ });
148
+
149
+ test("ConsolidationResultSchema stays strict on mixed-invalid arrays (#2455)", () => {
150
+ const validItem = { existingId: "fact-1", action: "SKIP", reason: "still accurate" };
151
+ const parsed = ConsolidationResultSchema.safeParse({
152
+ items: [validItem, { existingId: "fact-2", action: "DESTROY", reason: "bad enum" }],
153
+ profileUpdates: [],
154
+ entityUpdates: [],
155
+ });
156
+
157
+ assert.equal(parsed.success, false);
158
+ });
package/src/schemas.ts CHANGED
@@ -278,27 +278,51 @@ export const ProactiveExtractionResultSchema = z.object({
278
278
  ),
279
279
  });
280
280
 
281
+ /**
282
+ * Salvage array for model-produced extraction output (issue #2455).
283
+ *
284
+ * Mixed valid+invalid items parse to the valid subset so one malformed
285
+ * element no longer discards the whole extraction. A non-empty array with
286
+ * zero valid items still fails, so bounded retry stays effective and wholly
287
+ * malformed output is never silently accepted as empty. Empty arrays stay
288
+ * valid. Consolidation schemas intentionally stay strict — do not use this
289
+ * for them.
290
+ */
291
+ function salvageArray<T extends z.ZodTypeAny>(itemSchema: T) {
292
+ return z
293
+ .array(z.unknown())
294
+ .transform((items, ctx) => {
295
+ const valid: z.infer<T>[] = [];
296
+ for (const raw of items) {
297
+ const parsed = itemSchema.safeParse(raw);
298
+ if (parsed.success) {
299
+ valid.push(parsed.data);
300
+ }
301
+ }
302
+ if (items.length > 0 && valid.length === 0) {
303
+ ctx.addIssue({
304
+ code: z.ZodIssueCode.custom,
305
+ message: "array contains no items matching the schema",
306
+ });
307
+ return z.NEVER;
308
+ }
309
+ return valid;
310
+ });
311
+ }
312
+
281
313
  export const ExtractionResultSchema = z.object({
282
- facts: z
283
- .array(ExtractedFactSchema)
284
- .describe(
285
- "Extracted memories from the conversation. Include facts, preferences, corrections, and decisions. Only extract genuinely new, durable information — skip transient task state.",
286
- ),
287
- profileUpdates: z
288
- .array(z.string())
289
- .describe(
290
- "Updates to the user's behavioral profile. Each string is a standalone statement about the user's preferences, habits, or personality. Only include genuinely new insights.",
291
- ),
292
- entities: z
293
- .array(EntityMentionSchema)
294
- .describe(
295
- "Entities mentioned in the conversation with new facts about them.",
296
- ),
297
- questions: z
298
- .array(ExtractedQuestionSchema)
299
- .describe(
300
- "Zero to three source-grounded questions useful in future sessions. Return an empty array when the conversation supports none.",
301
- ),
314
+ facts: salvageArray(ExtractedFactSchema).describe(
315
+ "Extracted memories from the conversation. Include facts, preferences, corrections, and decisions. Only extract genuinely new, durable information — skip transient task state.",
316
+ ),
317
+ profileUpdates: salvageArray(z.string()).describe(
318
+ "Updates to the user's behavioral profile. Each string is a standalone statement about the user's preferences, habits, or personality. Only include genuinely new insights.",
319
+ ),
320
+ entities: salvageArray(EntityMentionSchema).describe(
321
+ "Entities mentioned in the conversation with new facts about them.",
322
+ ),
323
+ questions: salvageArray(ExtractedQuestionSchema).describe(
324
+ "Zero to three source-grounded questions useful in future sessions. Return an empty array when the conversation supports none.",
325
+ ),
302
326
  identityReflection: z
303
327
  .string()
304
328
  .optional()
@@ -311,8 +335,7 @@ export const ExtractionResultSchema = z.object({
311
335
  .optional()
312
336
  .nullable()
313
337
  .describe("A six-to-eight word title for this conversation segment."),
314
- relationships: z
315
- .array(ExtractedRelationshipSchema)
338
+ relationships: salvageArray(ExtractedRelationshipSchema)
316
339
  .optional()
317
340
  .nullable()
318
341
  .describe(