@remnic/core 9.3.636 → 9.3.638

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 (83) hide show
  1. package/dist/access-cli.js +12 -12
  2. package/dist/access-http.d.ts +2 -2
  3. package/dist/access-http.js +8 -6
  4. package/dist/access-mcp.d.ts +2 -2
  5. package/dist/access-mcp.js +7 -5
  6. package/dist/{access-service-GVOE5U3C.d.ts → access-service-DvEvBv9_.d.ts} +2 -1
  7. package/dist/access-service.d.ts +2 -2
  8. package/dist/access-service.js +6 -4
  9. package/dist/active-recall.js +1 -1
  10. package/dist/bootstrap.d.ts +1 -1
  11. package/dist/causal-behavior.js +2 -2
  12. package/dist/causal-chain.js +2 -2
  13. package/dist/causal-consolidation.js +2 -2
  14. package/dist/causal-retrieval.js +2 -2
  15. package/dist/causal-trajectory-graph.js +1 -1
  16. package/dist/causal-trajectory.js +1 -1
  17. package/dist/{chunk-VTH5SKQW.js → chunk-2JIYTTQJ.js} +4 -4
  18. package/dist/{chunk-HZVIYZYN.js → chunk-7KRYJUTB.js} +6 -6
  19. package/dist/{chunk-7H5WCPBS.js → chunk-A3HKTPPV.js} +21 -6
  20. package/dist/chunk-A3HKTPPV.js.map +1 -0
  21. package/dist/{chunk-2IT7WFYE.js → chunk-FWIROLS6.js} +2 -1
  22. package/dist/chunk-FWIROLS6.js.map +1 -0
  23. package/dist/{chunk-JYFO32V4.js → chunk-I3YAA4BI.js} +9 -9
  24. package/dist/{chunk-A6D7A2FW.js → chunk-IHG27THY.js} +4 -4
  25. package/dist/{chunk-W25I7G6U.js → chunk-KXTLEB4L.js} +3 -3
  26. package/dist/{chunk-XM6M6DHG.js → chunk-MNDFVT4F.js} +195 -6
  27. package/dist/chunk-MNDFVT4F.js.map +1 -0
  28. package/dist/{chunk-7V2SGZ3H.js → chunk-NE566K4E.js} +2 -2
  29. package/dist/{chunk-NFNQ7TWF.js → chunk-OSPVAP56.js} +3 -3
  30. package/dist/{chunk-UXFOGILU.js → chunk-QDAWVW75.js} +1 -1
  31. package/dist/{chunk-TQOU3VAT.js → chunk-SSOMTUCA.js} +1 -1
  32. package/dist/{chunk-TFICNXXU.js → chunk-TJRV4MWX.js} +990 -252
  33. package/dist/chunk-TJRV4MWX.js.map +1 -0
  34. package/dist/{chunk-BK35NOJL.js → chunk-U2YPUWRB.js} +3 -3
  35. package/dist/{chunk-EWC6W6AB.js → chunk-UXA5L2DZ.js} +2 -2
  36. package/dist/{chunk-XL7FK7PJ.js → chunk-Y56J7CXW.js} +33 -16
  37. package/dist/{chunk-XL7FK7PJ.js.map → chunk-Y56J7CXW.js.map} +1 -1
  38. package/dist/{cli-B-fHk59D.d.ts → cli-BJBKNWIS.d.ts} +2 -2
  39. package/dist/cli.d.ts +3 -3
  40. package/dist/cli.js +13 -12
  41. package/dist/compounding/engine.js +1 -1
  42. package/dist/config.js +1 -1
  43. package/dist/explicit-capture.d.ts +1 -1
  44. package/dist/{graph-edge-decay-5ZOK7ZNO.js → graph-edge-decay-PUFNHOBS.js} +2 -2
  45. package/dist/graph-snapshot.js +2 -2
  46. package/dist/graph.d.ts +3 -0
  47. package/dist/graph.js +1 -1
  48. package/dist/index.d.ts +4 -4
  49. package/dist/index.js +17 -17
  50. package/dist/mcp-memory-inspector-app.d.ts +2 -2
  51. package/dist/operator-toolkit.js +3 -3
  52. package/dist/{orchestrator-CoqytbK_.d.ts → orchestrator-ibTTeoUa.d.ts} +9 -0
  53. package/dist/orchestrator.d.ts +1 -1
  54. package/dist/orchestrator.js +9 -9
  55. package/dist/recall-state.d.ts +2 -0
  56. package/dist/recall-state.js +1 -1
  57. package/dist/resume-bundles.js +4 -4
  58. package/dist/summarizer.js +2 -2
  59. package/dist/transcript.js +2 -2
  60. package/package.json +1 -1
  61. package/src/access-service-namespace.test.ts +740 -0
  62. package/src/access-service.ts +222 -3
  63. package/src/config.test.ts +118 -0
  64. package/src/config.ts +45 -10
  65. package/src/graph.ts +39 -17
  66. package/src/orchestrator.ts +1286 -331
  67. package/src/recall-state.ts +3 -0
  68. package/dist/chunk-2IT7WFYE.js.map +0 -1
  69. package/dist/chunk-7H5WCPBS.js.map +0 -1
  70. package/dist/chunk-TFICNXXU.js.map +0 -1
  71. package/dist/chunk-XM6M6DHG.js.map +0 -1
  72. /package/dist/{chunk-VTH5SKQW.js.map → chunk-2JIYTTQJ.js.map} +0 -0
  73. /package/dist/{chunk-HZVIYZYN.js.map → chunk-7KRYJUTB.js.map} +0 -0
  74. /package/dist/{chunk-JYFO32V4.js.map → chunk-I3YAA4BI.js.map} +0 -0
  75. /package/dist/{chunk-A6D7A2FW.js.map → chunk-IHG27THY.js.map} +0 -0
  76. /package/dist/{chunk-W25I7G6U.js.map → chunk-KXTLEB4L.js.map} +0 -0
  77. /package/dist/{chunk-7V2SGZ3H.js.map → chunk-NE566K4E.js.map} +0 -0
  78. /package/dist/{chunk-NFNQ7TWF.js.map → chunk-OSPVAP56.js.map} +0 -0
  79. /package/dist/{chunk-UXFOGILU.js.map → chunk-QDAWVW75.js.map} +0 -0
  80. /package/dist/{chunk-TQOU3VAT.js.map → chunk-SSOMTUCA.js.map} +0 -0
  81. /package/dist/{chunk-BK35NOJL.js.map → chunk-U2YPUWRB.js.map} +0 -0
  82. /package/dist/{chunk-EWC6W6AB.js.map → chunk-UXA5L2DZ.js.map} +0 -0
  83. /package/dist/{graph-edge-decay-5ZOK7ZNO.js.map → graph-edge-decay-PUFNHOBS.js.map} +0 -0
@@ -54,7 +54,10 @@ import {
54
54
  } from "./memory-lifecycle-ledger-utils.js";
55
55
  import { getMemoryProjectionPath } from "./memory-projection-store.js";
56
56
  import { canReadNamespace, canWriteNamespace, defaultNamespaceForPrincipal, recallNamespacesForPrincipal, resolvePrincipal } from "./namespaces/principal.js";
57
+ import { namespaceIdentityFromToken } from "./namespaces/identity.js";
57
58
  import { namespaceCollectionName } from "./namespaces/search.js";
59
+ import { SecureStoreLockedError } from "./secure-store/index.js";
60
+ import { isPathInsideStorageRoot } from "./storage-paths.js";
58
61
  import type { LastRecallSnapshot } from "./recall-state.js";
59
62
  import type {
60
63
  GraphRecallSnapshot,
@@ -168,6 +171,52 @@ import { formatProfileTraceAscii } from "./profiling.js";
168
171
 
169
172
  export class EngramAccessInputError extends Error {}
170
173
 
174
+ function qmdCollectionPathParts(resultPath: string): {
175
+ collection: string;
176
+ relativePath: string;
177
+ } | null {
178
+ if (!resultPath || nodePath.isAbsolute(resultPath)) return null;
179
+ const normalized = resultPath.replace(/\\/g, "/").replace(/^\/+/, "");
180
+ const slashIndex = normalized.indexOf("/");
181
+ if (slashIndex <= 0 || slashIndex >= normalized.length - 1) return null;
182
+ const collection = normalized.slice(0, slashIndex);
183
+ if (/^\d{4}-\d{2}-\d{2}$/.test(collection)) return null;
184
+ return {
185
+ collection,
186
+ relativePath: normalized.slice(slashIndex + 1),
187
+ };
188
+ }
189
+
190
+ function qmdResultPathCandidates(
191
+ storageDir: string,
192
+ resultPath: string,
193
+ ): string[] {
194
+ const candidates = new Set<string>();
195
+ const storageRoot = nodePath.resolve(storageDir);
196
+ const addCandidate = (candidate: string) => {
197
+ const resolved = nodePath.resolve(candidate);
198
+ if (isPathInsideStorageRoot(storageRoot, resolved)) {
199
+ candidates.add(resolved);
200
+ }
201
+ };
202
+ const addRelativeCandidates = (relativePath: string) => {
203
+ const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
204
+ if (!normalized) return;
205
+ addCandidate(nodePath.join(storageRoot, normalized));
206
+ if (/^\d{4}-\d{2}-\d{2}\//.test(normalized)) {
207
+ addCandidate(nodePath.join(storageRoot, "facts", normalized));
208
+ }
209
+ };
210
+
211
+ if (nodePath.isAbsolute(resultPath)) {
212
+ addCandidate(resultPath);
213
+ } else {
214
+ addRelativeCandidates(resultPath);
215
+ }
216
+
217
+ return [...candidates];
218
+ }
219
+
171
220
  type AccessProfilingReportRequest = {
172
221
  format?: string;
173
222
  limit?: number;
@@ -1437,6 +1486,7 @@ export class EngramAccessService {
1437
1486
  queryLen: options.query.length,
1438
1487
  memoryIds,
1439
1488
  namespace,
1489
+ recallNamespaces: [namespace],
1440
1490
  traceId: options.snapshot.traceId,
1441
1491
  plannerMode: options.normalizedMode,
1442
1492
  requestedMode:
@@ -1500,8 +1550,124 @@ export class EngramAccessService {
1500
1550
  const namespace = snapshot.namespace ? this.resolveNamespace(snapshot.namespace) : this.orchestrator.config.defaultNamespace;
1501
1551
  const storage = await this.orchestrator.getStorage(namespace);
1502
1552
  const storageDir = storage.dir;
1553
+ const recallNamespaces = Array.from(
1554
+ new Set(
1555
+ [
1556
+ namespace,
1557
+ ...(Array.isArray(snapshot.recallNamespaces)
1558
+ ? snapshot.recallNamespaces.map((ns) => this.resolveNamespace(ns))
1559
+ : []),
1560
+ this.orchestrator.config.defaultNamespace,
1561
+ this.orchestrator.config.sharedNamespace,
1562
+ ...(this.orchestrator.config.namespacePolicies ?? []).map((p) => p.name),
1563
+ ].filter((ns): ns is string => typeof ns === "string" && ns.length > 0),
1564
+ ),
1565
+ );
1503
1566
  const results: EngramAccessMemorySummary[] = [];
1504
1567
  const seen = new Set<string>();
1568
+ const collectionNamespaceFromPrefix = (collectionPrefix: string): string | null => {
1569
+ const baseCollection = this.orchestrator.config.qmdCollection;
1570
+ if (collectionPrefix === baseCollection) return this.orchestrator.config.defaultNamespace;
1571
+ const namespaceSuffix = collectionPrefix.startsWith(`${baseCollection}--`)
1572
+ ? collectionPrefix.slice(baseCollection.length + 2)
1573
+ : "";
1574
+ if (!namespaceSuffix) return null;
1575
+
1576
+ const decoded = namespaceIdentityFromToken(namespaceSuffix);
1577
+ if (decoded !== null) return decoded || this.orchestrator.config.defaultNamespace;
1578
+ if (namespaceSuffix.startsWith("ns--")) {
1579
+ const legacyNamespace = namespaceSuffix.slice("ns--".length).trim();
1580
+ return legacyNamespace || null;
1581
+ }
1582
+ return null;
1583
+ };
1584
+ const readResultPath = async (
1585
+ memoryPath: string,
1586
+ ): Promise<{ memory: MemoryFile; baseDir: string } | null> => {
1587
+ const parts = qmdCollectionPathParts(memoryPath);
1588
+ const coldCollection =
1589
+ this.orchestrator.config.qmdColdCollection ?? "openclaw-engram-cold";
1590
+ if (parts && parts.collection === coldCollection) {
1591
+ const storages: Array<{ storage: StorageManager; dir: string }> = [];
1592
+ const seenStorageDirs = new Set<string>();
1593
+ const addStorage = (candidateStorage: StorageManager): void => {
1594
+ const candidateDir = nodePath.resolve(candidateStorage.dir);
1595
+ if (seenStorageDirs.has(candidateDir)) return;
1596
+ seenStorageDirs.add(candidateDir);
1597
+ storages.push({ storage: candidateStorage, dir: candidateDir });
1598
+ };
1599
+ addStorage(storage);
1600
+ for (const recallNamespace of recallNamespaces) {
1601
+ try {
1602
+ addStorage(await this.orchestrator.getStorage(recallNamespace));
1603
+ } catch {
1604
+ continue;
1605
+ }
1606
+ }
1607
+ for (const candidateStorage of storages) {
1608
+ try {
1609
+ const coldRoot = nodePath.join(candidateStorage.dir, "cold");
1610
+ for (const candidatePath of qmdResultPathCandidates(
1611
+ coldRoot,
1612
+ parts.relativePath,
1613
+ )) {
1614
+ const memory =
1615
+ await candidateStorage.storage.readMemoryByPath(candidatePath);
1616
+ if (memory) return { memory, baseDir: candidateStorage.dir };
1617
+ }
1618
+ } catch (err) {
1619
+ if (err instanceof SecureStoreLockedError) throw err;
1620
+ continue;
1621
+ }
1622
+ }
1623
+ return null;
1624
+ }
1625
+
1626
+ const collectionNamespace = parts
1627
+ ? collectionNamespaceFromPrefix(parts.collection)
1628
+ : null;
1629
+
1630
+ if (parts && collectionNamespace) {
1631
+ try {
1632
+ const collectionStorage =
1633
+ await this.orchestrator.getStorage(collectionNamespace);
1634
+ for (const candidate of qmdResultPathCandidates(
1635
+ collectionStorage.dir,
1636
+ parts.relativePath,
1637
+ )) {
1638
+ const memory = await collectionStorage.readMemoryByPath(candidate);
1639
+ if (memory) return { memory, baseDir: collectionStorage.dir };
1640
+ }
1641
+ return null;
1642
+ } catch (err) {
1643
+ if (err instanceof SecureStoreLockedError) throw err;
1644
+ return null;
1645
+ }
1646
+ }
1647
+
1648
+ if (nodePath.isAbsolute(memoryPath)) {
1649
+ const ownerStorage = await this.storageForAbsoluteRecallPath(
1650
+ memoryPath,
1651
+ namespace,
1652
+ recallNamespaces,
1653
+ );
1654
+ if (!ownerStorage) return null;
1655
+ for (const candidate of qmdResultPathCandidates(
1656
+ ownerStorage.dir,
1657
+ memoryPath,
1658
+ )) {
1659
+ const memory = await ownerStorage.storage.readMemoryByPath(candidate);
1660
+ if (memory) return { memory, baseDir: ownerStorage.dir };
1661
+ }
1662
+ return null;
1663
+ }
1664
+
1665
+ for (const candidate of qmdResultPathCandidates(storageDir, memoryPath)) {
1666
+ const memory = await storage.readMemoryByPath(candidate);
1667
+ if (memory) return { memory, baseDir: storageDir };
1668
+ }
1669
+ return null;
1670
+ };
1505
1671
 
1506
1672
  // Pre-fetch raw excerpts once when `disclosure === "raw"` so we don't
1507
1673
  // hit the LCM archive per-result (issue #677 PR 2/4). Excerpts are
@@ -1519,13 +1685,14 @@ export class EngramAccessService {
1519
1685
 
1520
1686
  for (const memoryPath of snapshot.resultPaths ?? []) {
1521
1687
  if (!memoryPath || seen.has(memoryPath)) continue;
1522
- const memory = await storage.readMemoryByPath(memoryPath);
1523
- if (!memory) continue;
1688
+ const resolved = await readResultPath(memoryPath);
1689
+ if (!resolved) continue;
1690
+ const { memory, baseDir } = resolved;
1524
1691
  seen.add(memoryPath);
1525
1692
  results.push(
1526
1693
  this.serializeMemorySummary(
1527
1694
  memory,
1528
- storageDir,
1695
+ baseDir,
1529
1696
  disclosure,
1530
1697
  // Attach the (possibly empty) raw excerpts to the first raw
1531
1698
  // result; subsequent results do not duplicate the array.
@@ -1552,6 +1719,58 @@ export class EngramAccessService {
1552
1719
  return results;
1553
1720
  }
1554
1721
 
1722
+ private async storageForAbsoluteRecallPath(
1723
+ memoryPath: string,
1724
+ primaryNamespace: string,
1725
+ recallNamespaces: readonly string[] = [],
1726
+ ): Promise<{ storage: StorageManager; dir: string } | null> {
1727
+ const resolvedPath = nodePath.resolve(memoryPath);
1728
+ const memoryRoot = nodePath.resolve(this.orchestrator.config.memoryDir);
1729
+ const namespacesRoot = nodePath.join(memoryRoot, "namespaces");
1730
+ const configuredNamespaces = new Set<string>();
1731
+ configuredNamespaces.add(primaryNamespace);
1732
+ for (const namespace of recallNamespaces) {
1733
+ configuredNamespaces.add(namespace);
1734
+ }
1735
+ configuredNamespaces.add(this.orchestrator.config.defaultNamespace);
1736
+ configuredNamespaces.add(this.orchestrator.config.sharedNamespace);
1737
+ if (isPathInsideStorageRoot(namespacesRoot, resolvedPath)) {
1738
+ const relativeToNamespaces = nodePath.relative(namespacesRoot, resolvedPath);
1739
+ const [namespaceSegment] = relativeToNamespaces.split(/[\\/]/);
1740
+ if (namespaceSegment) {
1741
+ configuredNamespaces.add(
1742
+ namespaceIdentityFromToken(namespaceSegment) ?? namespaceSegment,
1743
+ );
1744
+ }
1745
+ }
1746
+ for (const policy of this.orchestrator.config.namespacePolicies ?? []) {
1747
+ configuredNamespaces.add(policy.name);
1748
+ }
1749
+
1750
+ const matches: Array<{ storage: StorageManager; dir: string }> = [];
1751
+ for (const ns of configuredNamespaces) {
1752
+ if (!ns) continue;
1753
+ let candidateStorage: StorageManager;
1754
+ try {
1755
+ candidateStorage = await this.orchestrator.getStorage(ns);
1756
+ } catch {
1757
+ continue;
1758
+ }
1759
+ const candidateRoot = nodePath.resolve(candidateStorage.dir);
1760
+ if (!isPathInsideStorageRoot(candidateRoot, resolvedPath)) continue;
1761
+ if (
1762
+ candidateRoot === memoryRoot &&
1763
+ isPathInsideStorageRoot(namespacesRoot, resolvedPath)
1764
+ ) {
1765
+ continue;
1766
+ }
1767
+ matches.push({ storage: candidateStorage, dir: candidateRoot });
1768
+ }
1769
+
1770
+ matches.sort((a, b) => b.dir.length - a.dir.length);
1771
+ return matches[0] ?? null;
1772
+ }
1773
+
1555
1774
  /**
1556
1775
  * Fetch raw transcript excerpts from the LCM archive for `disclosure ===
1557
1776
  * "raw"` recalls (issue #677 PR 2/4). Returns `null` for non-raw recall
@@ -353,6 +353,124 @@ test("parseConfig rejects unknown taskModelChain keys (codex review #1425)", ()
353
353
  );
354
354
  });
355
355
 
356
+ test("parseConfig routes gateway task-model defaults through taskModelChain primary", () => {
357
+ const cfg = parseConfig({
358
+ modelSource: "gateway",
359
+ taskModelChain: {
360
+ primary: "openrouter/deepseek/deepseek-v4-flash",
361
+ fallbacks: ["zai/glm-4.5-air"],
362
+ },
363
+ });
364
+
365
+ // Base model stays direct-compatible — direct-key call sites (e.g. briefing
366
+ // follow-ups) pass it straight to the OpenAI Responses API, so it must never
367
+ // become a provider-qualified gateway route string (issue #1469 / PR #1470).
368
+ assert.equal(cfg.model, "gpt-5.5");
369
+ // Gateway-routed task models pick up the configured task-chain primary.
370
+ assert.equal(cfg.summaryModel, "openrouter/deepseek/deepseek-v4-flash");
371
+ assert.equal(cfg.recallPlannerModel, "openrouter/deepseek/deepseek-v4-flash");
372
+ });
373
+
374
+ test("parseConfig lets explicit task models override taskModelChain defaults", () => {
375
+ const cfg = parseConfig({
376
+ modelSource: "gateway",
377
+ model: "openrouter/model-override",
378
+ summaryModel: "openrouter/summary-override",
379
+ recallPlannerModel: "openrouter/planner-override",
380
+ taskModelChain: {
381
+ primary: "openrouter/deepseek/deepseek-v4-flash",
382
+ },
383
+ });
384
+
385
+ assert.equal(cfg.model, "openrouter/model-override");
386
+ assert.equal(cfg.summaryModel, "openrouter/summary-override");
387
+ assert.equal(cfg.recallPlannerModel, "openrouter/planner-override");
388
+ });
389
+
390
+ test("parseConfig lets a provider-qualified base model feed summaryModel in gateway mode without a task chain (#1469)", () => {
391
+ // A provider-qualified explicit `model` (no summaryModel, no taskModelChain) is
392
+ // routable through the gateway, so summaryModel inherits it as its second-priority
393
+ // source. This exercises the `gatewayTaskModel(explicitModel)` leg of the chain.
394
+ const cfg = parseConfig({
395
+ modelSource: "gateway",
396
+ model: "openrouter/my-model",
397
+ });
398
+
399
+ assert.equal(cfg.model, "openrouter/my-model");
400
+ assert.equal(cfg.summaryModel, "openrouter/my-model");
401
+ // recallPlannerModel intentionally does NOT inherit cfg.model — it only
402
+ // considers its own explicit field then the gateway task fallback.
403
+ assert.equal(cfg.recallPlannerModel, "");
404
+ });
405
+
406
+ test("parseConfig treats the injected bare schema-default model as absent for gateway task routing (#1469)", () => {
407
+ // OpenClaw applies the manifest schema defaults (model + recallPlannerModel
408
+ // default to "gpt-5.5") BEFORE parseConfig sees the config, so an operator who
409
+ // only set modelSource + taskModelChain still arrives here with a bare
410
+ // model === "gpt-5.5". The gateway-routed task models must NOT pin that bare id
411
+ // (it would resolve to an unroutable <provider>/gpt-5.5); they fall through to
412
+ // the configured task chain instead (codex review on PR #1470).
413
+ const cfg = parseConfig({
414
+ modelSource: "gateway",
415
+ model: "gpt-5.5", // injected schema default, not an operator choice
416
+ recallPlannerModel: "gpt-5.5", // injected schema default
417
+ taskModelChain: { primary: "openrouter/deepseek/deepseek-v4-flash" },
418
+ });
419
+
420
+ // Base model stays direct-compatible for direct-key call sites...
421
+ assert.equal(cfg.model, "gpt-5.5");
422
+ // ...but the gateway-routed task models pick up the task-chain primary.
423
+ assert.equal(cfg.summaryModel, "openrouter/deepseek/deepseek-v4-flash");
424
+ assert.equal(cfg.recallPlannerModel, "openrouter/deepseek/deepseek-v4-flash");
425
+ });
426
+
427
+ test("parseConfig drops a bare schema-default model in gateway mode with no task chain (#1469)", () => {
428
+ const cfg = parseConfig({
429
+ modelSource: "gateway",
430
+ model: "gpt-5.5", // injected schema default
431
+ recallPlannerModel: "gpt-5.5",
432
+ });
433
+
434
+ // Nothing routable is configured → gateway task models stay empty so the
435
+ // Gateway default wins (no bare "gpt-5.5" leaks to the flush plan / cron).
436
+ assert.equal(cfg.summaryModel, "");
437
+ assert.equal(cfg.recallPlannerModel, "");
438
+ });
439
+
440
+ test("parseConfig leaves gateway task-model defaults empty without taskModelChain", () => {
441
+ const cfg = parseConfig({ modelSource: "gateway" });
442
+
443
+ // Base model keeps the direct-compatible default...
444
+ assert.equal(cfg.model, "gpt-5.5");
445
+ // ...but gateway-routed task models stay empty so the Gateway default wins
446
+ // (no unroutable bare id is ever sent to the gateway).
447
+ assert.equal(cfg.summaryModel, "");
448
+ assert.equal(cfg.recallPlannerModel, "");
449
+ });
450
+
451
+ test("parseConfig keeps model direct-compatible when a direct openaiApiKey coexists with a gateway task chain (#1469)", () => {
452
+ const original = process.env.OPENAI_API_KEY;
453
+ delete process.env.OPENAI_API_KEY;
454
+ try {
455
+ const cfg = parseConfig({
456
+ modelSource: "gateway",
457
+ openaiApiKey: "sk-direct-key",
458
+ taskModelChain: { primary: "openrouter/deepseek/deepseek-v4-flash" },
459
+ });
460
+
461
+ // Direct-key call sites (e.g. briefing follow-ups) use cfg.model against the
462
+ // OpenAI Responses API, so it must NOT become the provider-qualified gateway
463
+ // route string even though a task chain is configured. The task chain only
464
+ // feeds the gateway-routed summary model (codex review on PR #1470).
465
+ assert.equal(cfg.openaiApiKey, "sk-direct-key");
466
+ assert.equal(cfg.model, "gpt-5.5");
467
+ assert.equal(cfg.summaryModel, "openrouter/deepseek/deepseek-v4-flash");
468
+ } finally {
469
+ if (original === undefined) delete process.env.OPENAI_API_KEY;
470
+ else process.env.OPENAI_API_KEY = original;
471
+ }
472
+ });
473
+
356
474
  test("parseConfig modelSource=gateway still honors an explicit openaiApiKey override", () => {
357
475
  const original = process.env.OPENAI_API_KEY;
358
476
  process.env.OPENAI_API_KEY = "sk-env-should-not-be-used";
package/src/config.ts CHANGED
@@ -774,10 +774,31 @@ export function parseConfig(raw: unknown): PluginConfig {
774
774
  // API key is optional at load time — retrieval works without it.
775
775
  // Extraction will log a warning if called without a key.
776
776
 
777
- const model =
778
- typeof cfg.model === "string" && cfg.model.length > 0
779
- ? cfg.model
777
+ const taskModelChain = parseModelChainConfig(cfg.taskModelChain, "taskModelChain");
778
+ // Gateway-routed background tasks (flush plan, hourly summary cron, recall
779
+ // planner) default to the configured task-model primary, or empty so the
780
+ // Gateway default wins — never a bare reasoning id that resolves to an
781
+ // unroutable `<provider>/gpt-5.5` (issue #1469).
782
+ const taskModelFallback =
783
+ modelSource === "gateway"
784
+ ? (taskModelChain?.primary ?? "")
780
785
  : DEFAULT_REASONING_MODEL;
786
+ const explicitModel =
787
+ typeof cfg.model === "string" && cfg.model.length > 0 ? cfg.model : "";
788
+ // Base reasoning/extraction model. Kept DIRECT-compatible: direct-key call
789
+ // sites (e.g. briefing follow-ups when `openaiApiKey` is set) pass this
790
+ // straight to the OpenAI Responses API, so it must stay a bare/direct model
791
+ // id and never a provider-qualified gateway route string. The gateway task
792
+ // chain is applied at gateway-routed call sites (summaryModel, recall planner)
793
+ // instead, not here (issue #1469 / codex review on PR #1470).
794
+ const model = explicitModel || DEFAULT_REASONING_MODEL;
795
+ // In gateway mode a usable task route must be provider-qualified (`provider/model`).
796
+ // A bare id — including the `"gpt-5.5"` schema default OpenClaw injects when the
797
+ // operator only configured `modelSource` + `taskModelChain` — resolves to an
798
+ // unroutable `<provider>/gpt-5.5`, so it is treated as ABSENT and the configured
799
+ // task chain wins. Direct (non-gateway) mode keeps bare ids as-is (#1469).
800
+ const gatewayTaskModel = (value: string): string =>
801
+ modelSource === "gateway" && !value.includes("/") ? "" : value;
781
802
  const captureMode =
782
803
  cfg.captureMode === "explicit" || cfg.captureMode === "hybrid"
783
804
  ? cfg.captureMode
@@ -2049,10 +2070,19 @@ export function parseConfig(raw: unknown): PluginConfig {
2049
2070
  typeof cfg.summaryRecallHours === "number" ? cfg.summaryRecallHours : 24,
2050
2071
  maxSummaryCount:
2051
2072
  typeof cfg.maxSummaryCount === "number" ? cfg.maxSummaryCount : 6,
2073
+ // Gateway-routed (memory flush plan + hourly summary cron). Precedence:
2074
+ // explicit summaryModel → explicit base model → gateway task-chain primary →
2075
+ // "" (omit, so the Gateway default wins). In gateway mode bare ids (incl. the
2076
+ // injected `model` schema default) are dropped via `gatewayTaskModel`, so the
2077
+ // Gateway is never sent an unroutable `<provider>/gpt-5.5` (issue #1469).
2052
2078
  summaryModel:
2053
- typeof cfg.summaryModel === "string" && cfg.summaryModel.length > 0
2054
- ? cfg.summaryModel
2055
- : model, // default: same as extraction model
2079
+ gatewayTaskModel(
2080
+ typeof cfg.summaryModel === "string" && cfg.summaryModel.length > 0
2081
+ ? cfg.summaryModel
2082
+ : "",
2083
+ ) ||
2084
+ gatewayTaskModel(explicitModel) ||
2085
+ taskModelFallback,
2056
2086
  // v2.4 Extended hourly summaries (default off)
2057
2087
  hourlySummariesExtendedEnabled: cfg.hourlySummariesExtendedEnabled === true,
2058
2088
  hourlySummariesIncludeToolStats: cfg.hourlySummariesIncludeToolStats === true,
@@ -2540,7 +2570,7 @@ export function parseConfig(raw: unknown): PluginConfig {
2540
2570
  typeof cfg.fastGatewayAgentId === "string" && cfg.fastGatewayAgentId.length > 0
2541
2571
  ? cfg.fastGatewayAgentId
2542
2572
  : "",
2543
- taskModelChain: parseModelChainConfig(cfg.taskModelChain, "taskModelChain"),
2573
+ taskModelChain,
2544
2574
 
2545
2575
  // v3.0 namespaces (default off)
2546
2576
  namespacesEnabled: cfg.namespacesEnabled === true,
@@ -2896,10 +2926,15 @@ export function parseConfig(raw: unknown): PluginConfig {
2896
2926
  // CLI/env surfaces (`--config recallPlannerLlmEnabled=true`) actually
2897
2927
  // enable it (gotcha #36); defaults off.
2898
2928
  recallPlannerLlmEnabled: coerceBooleanLike(cfg.recallPlannerLlmEnabled) ?? false,
2929
+ // Gateway-routed via FallbackLlmClient (qualifiedPlannerModel drops bare ids).
2930
+ // Drop the bare schema-default model in gateway mode too so the resolved value
2931
+ // reflects the actual gateway route (task-chain primary) instead of "gpt-5.5".
2899
2932
  recallPlannerModel:
2900
- typeof cfg.recallPlannerModel === "string" && cfg.recallPlannerModel.trim().length > 0
2901
- ? cfg.recallPlannerModel.trim()
2902
- : DEFAULT_REASONING_MODEL,
2933
+ gatewayTaskModel(
2934
+ typeof cfg.recallPlannerModel === "string" && cfg.recallPlannerModel.trim().length > 0
2935
+ ? cfg.recallPlannerModel.trim()
2936
+ : "",
2937
+ ) || taskModelFallback,
2903
2938
  recallPlannerTimeoutMs:
2904
2939
  typeof cfg.recallPlannerTimeoutMs === "number" ? cfg.recallPlannerTimeoutMs : 1500,
2905
2940
  recallPlannerUseResponsesApi: coerceBooleanLike(cfg.recallPlannerUseResponsesApi) ?? true,
package/src/graph.ts CHANGED
@@ -517,6 +517,8 @@ export class GraphIndex {
517
517
  * Default `false` (floor from config is applied).
518
518
  */
519
519
  includeLowConfidence?: boolean;
520
+ /** Absolute deadline in ms-since-epoch for post-retrieval assembly. */
521
+ deadlineAtMs?: number;
520
522
  }
521
523
  ): Promise<
522
524
  Array<{
@@ -545,15 +547,22 @@ export class GraphIndex {
545
547
  const floor =
546
548
  opts?.includeLowConfidence === true ? 0 : clampConfidenceFloor(this.cfg.graphTraversalConfidenceFloor);
547
549
  const iterations = clampPageRankIterations(this.cfg.graphTraversalPageRankIterations);
550
+ const deadlineAtMs = opts?.deadlineAtMs;
551
+ const deadlineExpired = (): boolean =>
552
+ typeof deadlineAtMs === "number" && Date.now() >= deadlineAtMs;
548
553
 
549
554
  try {
555
+ if (deadlineExpired()) return [];
550
556
  const allEdges = await this.loadEdgesCached();
557
+ if (deadlineExpired()) return [];
551
558
 
552
559
  // Build adjacency index: from → edges, to → edges (bidirectional for entity/time, directional for causal).
553
560
  // Edges below the confidence floor are pruned at index time so neither
554
561
  // direct activation nor downstream BFS expansion can re-introduce them.
555
562
  const adj = new Map<string, GraphEdge[]>();
556
- for (const edge of allEdges) {
563
+ for (let i = 0; i < allEdges.length; i += 1) {
564
+ if ((i & 1023) === 0 && deadlineExpired()) return [];
565
+ const edge = allEdges[i];
557
566
  const conf = readEdgeConfidence(edge);
558
567
  if (conf < floor) continue;
559
568
  if (!adj.has(edge.from)) adj.set(edge.from, []);
@@ -583,13 +592,28 @@ export class GraphIndex {
583
592
  frontier.set(`${seed}\0${seed}`, { node: seed, seed, activation: 1 });
584
593
  reachedBySeed.set(seed, new Set([seed]));
585
594
  }
595
+ const finalizeScores = () =>
596
+ Array.from(scores.entries())
597
+ .map(([p, score]) => ({
598
+ path: p,
599
+ score,
600
+ seed: provenance.get(p)?.seed ?? "",
601
+ hopDepth: provenance.get(p)?.hopDepth ?? 0,
602
+ decayedWeight: provenance.get(p)?.decayedWeight ?? 0,
603
+ graphType: provenance.get(p)?.graphType ?? "entity",
604
+ edgeConfidence: provenance.get(p)?.edgeConfidence ?? 1,
605
+ }))
606
+ .sort((a, b) => b.score - a.score);
586
607
 
587
608
  for (let hop = 0; hop < steps && frontier.size > 0; hop++) {
609
+ if (deadlineExpired()) return finalizeScores();
588
610
  const nextFrontier = new Map<string, { node: string; seed: string; activation: number }>();
589
611
 
590
612
  for (const { node, seed: sourceSeed, activation } of frontier.values()) {
591
613
  const edges = adj.get(node) ?? [];
592
- for (const edge of edges) {
614
+ for (let i = 0; i < edges.length; i += 1) {
615
+ if ((i & 1023) === 0 && deadlineExpired()) return finalizeScores();
616
+ const edge = edges[i];
593
617
  const neighbor = edge.to === node ? edge.from : edge.to;
594
618
  const conf = readEdgeConfidence(edge);
595
619
  // Defense in depth: the adjacency build already drops sub-floor
@@ -645,15 +669,17 @@ export class GraphIndex {
645
669
  // edges, weighted by edge confidence. Damping is fixed at the
646
670
  // canonical 0.85 so the ranking stays comparable across queries;
647
671
  // the `iterations` knob bounds compute, not behavior shape.
648
- if (iterations > 0 && scores.size > 1) {
672
+ if (!deadlineExpired() && iterations > 0 && scores.size > 1) {
649
673
  applyPageRankRefinement(scores, adj, {
650
674
  iterations,
651
675
  floor,
652
676
  damping: 0.85,
677
+ deadlineAtMs,
653
678
  });
654
679
  }
655
680
 
656
681
  // Apply lateral inhibition if enabled (Synapse-inspired competitive suppression)
682
+ if (deadlineExpired()) return finalizeScores();
657
683
  if (this.cfg.graphLateralInhibitionEnabled && scores.size > 1) {
658
684
  const inhibited = applyLateralInhibition(scores, {
659
685
  beta: this.cfg.graphLateralInhibitionBeta,
@@ -664,17 +690,7 @@ export class GraphIndex {
664
690
  }
665
691
  }
666
692
 
667
- return Array.from(scores.entries())
668
- .map(([p, score]) => ({
669
- path: p,
670
- score,
671
- seed: provenance.get(p)?.seed ?? "",
672
- hopDepth: provenance.get(p)?.hopDepth ?? 0,
673
- decayedWeight: provenance.get(p)?.decayedWeight ?? 0,
674
- graphType: provenance.get(p)?.graphType ?? "entity",
675
- edgeConfidence: provenance.get(p)?.edgeConfidence ?? 1,
676
- }))
677
- .sort((a, b) => b.score - a.score);
693
+ return finalizeScores();
678
694
  } catch (err) {
679
695
  const { log } = await import("./logger.js");
680
696
  log.warn(`[graph] spreadingActivation error: ${err}`);
@@ -725,11 +741,13 @@ export function clampPageRankIterations(raw: unknown): number {
725
741
  export function applyPageRankRefinement(
726
742
  scores: Map<string, number>,
727
743
  adj: Map<string, GraphEdge[]>,
728
- opts: { iterations: number; floor: number; damping: number }
744
+ opts: { iterations: number; floor: number; damping: number; deadlineAtMs?: number }
729
745
  ): void {
730
- const { iterations, floor, damping } = opts;
746
+ const { iterations, floor, damping, deadlineAtMs } = opts;
731
747
  if (iterations <= 0 || scores.size === 0) return;
732
748
  const safeDamping = Math.min(1, Math.max(0, damping));
749
+ const deadlineExpired = (): boolean =>
750
+ typeof deadlineAtMs === "number" && Date.now() >= deadlineAtMs;
733
751
 
734
752
  // Pre-compute confidence-weighted out-edge totals for normalization.
735
753
  // Done once per refinement, not per iteration, since adjacency is
@@ -748,6 +766,7 @@ export function applyPageRankRefinement(
748
766
  };
749
767
  const outboundTotal = new Map<string, number>();
750
768
  for (const [node, edges] of adj.entries()) {
769
+ if (deadlineExpired()) return;
751
770
  if (!scores.has(node)) continue; // only candidate nodes redistribute
752
771
  let sum = 0;
753
772
  for (const edge of edges) {
@@ -758,6 +777,7 @@ export function applyPageRankRefinement(
758
777
  }
759
778
 
760
779
  for (let i = 0; i < iterations; i += 1) {
780
+ if (deadlineExpired()) return;
761
781
  const next = new Map<string, number>();
762
782
  // Teleport / damping floor: every node retains `(1 - damping) * score`
763
783
  // of its current activation so dangling nodes do not bleed to zero.
@@ -776,7 +796,9 @@ export function applyPageRankRefinement(
776
796
  next.set(node, (next.get(node) ?? 0) + safeDamping * score);
777
797
  continue;
778
798
  }
779
- for (const edge of outEdges) {
799
+ for (let edgeIndex = 0; edgeIndex < outEdges.length; edgeIndex += 1) {
800
+ if ((edgeIndex & 1023) === 0 && deadlineExpired()) return;
801
+ const edge = outEdges[edgeIndex];
780
802
  if (!eligible(edge, node)) continue;
781
803
  const conf = readEdgeConfidence(edge);
782
804
  const neighbor = edge.to === node ? edge.from : edge.to;