@remnic/core 9.69.61 → 9.69.64
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/access-admin-ops-surface.js +27 -27
- package/dist/access-authorization-probe.js +28 -28
- package/dist/access-boundary.js +27 -27
- package/dist/access-cli.js +68 -68
- package/dist/access-extraction-force-flush.js +27 -27
- package/dist/access-http-briefing.js +28 -28
- package/dist/access-http-query.js +29 -29
- package/dist/access-http.js +44 -44
- package/dist/access-identity-continuity-surface.js +27 -27
- package/dist/access-lcm-surface.js +27 -27
- package/dist/access-mcp-output-schemas.d.ts.map +1 -1
- package/dist/access-mcp-output-schemas.js +1 -1
- package/dist/access-mcp.js +37 -37
- package/dist/access-namespace-preflight.js +27 -27
- package/dist/access-observe-write-surface.js +27 -27
- package/dist/access-operations-batch.js +28 -28
- package/dist/access-operations.js +35 -35
- package/dist/access-recall-concurrency.js +27 -27
- package/dist/access-recall-response.d.ts.map +1 -1
- package/dist/access-recall-response.js +27 -27
- package/dist/access-recall-surface.js +27 -27
- package/dist/access-service.d.ts +4 -0
- package/dist/access-service.d.ts.map +1 -1
- package/dist/access-service.js +27 -27
- package/dist/access-surface-catalog.js +29 -29
- package/dist/active-recall.js +9 -9
- package/dist/briefing.js +10 -10
- package/dist/buffer.js +5 -5
- package/dist/causal-consolidation.js +11 -11
- package/dist/{chunk-52VL6LCY.js → chunk-2VH2JWYN.js} +2 -2
- package/dist/{chunk-WD32KTVI.js → chunk-2ZVSQPAA.js} +32 -16
- package/dist/chunk-2ZVSQPAA.js.map +1 -0
- package/dist/{chunk-G4WZDQ32.js → chunk-3NG5OOCX.js} +2 -2
- package/dist/{chunk-KG2OOAFF.js → chunk-3NTVCMDD.js} +2 -2
- package/dist/{chunk-7OC3Q5X4.js → chunk-3S4R2HA5.js} +2 -2
- package/dist/{chunk-NORFVW2H.js → chunk-4RWPQZYD.js} +2 -2
- package/dist/{chunk-B5GMOOY2.js → chunk-5AABWM72.js} +3 -3
- package/dist/{chunk-KRPMISWO.js → chunk-5C765XYU.js} +2 -2
- package/dist/{chunk-WLZYGLJ4.js → chunk-5MA7ZLPD.js} +5 -7
- package/dist/chunk-5MA7ZLPD.js.map +1 -0
- package/dist/{chunk-XOLVM3AT.js → chunk-5WXJE5TV.js} +2 -2
- package/dist/{chunk-2C5Z7CWB.js → chunk-6YTL3JYX.js} +2 -2
- package/dist/{chunk-F4PEETM4.js → chunk-755VRFBS.js} +2 -2
- package/dist/{chunk-HNOKNHFO.js → chunk-7L747CFR.js} +2 -2
- package/dist/{chunk-WPGR3RW4.js → chunk-7VO6E5UD.js} +2 -2
- package/dist/{chunk-LMLPLIT4.js → chunk-AQ6IBVFQ.js} +2 -2
- package/dist/{chunk-XN4CPSCN.js → chunk-B2JE56NB.js} +2 -2
- package/dist/{chunk-L4WBADKG.js → chunk-B5EZWPSM.js} +2 -2
- package/dist/{chunk-EIU642TN.js → chunk-BE4GMH45.js} +2 -2
- package/dist/{chunk-NLE6TSLG.js → chunk-BLVHDY2U.js} +5 -7
- package/dist/chunk-BLVHDY2U.js.map +1 -0
- package/dist/{chunk-JTTLH646.js → chunk-C2W6JKQQ.js} +2 -2
- package/dist/{chunk-FG5QU7A2.js → chunk-CFCAFAJC.js} +2 -2
- package/dist/{chunk-ZJ6MEVTX.js → chunk-CZM4MHZF.js} +37 -32
- package/dist/chunk-CZM4MHZF.js.map +1 -0
- package/dist/{chunk-M4PHCEPU.js → chunk-DDY7LJMP.js} +2 -2
- package/dist/{chunk-2L5DIVDU.js → chunk-DT33WNG2.js} +4 -4
- package/dist/{chunk-SZAWZPY6.js → chunk-E7SKAUEW.js} +7 -7
- package/dist/{chunk-NRVGODSE.js → chunk-EK4MOTHM.js} +6 -6
- package/dist/{chunk-QEGKMZNQ.js → chunk-FO5NDQZH.js} +3 -3
- package/dist/chunk-G26YNEOC.js +37 -0
- package/dist/chunk-G26YNEOC.js.map +1 -0
- package/dist/{chunk-GMHWCPLP.js → chunk-GZLSIPYU.js} +2 -2
- package/dist/{chunk-ZFUPRODV.js → chunk-HPWJ5B7D.js} +39 -3
- package/dist/chunk-HPWJ5B7D.js.map +1 -0
- package/dist/{chunk-SEHKTE4F.js → chunk-I4IKTNZP.js} +3 -3
- package/dist/{chunk-W65KK3X6.js → chunk-II4TIQCU.js} +2 -2
- package/dist/{chunk-FWVDTLRK.js → chunk-IJQEVNEF.js} +1 -1
- package/dist/{chunk-KXJ7XYUT.js → chunk-IOUQUXHO.js} +2 -2
- package/dist/{chunk-V3JM4XEI.js → chunk-ITLFUILH.js} +2 -2
- package/dist/{chunk-ZNFHWNDM.js → chunk-IULYVB5X.js} +5 -5
- package/dist/{chunk-Z3YQOV45.js → chunk-JLU7WK22.js} +2 -2
- package/dist/{chunk-NRTVZSPO.js → chunk-L4HT5QZ5.js} +2 -2
- package/dist/{chunk-5QZHDS4X.js → chunk-LI4IWBLN.js} +2 -2
- package/dist/{chunk-LY3SDHMI.js → chunk-LQ4OSM5V.js} +3 -3
- package/dist/{chunk-2OUSWDY6.js → chunk-M3TMVHQ2.js} +2 -2
- package/dist/{chunk-IDRSTHVR.js → chunk-MB3HMRXM.js} +2 -2
- package/dist/{chunk-4P3ZBL5E.js → chunk-MUGLJAZ3.js} +2 -2
- package/dist/{chunk-ZOASYXBS.js → chunk-MY2PLXSM.js} +6 -2
- package/dist/chunk-MY2PLXSM.js.map +1 -0
- package/dist/{chunk-4IFSMCRS.js → chunk-NYN4ZMPL.js} +2 -2
- package/dist/{chunk-K7S2IMGJ.js → chunk-O7QF3TCV.js} +322 -25
- package/dist/{chunk-K7S2IMGJ.js.map → chunk-O7QF3TCV.js.map} +1 -1
- package/dist/{chunk-KV44HTMJ.js → chunk-OBD77TYC.js} +2 -2
- package/dist/{chunk-GYNCJSQ7.js → chunk-OCEIHKH5.js} +3 -3
- package/dist/{chunk-DC5CTLCZ.js → chunk-PSGRZFBH.js} +10 -10
- package/dist/{chunk-FQGY7XB2.js → chunk-S52VKUUW.js} +2 -2
- package/dist/{chunk-D7MAYFQP.js → chunk-S7LWX2PQ.js} +2 -2
- package/dist/{chunk-TYP7GYRI.js → chunk-SB4ZWVKI.js} +2 -2
- package/dist/{chunk-KVO2BTA4.js → chunk-T2YX56QI.js} +2 -2
- package/dist/{chunk-G6NQ3L4T.js → chunk-T3FMRLWQ.js} +2 -2
- package/dist/{chunk-CDQO5DCD.js → chunk-TO3G56GM.js} +2 -2
- package/dist/{chunk-WEQZ5OYK.js → chunk-U6GKPSZH.js} +2 -2
- package/dist/{chunk-FM5HN7LL.js → chunk-WQC54W25.js} +9 -17
- package/dist/chunk-WQC54W25.js.map +1 -0
- package/dist/{chunk-XDH5MO6L.js → chunk-WZ33AIOP.js} +2 -2
- package/dist/{chunk-TM7S5UHD.js → chunk-XDIFADVN.js} +2 -2
- package/dist/{chunk-SVWYZXIL.js → chunk-XDOXR3U6.js} +2 -2
- package/dist/{chunk-VKDWYDFQ.js → chunk-XGLBVYEA.js} +5 -5
- package/dist/{chunk-5Y7VCE3A.js → chunk-XJNSOI23.js} +2 -2
- package/dist/{chunk-LDP6LBDS.js → chunk-Y3AFTYK4.js} +2 -2
- package/dist/{chunk-YL2W6NUG.js → chunk-YGJMMMPN.js} +4 -4
- package/dist/{chunk-KIOXEHWF.js → chunk-YIDBKTZJ.js} +2 -2
- package/dist/{chunk-F5ZCNJ3V.js → chunk-ZPPWW576.js} +2 -2
- package/dist/{chunk-TQAXUYUE.js → chunk-ZSXSMVW6.js} +2 -2
- package/dist/{chunk-6RD7MSIO.js → chunk-ZUT4FK5W.js} +2 -2
- package/dist/cli.js +54 -54
- package/dist/coding/export-okf-codegraph.js +9 -9
- package/dist/compounding/engine.js +10 -10
- package/dist/config.js +9 -9
- package/dist/connectors/codex-materialize-runner.js +10 -10
- package/dist/connectors/index.js +10 -10
- package/dist/convergence-refresh.js +12 -12
- package/dist/corpus-watermark.js +12 -12
- package/dist/entity-retrieval.js +10 -10
- package/dist/external-wiki-access.js +28 -28
- package/dist/extraction.js +16 -16
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -68
- package/dist/location/index.js +12 -12
- package/dist/maintenance/memory-governance.js +10 -10
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -10
- package/dist/maintenance/rebuild-memory-projection.js +11 -11
- package/dist/memory-browse-operations.js +28 -28
- package/dist/memory-browse.js +5 -5
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +1 -1
- package/dist/namespaces/migrate.js +11 -11
- package/dist/namespaces/search.js +8 -8
- package/dist/namespaces/storage.js +10 -10
- package/dist/operator-doctor-corpus.js +13 -13
- package/dist/operator-toolkit.js +18 -18
- package/dist/orchestration/maintenance.js +18 -18
- package/dist/orchestration/recall-internal.d.ts.map +1 -1
- package/dist/orchestrator.js +68 -68
- package/dist/profiling.d.ts.map +1 -1
- package/dist/profiling.js +1 -1
- package/dist/qmd.js +2 -2
- package/dist/recall-composition-decision.d.ts +9 -0
- package/dist/recall-composition-decision.d.ts.map +1 -1
- package/dist/recall-composition-decision.js +2 -1
- package/dist/recall-navigation-operations.js +28 -28
- package/dist/recall-navigation.js +27 -27
- package/dist/recall-state-view-admission.js +5 -5
- package/dist/recall-why-service.d.ts.map +1 -1
- package/dist/recall-why-service.js +5 -5
- package/dist/resume-bundles.js +9 -9
- package/dist/search/factory.js +7 -7
- package/dist/search/index.js +7 -7
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/orama-backend.js +2 -2
- package/dist/search/port.d.ts +9 -0
- package/dist/search/port.d.ts.map +1 -1
- package/dist/search/port.js +7 -1
- package/dist/search/remote-backend.js +2 -2
- package/dist/security/audit-memory-cli.d.ts.map +1 -1
- package/dist/security/audit-memory.d.ts +10 -0
- package/dist/security/audit-memory.d.ts.map +1 -1
- package/dist/security/injection-screen.d.ts +1 -2
- package/dist/security/injection-screen.d.ts.map +1 -1
- package/dist/semantic-consolidation.js +11 -11
- package/dist/semantic-rule-promotion.js +10 -10
- package/dist/semantic-rule-verifier.js +10 -10
- package/dist/source-agent-qualifier.js +13 -13
- package/dist/standup/build.js +10 -10
- package/dist/storage.js +9 -9
- package/dist/summarizer.js +2 -2
- package/dist/support-passport/index.js +30 -30
- package/dist/transfer/export-okf.js +10 -10
- package/dist/verified-recall.js +10 -10
- package/dist/who-knows-cli.js +11 -11
- package/dist/who-knows.js +10 -10
- package/package.json +12 -2
- package/src/access-mcp-output-schemas.ts +4 -0
- package/src/access-recall-response.ts +26 -1
- package/src/access-service.ts +1 -0
- package/src/index.ts +1 -1
- package/src/model-registry.test.ts +22 -1
- package/src/model-registry.ts +7 -6
- package/src/orchestration/recall-internal.ts +8 -5
- package/src/profiling.test.ts +20 -1
- package/src/profiling.ts +7 -7
- package/src/recall-composition-decision.test.ts +75 -0
- package/src/recall-composition-decision.ts +52 -3
- package/src/recall-retrieval-failure.test.ts +245 -0
- package/src/recall-why-service.ts +8 -24
- package/src/search/port.ts +32 -0
- package/src/security/audit-memory-cli.test.ts +90 -0
- package/src/security/audit-memory-cli.ts +7 -0
- package/src/security/audit-memory.ts +14 -0
- package/src/security/injection-screen.test.ts +245 -0
- package/src/security/injection-screen.ts +341 -11
- package/dist/chunk-FM5HN7LL.js.map +0 -1
- package/dist/chunk-NLE6TSLG.js.map +0 -1
- package/dist/chunk-U2VDCFXW.js +0 -19
- package/dist/chunk-U2VDCFXW.js.map +0 -1
- package/dist/chunk-WD32KTVI.js.map +0 -1
- package/dist/chunk-WLZYGLJ4.js.map +0 -1
- package/dist/chunk-ZFUPRODV.js.map +0 -1
- package/dist/chunk-ZJ6MEVTX.js.map +0 -1
- package/dist/chunk-ZOASYXBS.js.map +0 -1
- /package/dist/{chunk-52VL6LCY.js.map → chunk-2VH2JWYN.js.map} +0 -0
- /package/dist/{chunk-G4WZDQ32.js.map → chunk-3NG5OOCX.js.map} +0 -0
- /package/dist/{chunk-KG2OOAFF.js.map → chunk-3NTVCMDD.js.map} +0 -0
- /package/dist/{chunk-7OC3Q5X4.js.map → chunk-3S4R2HA5.js.map} +0 -0
- /package/dist/{chunk-NORFVW2H.js.map → chunk-4RWPQZYD.js.map} +0 -0
- /package/dist/{chunk-B5GMOOY2.js.map → chunk-5AABWM72.js.map} +0 -0
- /package/dist/{chunk-KRPMISWO.js.map → chunk-5C765XYU.js.map} +0 -0
- /package/dist/{chunk-XOLVM3AT.js.map → chunk-5WXJE5TV.js.map} +0 -0
- /package/dist/{chunk-2C5Z7CWB.js.map → chunk-6YTL3JYX.js.map} +0 -0
- /package/dist/{chunk-F4PEETM4.js.map → chunk-755VRFBS.js.map} +0 -0
- /package/dist/{chunk-HNOKNHFO.js.map → chunk-7L747CFR.js.map} +0 -0
- /package/dist/{chunk-WPGR3RW4.js.map → chunk-7VO6E5UD.js.map} +0 -0
- /package/dist/{chunk-LMLPLIT4.js.map → chunk-AQ6IBVFQ.js.map} +0 -0
- /package/dist/{chunk-XN4CPSCN.js.map → chunk-B2JE56NB.js.map} +0 -0
- /package/dist/{chunk-L4WBADKG.js.map → chunk-B5EZWPSM.js.map} +0 -0
- /package/dist/{chunk-EIU642TN.js.map → chunk-BE4GMH45.js.map} +0 -0
- /package/dist/{chunk-JTTLH646.js.map → chunk-C2W6JKQQ.js.map} +0 -0
- /package/dist/{chunk-FG5QU7A2.js.map → chunk-CFCAFAJC.js.map} +0 -0
- /package/dist/{chunk-M4PHCEPU.js.map → chunk-DDY7LJMP.js.map} +0 -0
- /package/dist/{chunk-2L5DIVDU.js.map → chunk-DT33WNG2.js.map} +0 -0
- /package/dist/{chunk-SZAWZPY6.js.map → chunk-E7SKAUEW.js.map} +0 -0
- /package/dist/{chunk-NRVGODSE.js.map → chunk-EK4MOTHM.js.map} +0 -0
- /package/dist/{chunk-QEGKMZNQ.js.map → chunk-FO5NDQZH.js.map} +0 -0
- /package/dist/{chunk-GMHWCPLP.js.map → chunk-GZLSIPYU.js.map} +0 -0
- /package/dist/{chunk-SEHKTE4F.js.map → chunk-I4IKTNZP.js.map} +0 -0
- /package/dist/{chunk-W65KK3X6.js.map → chunk-II4TIQCU.js.map} +0 -0
- /package/dist/{chunk-FWVDTLRK.js.map → chunk-IJQEVNEF.js.map} +0 -0
- /package/dist/{chunk-KXJ7XYUT.js.map → chunk-IOUQUXHO.js.map} +0 -0
- /package/dist/{chunk-V3JM4XEI.js.map → chunk-ITLFUILH.js.map} +0 -0
- /package/dist/{chunk-ZNFHWNDM.js.map → chunk-IULYVB5X.js.map} +0 -0
- /package/dist/{chunk-Z3YQOV45.js.map → chunk-JLU7WK22.js.map} +0 -0
- /package/dist/{chunk-NRTVZSPO.js.map → chunk-L4HT5QZ5.js.map} +0 -0
- /package/dist/{chunk-5QZHDS4X.js.map → chunk-LI4IWBLN.js.map} +0 -0
- /package/dist/{chunk-LY3SDHMI.js.map → chunk-LQ4OSM5V.js.map} +0 -0
- /package/dist/{chunk-2OUSWDY6.js.map → chunk-M3TMVHQ2.js.map} +0 -0
- /package/dist/{chunk-IDRSTHVR.js.map → chunk-MB3HMRXM.js.map} +0 -0
- /package/dist/{chunk-4P3ZBL5E.js.map → chunk-MUGLJAZ3.js.map} +0 -0
- /package/dist/{chunk-4IFSMCRS.js.map → chunk-NYN4ZMPL.js.map} +0 -0
- /package/dist/{chunk-KV44HTMJ.js.map → chunk-OBD77TYC.js.map} +0 -0
- /package/dist/{chunk-GYNCJSQ7.js.map → chunk-OCEIHKH5.js.map} +0 -0
- /package/dist/{chunk-DC5CTLCZ.js.map → chunk-PSGRZFBH.js.map} +0 -0
- /package/dist/{chunk-FQGY7XB2.js.map → chunk-S52VKUUW.js.map} +0 -0
- /package/dist/{chunk-D7MAYFQP.js.map → chunk-S7LWX2PQ.js.map} +0 -0
- /package/dist/{chunk-TYP7GYRI.js.map → chunk-SB4ZWVKI.js.map} +0 -0
- /package/dist/{chunk-KVO2BTA4.js.map → chunk-T2YX56QI.js.map} +0 -0
- /package/dist/{chunk-G6NQ3L4T.js.map → chunk-T3FMRLWQ.js.map} +0 -0
- /package/dist/{chunk-CDQO5DCD.js.map → chunk-TO3G56GM.js.map} +0 -0
- /package/dist/{chunk-WEQZ5OYK.js.map → chunk-U6GKPSZH.js.map} +0 -0
- /package/dist/{chunk-XDH5MO6L.js.map → chunk-WZ33AIOP.js.map} +0 -0
- /package/dist/{chunk-TM7S5UHD.js.map → chunk-XDIFADVN.js.map} +0 -0
- /package/dist/{chunk-SVWYZXIL.js.map → chunk-XDOXR3U6.js.map} +0 -0
- /package/dist/{chunk-VKDWYDFQ.js.map → chunk-XGLBVYEA.js.map} +0 -0
- /package/dist/{chunk-5Y7VCE3A.js.map → chunk-XJNSOI23.js.map} +0 -0
- /package/dist/{chunk-LDP6LBDS.js.map → chunk-Y3AFTYK4.js.map} +0 -0
- /package/dist/{chunk-YL2W6NUG.js.map → chunk-YGJMMMPN.js.map} +0 -0
- /package/dist/{chunk-KIOXEHWF.js.map → chunk-YIDBKTZJ.js.map} +0 -0
- /package/dist/{chunk-F5ZCNJ3V.js.map → chunk-ZPPWW576.js.map} +0 -0
- /package/dist/{chunk-TQAXUYUE.js.map → chunk-ZSXSMVW6.js.map} +0 -0
- /package/dist/{chunk-6RD7MSIO.js.map → chunk-ZUT4FK5W.js.map} +0 -0
package/src/model-registry.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { log } from "./logger.js";
|
|
7
7
|
import fs from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
9
|
|
|
10
10
|
export interface ModelCapabilities {
|
|
11
11
|
modelId: string;
|
|
@@ -116,11 +116,7 @@ export class ModelRegistry {
|
|
|
116
116
|
private readonly CACHE_TTL_DAYS = 7;
|
|
117
117
|
|
|
118
118
|
constructor(memoryDir: string) {
|
|
119
|
-
|
|
120
|
-
if (!fs.existsSync(registryDir)) {
|
|
121
|
-
fs.mkdirSync(registryDir, { recursive: true });
|
|
122
|
-
}
|
|
123
|
-
this.registryPath = join(registryDir, "model-capabilities.json");
|
|
119
|
+
this.registryPath = join(memoryDir, ".registry", "model-capabilities.json");
|
|
124
120
|
this.data = this.loadRegistry();
|
|
125
121
|
}
|
|
126
122
|
|
|
@@ -140,6 +136,11 @@ export class ModelRegistry {
|
|
|
140
136
|
|
|
141
137
|
private saveRegistry(): void {
|
|
142
138
|
try {
|
|
139
|
+
// Created on first save, never at construction: building a registry
|
|
140
|
+
// happens on every plugin registration, including read-only discovery
|
|
141
|
+
// passes, and must not touch the operator's memory dir.
|
|
142
|
+
const registryDir = dirname(this.registryPath);
|
|
143
|
+
if (!fs.existsSync(registryDir)) fs.mkdirSync(registryDir, { recursive: true });
|
|
143
144
|
fs.writeFileSync(this.registryPath, JSON.stringify(this.data, null, 2));
|
|
144
145
|
} catch (err) {
|
|
145
146
|
log.warn(`ModelRegistry: failed to save registry: ${err}`);
|
|
@@ -130,11 +130,9 @@ export class RecallInternalCoordinator {
|
|
|
130
130
|
lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.deps.config),
|
|
131
131
|
): Promise<string> {
|
|
132
132
|
const recallStart = Date.now();
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
136
|
-
// "no matches" from "backend could not answer" (CLAUDE.md rule 34).
|
|
137
|
-
const backendDegradations: SearchDegradation[] = options.degradationSink ?? [];
|
|
133
|
+
// Per-recall QMD degradations (#1536/#3082). Local array so overlapping
|
|
134
|
+
// recalls that share a sink cannot mark each other failed.
|
|
135
|
+
const backendDegradations: SearchDegradation[] = [];
|
|
138
136
|
// Issue #680 — historical recall. Parse `options.asOf` once at the
|
|
139
137
|
// top of the recall so each boost-pass uses identical filter logic.
|
|
140
138
|
// Invalid values are rejected at input boundaries (CLI / HTTP / MCP)
|
|
@@ -4964,7 +4962,12 @@ export class RecallInternalCoordinator {
|
|
|
4964
4962
|
compactContext: compactRecallContextFromBuckets(sectionBuckets),
|
|
4965
4963
|
footer: curiosityFooter,
|
|
4966
4964
|
maxChars: recallBudgetChars,
|
|
4965
|
+
backendDegradations,
|
|
4966
|
+
qmdExpected: this.deps.config.searchBackend !== "noop" && (this.deps.config.searchBackend !== "qmd" || resolveQmdCapabilities(this.deps.config).qmd),
|
|
4967
4967
|
});
|
|
4968
|
+
if (options.degradationSink) {
|
|
4969
|
+
for (const d of backendDegradations) options.degradationSink.push(d);
|
|
4970
|
+
}
|
|
4968
4971
|
notifyContextComposition(options.onContextComposition, composition, (err) => {
|
|
4969
4972
|
log.warn("recall: context composition observer failed open", err);
|
|
4970
4973
|
});
|
package/src/profiling.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import test, { describe } from "node:test";
|
|
@@ -283,4 +283,23 @@ describe("ProfilingCollector", () => {
|
|
|
283
283
|
cleanup(dir);
|
|
284
284
|
}
|
|
285
285
|
});
|
|
286
|
+
|
|
287
|
+
test("construction never creates the storage dir; the first persist does", () => {
|
|
288
|
+
const dir = tempDir();
|
|
289
|
+
const storageDir = join(dir, "profiling");
|
|
290
|
+
try {
|
|
291
|
+
const collector = new ProfilingCollector({ enabled: true, storageDir, maxTraces: 10 });
|
|
292
|
+
// Constructing a collector happens on every plugin registration, including
|
|
293
|
+
// read-only discovery passes, so it must not touch the memory dir.
|
|
294
|
+
assert.equal(existsSync(storageDir), false, "no dir before a trace is written");
|
|
295
|
+
collector.startTrace("recall", "test-session");
|
|
296
|
+
collector.startSpan("qmd-search");
|
|
297
|
+
collector.endSpan("qmd-search");
|
|
298
|
+
collector.endTrace();
|
|
299
|
+
assert.equal(existsSync(storageDir), true, "the first persisted trace creates it");
|
|
300
|
+
assert.equal(readdirSync(storageDir).length, 1);
|
|
301
|
+
} finally {
|
|
302
|
+
cleanup(dir);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
286
305
|
});
|
package/src/profiling.ts
CHANGED
|
@@ -118,13 +118,6 @@ export class ProfilingCollector {
|
|
|
118
118
|
this.enabled = config.enabled;
|
|
119
119
|
this.storageDir = config.storageDir;
|
|
120
120
|
this.maxTraces = Math.max(0, config.maxTraces);
|
|
121
|
-
|
|
122
|
-
if (this.enabled) {
|
|
123
|
-
if (!existsSync(this.storageDir)) {
|
|
124
|
-
mkdirSync(this.storageDir, { recursive: true });
|
|
125
|
-
log.debug(`profiling: created storage dir ${this.storageDir}`);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
121
|
}
|
|
129
122
|
|
|
130
123
|
get isEnabled(): boolean {
|
|
@@ -347,6 +340,13 @@ export class ProfilingCollector {
|
|
|
347
340
|
const filename = `${trace.kind}-${trace.traceId}.jsonl`;
|
|
348
341
|
const filepath = join(this.storageDir, filename);
|
|
349
342
|
try {
|
|
343
|
+
// Created on first persist, never at construction: constructing a
|
|
344
|
+
// collector (plugin registration, a discovery pass, CLI metadata) must
|
|
345
|
+
// not touch the operator's memory dir when no trace is ever written.
|
|
346
|
+
if (!existsSync(this.storageDir)) {
|
|
347
|
+
mkdirSync(this.storageDir, { recursive: true });
|
|
348
|
+
log.debug(`profiling: created storage dir ${this.storageDir}`);
|
|
349
|
+
}
|
|
350
350
|
writeFileSync(filepath, JSON.stringify(trace) + "\n", "utf-8");
|
|
351
351
|
log.debug(`profiling: persisted ${filename}`);
|
|
352
352
|
} catch (err) {
|
|
@@ -30,6 +30,81 @@ test("#2972 empty recall stays marker-free", () => {
|
|
|
30
30
|
assert.equal("degradation" in composition, false);
|
|
31
31
|
});
|
|
32
32
|
|
|
33
|
+
test("#3082 empty context plus daemon_timeout is missing, not a genuine empty", () => {
|
|
34
|
+
const { composition, context } = decideRecallContextComposition({
|
|
35
|
+
context: "",
|
|
36
|
+
maxChars: 512,
|
|
37
|
+
backendDegradations: [{ backend: "qmd", code: "daemon_timeout", detail: "no response in 8000ms" }],
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.equal(composition.degradation?.state, "missing");
|
|
41
|
+
assert.equal(composition.degradation?.reason, "backend-unavailable");
|
|
42
|
+
assert.match(composition.degradation?.detail ?? "", /qmd:daemon_timeout/);
|
|
43
|
+
assert.match(context, /memory context unavailable/i);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("#3082 vector_tier_unavailable on empty context is not an outage", () => {
|
|
47
|
+
const { composition, context } = decideRecallContextComposition({
|
|
48
|
+
context: "",
|
|
49
|
+
maxChars: 512,
|
|
50
|
+
backendDegradations: [{ backend: "qmd", code: "vector_tier_unavailable" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
assert.equal(context, "");
|
|
54
|
+
assert.equal("degradation" in composition, false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("#3082 daemon_timeout with recalled context keeps results and marks degraded", () => {
|
|
58
|
+
const { composition, truncated } = decideRecallContextComposition({
|
|
59
|
+
context: "A remembered deployment decision.",
|
|
60
|
+
maxChars: 512,
|
|
61
|
+
backendDegradations: [{ backend: "qmd", code: "daemon_timeout", detail: "cold-tier timed out" }],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
assert.equal(composition.context, "A remembered deployment decision.");
|
|
65
|
+
assert.equal(composition.degradation?.state, "degraded");
|
|
66
|
+
assert.equal(composition.degradation?.reason, "backend-unavailable");
|
|
67
|
+
assert.equal(truncated, false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("#3082 QMD-disabled backend_unavailable is not an outage", () => {
|
|
71
|
+
const { composition, context } = decideRecallContextComposition({
|
|
72
|
+
context: "",
|
|
73
|
+
maxChars: 512,
|
|
74
|
+
qmdExpected: false,
|
|
75
|
+
backendDegradations: [{ backend: "qmd", code: "backend_unavailable", detail: "qmd disabled" }],
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(context, "");
|
|
79
|
+
assert.equal("degradation" in composition, false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("#3082 non-QMD backend_unavailable is still an outage when QMD is off", () => {
|
|
83
|
+
const { composition, context } = decideRecallContextComposition({
|
|
84
|
+
context: "",
|
|
85
|
+
maxChars: 512,
|
|
86
|
+
qmdExpected: false,
|
|
87
|
+
backendDegradations: [{ backend: "remote", code: "backend_unavailable" }],
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
assert.equal(composition.degradation?.state, "missing");
|
|
91
|
+
assert.equal(composition.degradation?.reason, "backend-unavailable");
|
|
92
|
+
assert.match(context, /memory context unavailable/i);
|
|
93
|
+
});
|
|
94
|
+
test("#3082 timeout plus budget clip still reports backend-unavailable", () => {
|
|
95
|
+
const longContext = "A remembered deployment decision. ".repeat(40).trim();
|
|
96
|
+
const { composition, truncated } = decideRecallContextComposition({
|
|
97
|
+
context: longContext,
|
|
98
|
+
maxChars: 80,
|
|
99
|
+
backendDegradations: [{ backend: "qmd", code: "daemon_timeout", detail: "no response in 8000ms" }],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.equal(composition.degradation?.reason, "backend-unavailable");
|
|
103
|
+
assert.match(composition.degradation?.detail ?? "", /qmd:daemon_timeout/);
|
|
104
|
+
assert.ok(composition.degradation?.budget, "budget accounting stays on the overlay");
|
|
105
|
+
assert.equal(truncated, true);
|
|
106
|
+
});
|
|
107
|
+
|
|
33
108
|
test("#2972 compact form from section buckets is preferred over clipping", () => {
|
|
34
109
|
const longOne = "full-form entry one with a long body ".repeat(6).trim();
|
|
35
110
|
const longTwo = "full-form entry two with a long body ".repeat(6).trim();
|
|
@@ -15,6 +15,11 @@ import {
|
|
|
15
15
|
RECALL_CONTEXT_SEPARATOR,
|
|
16
16
|
type RecallContextComposition,
|
|
17
17
|
} from "./recall-context-composition.js";
|
|
18
|
+
import {
|
|
19
|
+
describeSearchDegradations,
|
|
20
|
+
fatalSearchDegradations,
|
|
21
|
+
type SearchDegradation,
|
|
22
|
+
} from "./search/port.js";
|
|
18
23
|
|
|
19
24
|
export type RecallCompositionChunk = string | { readonly content: string };
|
|
20
25
|
|
|
@@ -23,6 +28,14 @@ export interface DecideRecallContextCompositionInput {
|
|
|
23
28
|
compactContext?: string;
|
|
24
29
|
footer?: string;
|
|
25
30
|
maxChars: number;
|
|
31
|
+
/** Mid-recall backend degradations (#3082). Absent on the healthy path. */
|
|
32
|
+
backendDegradations?: readonly SearchDegradation[];
|
|
33
|
+
/**
|
|
34
|
+
* When false, `{ backend: "qmd", code: "backend_unavailable" }` is the
|
|
35
|
+
* QMD-disabled/noop skip marker, not an outage. Other backends still count.
|
|
36
|
+
* Default true so unit tests that pass degradations stay honest.
|
|
37
|
+
*/
|
|
38
|
+
qmdExpected?: boolean;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export interface DecidedRecallContextComposition {
|
|
@@ -65,18 +78,54 @@ export function compactRecallContextFromBuckets(
|
|
|
65
78
|
export function decideRecallContextComposition(
|
|
66
79
|
input: DecideRecallContextCompositionInput,
|
|
67
80
|
): DecidedRecallContextComposition {
|
|
68
|
-
const
|
|
81
|
+
const observed = input.backendDegradations ?? [];
|
|
82
|
+
const qmdExpected = input.qmdExpected !== false;
|
|
83
|
+
const fatal = fatalSearchDegradations(observed).filter(
|
|
84
|
+
(d) => qmdExpected || d.backend !== "qmd" || d.code !== "backend_unavailable",
|
|
85
|
+
);
|
|
86
|
+
if (fatal.length > 0 && input.context.trim().length === 0) {
|
|
87
|
+
const missing = composeMissingMemoryContext({
|
|
88
|
+
detail: describeSearchDegradations(fatal),
|
|
89
|
+
});
|
|
90
|
+
const composition = input.footer
|
|
91
|
+
? { ...missing, footer: input.footer }
|
|
92
|
+
: missing;
|
|
93
|
+
return {
|
|
94
|
+
composition,
|
|
95
|
+
context: composeRecallContext(composition),
|
|
96
|
+
truncated: false,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const bounded = boundRecallContextComposition({
|
|
69
100
|
context: input.context,
|
|
70
101
|
footer: input.footer,
|
|
71
102
|
maxChars: input.maxChars,
|
|
72
103
|
compactContext: input.compactContext,
|
|
73
104
|
});
|
|
105
|
+
if (fatal.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
composition: bounded,
|
|
108
|
+
context: composeRecallContext(bounded),
|
|
109
|
+
truncated:
|
|
110
|
+
bounded.degradation?.reason === "budget-clipped" ||
|
|
111
|
+
bounded.degradation?.reason === "budget-compacted",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const composition = {
|
|
115
|
+
...bounded,
|
|
116
|
+
degradation: {
|
|
117
|
+
state: "degraded" as const,
|
|
118
|
+
reason: "backend-unavailable" as const,
|
|
119
|
+
detail: describeSearchDegradations(fatal),
|
|
120
|
+
...(bounded.degradation?.budget ? { budget: bounded.degradation.budget } : {}),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
74
123
|
return {
|
|
75
124
|
composition,
|
|
76
125
|
context: composeRecallContext(composition),
|
|
77
126
|
truncated:
|
|
78
|
-
|
|
79
|
-
|
|
127
|
+
bounded.degradation?.reason === "budget-clipped" ||
|
|
128
|
+
bounded.degradation?.reason === "budget-compacted",
|
|
80
129
|
};
|
|
81
130
|
}
|
|
82
131
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3082: recall must distinguish a QMD daemon timeout from a genuine
|
|
3
|
+
* empty retrieval. Internally the pipeline already collected the
|
|
4
|
+
* degradation; this file asserts it is no longer flattened away by
|
|
5
|
+
* the time it reaches orchestrator.recall / EngramAccessService.recall
|
|
6
|
+
* / the MCP tool payload.
|
|
7
|
+
*
|
|
8
|
+
* Fixture data is synthetic and the store is an empty temp directory.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
|
|
17
|
+
import { EngramMcpServer } from "./access-mcp.js";
|
|
18
|
+
import { EngramAccessService } from "./access-service.js";
|
|
19
|
+
import type { EngramAccessRecallRequest, EngramAccessRecallResponse } from "./access-service.js";
|
|
20
|
+
import { parseConfig } from "./config.js";
|
|
21
|
+
import { Orchestrator } from "./orchestrator.js";
|
|
22
|
+
import {
|
|
23
|
+
MEMORY_CONTEXT_UNAVAILABLE_NOTE,
|
|
24
|
+
type RecallContextComposition,
|
|
25
|
+
} from "./recall-context-composition.js";
|
|
26
|
+
import type { SearchBackend, SearchDegradation, SearchExecutionOptions } from "./search/port.js";
|
|
27
|
+
import { DEFAULT_RECALL_DISCLOSURE } from "./types.js";
|
|
28
|
+
|
|
29
|
+
const TIMEOUT_DEGRADATION: SearchDegradation = {
|
|
30
|
+
backend: "qmd",
|
|
31
|
+
code: "daemon_timeout",
|
|
32
|
+
detail: "no response within the deadline",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const QUERY = "what writing rules apply here?";
|
|
36
|
+
|
|
37
|
+
function searchBackend(observed: { calls: number }, degrade: boolean): SearchBackend {
|
|
38
|
+
const search = (execution?: SearchExecutionOptions) => {
|
|
39
|
+
observed.calls += 1;
|
|
40
|
+
if (degrade) execution?.onDegradation?.(TIMEOUT_DEGRADATION);
|
|
41
|
+
return [];
|
|
42
|
+
};
|
|
43
|
+
const backend: Partial<SearchBackend> = {
|
|
44
|
+
async probe() {
|
|
45
|
+
return true;
|
|
46
|
+
},
|
|
47
|
+
isAvailable() {
|
|
48
|
+
return true;
|
|
49
|
+
},
|
|
50
|
+
debugStatus() {
|
|
51
|
+
return degrade ? "backend=timeout-stub" : "backend=empty-stub";
|
|
52
|
+
},
|
|
53
|
+
async search(_query, _collection, _maxResults, _options, execution) {
|
|
54
|
+
return search(execution);
|
|
55
|
+
},
|
|
56
|
+
async searchGlobal(_query, _maxResults, execution) {
|
|
57
|
+
return search(execution);
|
|
58
|
+
},
|
|
59
|
+
async bm25Search(_query, _collection, _maxResults, execution) {
|
|
60
|
+
return search(execution);
|
|
61
|
+
},
|
|
62
|
+
async vectorSearch(_query, _collection, _maxResults, execution) {
|
|
63
|
+
return search(execution);
|
|
64
|
+
},
|
|
65
|
+
async hybridSearch(_query, _collection, _maxResults, execution) {
|
|
66
|
+
return search(execution);
|
|
67
|
+
},
|
|
68
|
+
async update() {},
|
|
69
|
+
async updateCollection() {},
|
|
70
|
+
async embed() {},
|
|
71
|
+
async embedCollection() {},
|
|
72
|
+
async ensureCollection() {
|
|
73
|
+
return "skipped";
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return backend as SearchBackend;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function withOrchestrator(
|
|
80
|
+
prefix: string,
|
|
81
|
+
degrade: boolean,
|
|
82
|
+
run: (orchestrator: Orchestrator, observed: { calls: number }) => Promise<void>,
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), prefix));
|
|
85
|
+
const orchestrator = new Orchestrator(
|
|
86
|
+
parseConfig({
|
|
87
|
+
memoryDir,
|
|
88
|
+
workspaceDir: memoryDir,
|
|
89
|
+
qmdEnabled: true,
|
|
90
|
+
embeddingFallbackEnabled: false,
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
const observed = { calls: 0 };
|
|
94
|
+
// Unchecked cast with reason: `qmd` is assigned post-construction because the
|
|
95
|
+
// backend is built by the search factory, which would reach for a real daemon.
|
|
96
|
+
const withBackend = orchestrator as unknown as { qmd: SearchBackend };
|
|
97
|
+
withBackend.qmd = searchBackend(observed, degrade);
|
|
98
|
+
try {
|
|
99
|
+
await orchestrator.initialize();
|
|
100
|
+
await orchestrator.deferredReady;
|
|
101
|
+
await run(orchestrator, observed);
|
|
102
|
+
} finally {
|
|
103
|
+
await orchestrator.destroy();
|
|
104
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
test("QMD daemon_timeout mid-recall is distinguishable from genuine empty at the orchestrator", async () => {
|
|
109
|
+
await withOrchestrator("remnic-recall-timeout-", true, async (orchestrator, observed) => {
|
|
110
|
+
let composition: RecallContextComposition | undefined;
|
|
111
|
+
const context = await orchestrator.recall(QUERY, "timeout-session", {
|
|
112
|
+
onContextComposition: (value) => {
|
|
113
|
+
composition = value;
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
assert.ok(observed.calls > 0, "recall must consult the backend");
|
|
118
|
+
assert.equal(composition?.degradation?.state, "missing");
|
|
119
|
+
assert.equal(composition?.degradation?.reason, "backend-unavailable");
|
|
120
|
+
assert.match(composition?.degradation?.detail ?? "", /qmd:daemon_timeout/);
|
|
121
|
+
assert.equal(context, MEMORY_CONTEXT_UNAVAILABLE_NOTE);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("genuine empty retrieval stays marker-free at the orchestrator", async () => {
|
|
126
|
+
await withOrchestrator("remnic-recall-empty-", false, async (orchestrator, observed) => {
|
|
127
|
+
let composition: RecallContextComposition | undefined;
|
|
128
|
+
const context = await orchestrator.recall(QUERY, "empty-session", {
|
|
129
|
+
onContextComposition: (value) => {
|
|
130
|
+
composition = value;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
assert.ok(observed.calls > 0, "recall must consult the backend");
|
|
135
|
+
assert.equal(composition?.degradation, undefined);
|
|
136
|
+
assert.equal(context.includes("Memory context unavailable"), false);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("access recall surfaces retrievalFailure on daemon timeout and omits it on genuine empty", async () => {
|
|
141
|
+
await withOrchestrator("remnic-recall-access-timeout-", true, async (timedOut, observed) => {
|
|
142
|
+
const failed = await new EngramAccessService(timedOut).recall({
|
|
143
|
+
query: QUERY,
|
|
144
|
+
sessionKey: "access-timeout",
|
|
145
|
+
});
|
|
146
|
+
assert.ok(observed.calls > 0, "timeout recall must consult the backend");
|
|
147
|
+
assert.equal(failed.retrievalFailure?.reason, "backend_unavailable");
|
|
148
|
+
assert.match(failed.retrievalFailure?.detail ?? "", /qmd:daemon_timeout/);
|
|
149
|
+
assert.equal(failed.contextComposition?.degradation?.state, "missing");
|
|
150
|
+
assert.match(failed.context, /memory context unavailable/i);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await withOrchestrator("remnic-recall-access-empty-", false, async (empty, observed) => {
|
|
154
|
+
const okEmpty = await new EngramAccessService(empty).recall({
|
|
155
|
+
query: QUERY,
|
|
156
|
+
sessionKey: "access-empty",
|
|
157
|
+
});
|
|
158
|
+
assert.ok(observed.calls > 0, "empty recall must consult the backend");
|
|
159
|
+
assert.equal(okEmpty.retrievalFailure, undefined);
|
|
160
|
+
assert.equal(okEmpty.contextComposition?.degradation, undefined);
|
|
161
|
+
assert.equal(okEmpty.context.includes("Memory context unavailable"), false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a reused degradationSink cannot mark a later healthy empty recall as failed", async () => {
|
|
166
|
+
await withOrchestrator("remnic-recall-reused-sink-", true, async (orchestrator, observed) => {
|
|
167
|
+
const sink: SearchDegradation[] = [];
|
|
168
|
+
await orchestrator.recall(QUERY, "first-timeout", { degradationSink: sink });
|
|
169
|
+
assert.ok(sink.length > 0, "the first recall must record the timeout");
|
|
170
|
+
|
|
171
|
+
const withBackend = orchestrator as unknown as { qmd: SearchBackend };
|
|
172
|
+
withBackend.qmd = searchBackend(observed, false);
|
|
173
|
+
let composition: RecallContextComposition | undefined;
|
|
174
|
+
await orchestrator.recall(QUERY, "second-empty", {
|
|
175
|
+
degradationSink: sink,
|
|
176
|
+
onContextComposition: (value) => {
|
|
177
|
+
composition = value;
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
assert.equal(composition?.degradation, undefined);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("MCP recall payload keeps retrievalFailure so a tool caller can branch on it", async () => {
|
|
185
|
+
const timeoutResponse: EngramAccessRecallResponse = {
|
|
186
|
+
query: QUERY,
|
|
187
|
+
namespace: "default",
|
|
188
|
+
context: MEMORY_CONTEXT_UNAVAILABLE_NOTE,
|
|
189
|
+
count: 0,
|
|
190
|
+
memoryIds: [],
|
|
191
|
+
results: [],
|
|
192
|
+
fallbackUsed: false,
|
|
193
|
+
sourcesUsed: [],
|
|
194
|
+
disclosure: DEFAULT_RECALL_DISCLOSURE,
|
|
195
|
+
retrievalFailure: {
|
|
196
|
+
reason: "backend_unavailable",
|
|
197
|
+
detail: "qmd:daemon_timeout (no response within the deadline)",
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
const emptyResponse: EngramAccessRecallResponse = {
|
|
201
|
+
query: QUERY,
|
|
202
|
+
namespace: "default",
|
|
203
|
+
context: "",
|
|
204
|
+
count: 0,
|
|
205
|
+
memoryIds: [],
|
|
206
|
+
results: [],
|
|
207
|
+
fallbackUsed: false,
|
|
208
|
+
sourcesUsed: [],
|
|
209
|
+
disclosure: DEFAULT_RECALL_DISCLOSURE,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
async function callRecall(response: EngramAccessRecallResponse): Promise<EngramAccessRecallResponse> {
|
|
213
|
+
const service = {
|
|
214
|
+
briefingEnabled: false,
|
|
215
|
+
recall: (_req: EngramAccessRecallRequest) => Promise.resolve(response),
|
|
216
|
+
} as unknown as EngramAccessService;
|
|
217
|
+
const server = new EngramMcpServer(service);
|
|
218
|
+
// Unchecked cast with reason: handleRequest is a JSON-RPC envelope; this
|
|
219
|
+
// test owns both the spy recall result and the tool name, so the payload
|
|
220
|
+
// shape is the MCP recall structuredContent, not untrusted input.
|
|
221
|
+
const rpc = (await server.handleRequest({
|
|
222
|
+
jsonrpc: "2.0",
|
|
223
|
+
id: 1,
|
|
224
|
+
method: "tools/call",
|
|
225
|
+
params: { name: "engram.recall", arguments: { query: QUERY } },
|
|
226
|
+
})) as {
|
|
227
|
+
result?: { structuredContent?: EngramAccessRecallResponse; content?: Array<{ text?: string }> };
|
|
228
|
+
};
|
|
229
|
+
const structured = rpc.result?.structuredContent;
|
|
230
|
+
assert.ok(structured, "MCP recall must return structuredContent");
|
|
231
|
+
const text = rpc.result?.content?.[0]?.text ?? "{}";
|
|
232
|
+
const parsed: unknown = JSON.parse(text);
|
|
233
|
+
assert.ok(parsed && typeof parsed === "object");
|
|
234
|
+
const parsedFailure = "retrievalFailure" in parsed ? parsed.retrievalFailure : undefined;
|
|
235
|
+
assert.deepEqual(parsedFailure, structured.retrievalFailure);
|
|
236
|
+
return structured;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const timedOut = await callRecall(timeoutResponse);
|
|
240
|
+
const genuineEmpty = await callRecall(emptyResponse);
|
|
241
|
+
assert.equal(timedOut.count, genuineEmpty.count);
|
|
242
|
+
assert.deepEqual(timedOut.results, genuineEmpty.results);
|
|
243
|
+
assert.equal(timedOut.retrievalFailure?.reason, "backend_unavailable");
|
|
244
|
+
assert.equal(genuineEmpty.retrievalFailure, undefined);
|
|
245
|
+
});
|
|
@@ -27,29 +27,13 @@ import {
|
|
|
27
27
|
type RecallWhyReport,
|
|
28
28
|
} from "./recall-why.js";
|
|
29
29
|
import { summarizeRecallWhy } from "./recall-why-renderer.js";
|
|
30
|
-
import
|
|
30
|
+
import {
|
|
31
|
+
describeSearchDegradations,
|
|
32
|
+
fatalSearchDegradations,
|
|
33
|
+
type SearchDegradation,
|
|
34
|
+
} from "./search/port.js";
|
|
31
35
|
import type { MemoryFile, MemoryStatus } from "./types.js";
|
|
32
36
|
|
|
33
|
-
/**
|
|
34
|
-
* Degradation codes that mean the search backend could NOT answer, so an
|
|
35
|
-
* empty pipeline carries no information. `vector_tier_unavailable` is
|
|
36
|
-
* excluded on purpose: the lexical tier still answered, so the result set
|
|
37
|
-
* is degraded but real.
|
|
38
|
-
*/
|
|
39
|
-
const FATAL_DEGRADATION_CODES: Partial<Record<SearchDegradation["code"], true>> = {
|
|
40
|
-
backend_unavailable: true,
|
|
41
|
-
backend_error: true,
|
|
42
|
-
daemon_timeout: true,
|
|
43
|
-
daemon_loading: true,
|
|
44
|
-
subprocess_error: true,
|
|
45
|
-
deadline_exceeded: true,
|
|
46
|
-
remote_error: true,
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
function describeDegradations(degradations: readonly SearchDegradation[]): string {
|
|
50
|
-
return degradations.map((d) => `${d.backend}:${d.code}${d.detail !== undefined ? ` (${d.detail})` : ""}`).join("; ");
|
|
51
|
-
}
|
|
52
|
-
|
|
53
37
|
export interface RecallWhyRequest {
|
|
54
38
|
query: string;
|
|
55
39
|
/** Memory id or substring to trace through every stage. */
|
|
@@ -139,15 +123,15 @@ export async function runRecallWhy(deps: RecallWhyServiceDeps, request: RecallWh
|
|
|
139
123
|
...(options.abortSignal !== undefined ? { abortSignal: options.abortSignal } : {}),
|
|
140
124
|
degradationSink: degradations,
|
|
141
125
|
});
|
|
142
|
-
const fatal = degradations
|
|
126
|
+
const fatal = fatalSearchDegradations(degradations);
|
|
143
127
|
if (fatal.length > 0) {
|
|
144
|
-
return { ok: false, reason: "backend_unavailable", detail:
|
|
128
|
+
return { ok: false, reason: "backend_unavailable", detail: describeSearchDegradations(fatal) };
|
|
145
129
|
}
|
|
146
130
|
return { ok: true, snapshot: captured.snapshot };
|
|
147
131
|
} catch (err) {
|
|
148
132
|
// Cancellation is the caller's decision, not a backend outage.
|
|
149
133
|
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
150
|
-
const observed = degradations.length > 0 ? `${
|
|
134
|
+
const observed = degradations.length > 0 ? `${describeSearchDegradations(degradations)}; ` : "";
|
|
151
135
|
return {
|
|
152
136
|
ok: false,
|
|
153
137
|
reason: "backend_unavailable",
|
package/src/search/port.ts
CHANGED
|
@@ -55,6 +55,38 @@ export function reportSearchDegradation(
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Codes that mean the search backend could NOT answer, so an empty
|
|
60
|
+
* result set carries no information (issue #3082 / recall_why #3033).
|
|
61
|
+
* `vector_tier_unavailable` is excluded on purpose: the lexical tier
|
|
62
|
+
* still answered, so the result set is degraded but real.
|
|
63
|
+
*/
|
|
64
|
+
export const FATAL_SEARCH_DEGRADATION_CODES: Readonly<
|
|
65
|
+
Partial<Record<SearchDegradation["code"], true>>
|
|
66
|
+
> = Object.freeze({
|
|
67
|
+
backend_unavailable: true,
|
|
68
|
+
backend_error: true,
|
|
69
|
+
daemon_timeout: true,
|
|
70
|
+
daemon_loading: true,
|
|
71
|
+
subprocess_error: true,
|
|
72
|
+
deadline_exceeded: true,
|
|
73
|
+
remote_error: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export function fatalSearchDegradations(
|
|
77
|
+
degradations: readonly SearchDegradation[],
|
|
78
|
+
): SearchDegradation[] {
|
|
79
|
+
return degradations.filter((d) => FATAL_SEARCH_DEGRADATION_CODES[d.code] === true);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function describeSearchDegradations(
|
|
83
|
+
degradations: readonly SearchDegradation[],
|
|
84
|
+
): string {
|
|
85
|
+
return degradations
|
|
86
|
+
.map((d) => `${d.backend}:${d.code}${d.detail !== undefined ? ` (${d.detail})` : ""}`)
|
|
87
|
+
.join("; ");
|
|
88
|
+
}
|
|
89
|
+
|
|
58
90
|
export function resolveEnsureCollectionArgs(
|
|
59
91
|
collectionOrExecution?: string | SearchExecutionOptions,
|
|
60
92
|
execution?: SearchExecutionOptions
|