@remnic/core 9.3.664 → 9.3.666
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-audit.js +2 -2
- package/dist/access-cli.js +41 -40
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +3 -2
- package/dist/access-http.js +25 -25
- package/dist/access-mcp.d.ts +3 -2
- package/dist/access-mcp.js +22 -22
- package/dist/access-schema.js +3 -3
- package/dist/{access-service-D0SLB4MH.d.ts → access-service-DsS-TatL.d.ts} +1 -1
- package/dist/access-service.d.ts +3 -2
- package/dist/access-service.js +21 -21
- package/dist/adapters/index.js +4 -4
- package/dist/adapters/registry.js +2 -2
- package/dist/bootstrap.d.ts +2 -1
- package/dist/briefing.js +4 -3
- package/dist/capabilities.d.ts +73 -0
- package/dist/capabilities.js +8 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/causal-behavior.js +2 -2
- package/dist/causal-chain.js +2 -2
- package/dist/causal-consolidation.js +7 -6
- package/dist/causal-consolidation.js.map +1 -1
- package/dist/causal-retrieval.js +2 -2
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-ROHLEUTH.js → chunk-23EBQ27U.js} +5 -5
- package/dist/{chunk-YW52BQSU.js → chunk-2TCHDANJ.js} +2 -2
- package/dist/{chunk-IROWLAWG.js → chunk-46WUVFOD.js} +4 -4
- package/dist/{chunk-XB5P5P2L.js → chunk-4T7P2HLJ.js} +3 -3
- package/dist/{chunk-7XH7VJN4.js → chunk-6T4LTI2F.js} +4 -4
- package/dist/{chunk-TVVEYCNW.js → chunk-7K5Q6COX.js} +4 -4
- package/dist/{chunk-BZG2CWOQ.js → chunk-A5TEHAR4.js} +3 -3
- package/dist/{chunk-C7AF236A.js → chunk-AARDBQTA.js} +2 -2
- package/dist/{chunk-IHG6CC7T.js → chunk-BQJUPECT.js} +2 -2
- package/dist/{chunk-7OGJQP7T.js → chunk-CRO4LCQ6.js} +5 -5
- package/dist/{chunk-YNDLCWXS.js → chunk-EZ25VE3G.js} +4 -4
- package/dist/{chunk-LIERUFPO.js → chunk-GZ6QAYSH.js} +94 -74
- package/dist/chunk-GZ6QAYSH.js.map +1 -0
- package/dist/{chunk-UXA5L2DZ.js → chunk-HQCGRSRU.js} +2 -2
- package/dist/{chunk-RKNJBZ55.js → chunk-JBPKEARU.js} +4 -4
- package/dist/{chunk-XW3W4PV4.js → chunk-JTPXSXHC.js} +2 -2
- package/dist/{chunk-OHJFJ4HI.js → chunk-KOXGLQS7.js} +2 -2
- package/dist/{chunk-NLF54XMD.js → chunk-MPXYHC35.js} +26 -26
- package/dist/{chunk-6JBKHTQD.js → chunk-MR4PJ277.js} +2 -2
- package/dist/{chunk-EXXBA5OM.js → chunk-OI4BXFSB.js} +4 -4
- package/dist/{chunk-SQZ42MKH.js → chunk-OQH5XUH3.js} +6 -3
- package/dist/chunk-OQH5XUH3.js.map +1 -0
- package/dist/{chunk-2HEZXPYU.js → chunk-Q2LQZYQ7.js} +3 -3
- package/dist/{chunk-YKX63GBK.js → chunk-QHWJG5C5.js} +8 -8
- package/dist/{chunk-T2AN3BSP.js → chunk-QZ7ODIVL.js} +2 -2
- package/dist/chunk-RI5XBIZ6.js +23 -0
- package/dist/chunk-RI5XBIZ6.js.map +1 -0
- package/dist/{chunk-7ILWCUWH.js → chunk-TJ7HH5LB.js} +28 -3
- package/dist/chunk-TJ7HH5LB.js.map +1 -0
- package/dist/{chunk-V25ZAOSB.js → chunk-UOBLE67F.js} +4 -4
- package/dist/{chunk-JIX3ZL2J.js → chunk-UVUTV7CM.js} +15 -15
- package/dist/{chunk-VH6EIKVS.js → chunk-WKMCC4NQ.js} +35 -16
- package/dist/chunk-WKMCC4NQ.js.map +1 -0
- package/dist/{chunk-SSOMTUCA.js → chunk-WXGTC424.js} +1 -1
- package/dist/{chunk-KHGE6PMF.js → chunk-WXXLSZHA.js} +2 -2
- package/dist/{chunk-DSLUOQDY.js → chunk-XMWF6AU3.js} +2 -2
- package/dist/{chunk-DQY7NJ5L.js → chunk-XS2CWEHZ.js} +2 -2
- package/dist/{cli-BQRqR9N-.d.ts → cli-BypxcNqq.d.ts} +2 -2
- package/dist/cli.d.ts +4 -3
- package/dist/cli.js +42 -42
- package/dist/compounding/engine.js +4 -3
- package/dist/connectors/codex-materialize-runner.js +4 -3
- package/dist/connectors/index.js +4 -3
- package/dist/consolidation-provenance-check.js +2 -2
- package/dist/conversation-index/backend.js +2 -2
- package/dist/dashboard-runtime.js +2 -2
- package/dist/direct-answer-wiring.d.ts +13 -3
- package/dist/direct-answer-wiring.js +1 -1
- package/dist/entity-retrieval.js +4 -3
- package/dist/explicit-capture.d.ts +2 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +66 -65
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +2 -2
- package/dist/lcm/index.js +4 -4
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -3
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/mcp-memory-inspector-app.d.ts +3 -2
- package/dist/namespaces/migrate.js +11 -11
- package/dist/namespaces/search.js +7 -7
- package/dist/namespaces/storage.d.ts +13 -0
- package/dist/namespaces/storage.js +4 -3
- package/dist/operator-toolkit.js +15 -15
- package/dist/{orchestrator-Cg1UkvmO.d.ts → orchestrator-DZqPVoMI.d.ts} +8 -0
- package/dist/orchestrator.d.ts +2 -1
- package/dist/orchestrator.js +32 -31
- package/dist/recall-planner-llm.d.ts +2 -1
- package/dist/recall-planner-llm.js +3 -2
- package/dist/recall-planner-llm.js.map +1 -1
- package/dist/search/factory.js +6 -6
- package/dist/search/index.js +10 -10
- package/dist/search/lancedb-backend.js +1 -1
- package/dist/search/meilisearch-backend.js +1 -1
- package/dist/search/orama-backend.js +1 -1
- package/dist/semantic-consolidation.js +5 -4
- package/dist/semantic-rule-promotion.js +4 -3
- package/dist/semantic-rule-verifier.js +4 -3
- package/dist/storage.js +3 -2
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +1 -1
- package/dist/verified-recall.js +4 -3
- package/package.json +1 -1
- package/src/capabilities.test.ts +97 -0
- package/src/capabilities.ts +86 -0
- package/src/direct-answer-wiring.test.ts +53 -2
- package/src/direct-answer-wiring.ts +18 -5
- package/src/namespaces/catalog.test.ts +12 -12
- package/src/namespaces/storage.ts +28 -1
- package/src/orchestrator.ts +69 -19
- package/src/recall-planner-llm.test.ts +12 -11
- package/src/recall-planner-llm.ts +7 -1
- package/src/storage-fallback-category-dirs.test.ts +150 -1
- package/src/storage.ts +51 -14
- package/dist/chunk-7ILWCUWH.js.map +0 -1
- package/dist/chunk-LIERUFPO.js.map +0 -1
- package/dist/chunk-SQZ42MKH.js.map +0 -1
- package/dist/chunk-VH6EIKVS.js.map +0 -1
- /package/dist/{chunk-ROHLEUTH.js.map → chunk-23EBQ27U.js.map} +0 -0
- /package/dist/{chunk-YW52BQSU.js.map → chunk-2TCHDANJ.js.map} +0 -0
- /package/dist/{chunk-IROWLAWG.js.map → chunk-46WUVFOD.js.map} +0 -0
- /package/dist/{chunk-XB5P5P2L.js.map → chunk-4T7P2HLJ.js.map} +0 -0
- /package/dist/{chunk-7XH7VJN4.js.map → chunk-6T4LTI2F.js.map} +0 -0
- /package/dist/{chunk-TVVEYCNW.js.map → chunk-7K5Q6COX.js.map} +0 -0
- /package/dist/{chunk-BZG2CWOQ.js.map → chunk-A5TEHAR4.js.map} +0 -0
- /package/dist/{chunk-C7AF236A.js.map → chunk-AARDBQTA.js.map} +0 -0
- /package/dist/{chunk-IHG6CC7T.js.map → chunk-BQJUPECT.js.map} +0 -0
- /package/dist/{chunk-7OGJQP7T.js.map → chunk-CRO4LCQ6.js.map} +0 -0
- /package/dist/{chunk-YNDLCWXS.js.map → chunk-EZ25VE3G.js.map} +0 -0
- /package/dist/{chunk-UXA5L2DZ.js.map → chunk-HQCGRSRU.js.map} +0 -0
- /package/dist/{chunk-RKNJBZ55.js.map → chunk-JBPKEARU.js.map} +0 -0
- /package/dist/{chunk-XW3W4PV4.js.map → chunk-JTPXSXHC.js.map} +0 -0
- /package/dist/{chunk-OHJFJ4HI.js.map → chunk-KOXGLQS7.js.map} +0 -0
- /package/dist/{chunk-NLF54XMD.js.map → chunk-MPXYHC35.js.map} +0 -0
- /package/dist/{chunk-6JBKHTQD.js.map → chunk-MR4PJ277.js.map} +0 -0
- /package/dist/{chunk-EXXBA5OM.js.map → chunk-OI4BXFSB.js.map} +0 -0
- /package/dist/{chunk-2HEZXPYU.js.map → chunk-Q2LQZYQ7.js.map} +0 -0
- /package/dist/{chunk-YKX63GBK.js.map → chunk-QHWJG5C5.js.map} +0 -0
- /package/dist/{chunk-T2AN3BSP.js.map → chunk-QZ7ODIVL.js.map} +0 -0
- /package/dist/{chunk-V25ZAOSB.js.map → chunk-UOBLE67F.js.map} +0 -0
- /package/dist/{chunk-JIX3ZL2J.js.map → chunk-UVUTV7CM.js.map} +0 -0
- /package/dist/{chunk-SSOMTUCA.js.map → chunk-WXGTC424.js.map} +0 -0
- /package/dist/{chunk-KHGE6PMF.js.map → chunk-WXXLSZHA.js.map} +0 -0
- /package/dist/{chunk-DSLUOQDY.js.map → chunk-XMWF6AU3.js.map} +0 -0
- /package/dist/{chunk-DQY7NJ5L.js.map → chunk-XS2CWEHZ.js.map} +0 -0
package/src/orchestrator.ts
CHANGED
|
@@ -245,6 +245,7 @@ import {
|
|
|
245
245
|
type TrustZoneSearchResult,
|
|
246
246
|
} from "./trust-zones.js";
|
|
247
247
|
import { tryDirectAnswer, type DirectAnswerSources } from "./direct-answer-wiring.js";
|
|
248
|
+
import { resolveCapabilities, type CapabilitySet } from "./capabilities.js";
|
|
248
249
|
import { DEFAULT_TAXONOMY } from "./taxonomy/index.js";
|
|
249
250
|
import {
|
|
250
251
|
searchHarmonicRetrieval,
|
|
@@ -1376,6 +1377,13 @@ export function resolveRecallModeDecision(options: RecallModeGraphOptions): Reca
|
|
|
1376
1377
|
export async function resolveRecallModeDecisionAsync(
|
|
1377
1378
|
options: RecallModeGraphOptions & {
|
|
1378
1379
|
config: PluginConfig;
|
|
1380
|
+
/**
|
|
1381
|
+
* Recall-operation capability gates (issue #1523). OPTIONAL and additive:
|
|
1382
|
+
* the recall orchestrator passes a resolved set, but existing callers that
|
|
1383
|
+
* only pass `config` + planner flags stay backward-compatible — the LLM
|
|
1384
|
+
* planner gate falls back to `config.recallPlannerLlmEnabled` when omitted.
|
|
1385
|
+
*/
|
|
1386
|
+
caps?: CapabilitySet;
|
|
1379
1387
|
hints?: string[];
|
|
1380
1388
|
llm?: FallbackLlmClient;
|
|
1381
1389
|
signal?: AbortSignal;
|
|
@@ -1384,7 +1392,11 @@ export async function resolveRecallModeDecisionAsync(
|
|
|
1384
1392
|
const heuristicDecision = resolveRecallModeDecision(options);
|
|
1385
1393
|
|
|
1386
1394
|
// Planner globally off, or LLM planning not opted into → heuristic only.
|
|
1387
|
-
|
|
1395
|
+
// Prefer the resolved capability when supplied; otherwise fall back to the
|
|
1396
|
+
// config flag so callers on the old option shape get identical gating.
|
|
1397
|
+
const plannerLlmEnabled =
|
|
1398
|
+
options.caps?.recallPlannerLlm ?? options.config.recallPlannerLlmEnabled;
|
|
1399
|
+
if (!options.plannerEnabled || !plannerLlmEnabled) {
|
|
1388
1400
|
return heuristicDecision;
|
|
1389
1401
|
}
|
|
1390
1402
|
|
|
@@ -1395,6 +1407,7 @@ export async function resolveRecallModeDecisionAsync(
|
|
|
1395
1407
|
options.config,
|
|
1396
1408
|
options.llm,
|
|
1397
1409
|
options.signal,
|
|
1410
|
+
options.caps,
|
|
1398
1411
|
);
|
|
1399
1412
|
|
|
1400
1413
|
// Shadow mode: record what the LLM would have chosen but keep the heuristic
|
|
@@ -5738,6 +5751,10 @@ export class Orchestrator {
|
|
|
5738
5751
|
sessionKey?: string,
|
|
5739
5752
|
options: RecallInvocationOptions = {},
|
|
5740
5753
|
): Promise<string> {
|
|
5754
|
+
// Resolve the recall-operation capability gates ONCE, at the operation
|
|
5755
|
+
// entry, and thread the frozen set down (issue #1523). Never re-read the
|
|
5756
|
+
// migrated flags off `this.config` mid-operation.
|
|
5757
|
+
const caps = resolveCapabilities(this.config);
|
|
5741
5758
|
const abortController = new AbortController();
|
|
5742
5759
|
const onAbort = () => {
|
|
5743
5760
|
abortController.abort();
|
|
@@ -5813,7 +5830,7 @@ export class Orchestrator {
|
|
|
5813
5830
|
const recallPromise = this.recallInternal(prompt, sessionKey, {
|
|
5814
5831
|
...options,
|
|
5815
5832
|
abortSignal: abortController.signal,
|
|
5816
|
-
});
|
|
5833
|
+
}, caps);
|
|
5817
5834
|
const RECALL_TIMEOUT_MS = this.config.recallOuterTimeoutMs ?? 75_000;
|
|
5818
5835
|
if (RECALL_TIMEOUT_MS <= 0) {
|
|
5819
5836
|
return await recallPromise;
|
|
@@ -5837,13 +5854,14 @@ export class Orchestrator {
|
|
|
5837
5854
|
// Observation-mode direct-answer tier (issue #518 slice 3c).
|
|
5838
5855
|
// Runs after the user's recall already succeeded, fire-and-forget,
|
|
5839
5856
|
// so annotation latency can never delay the caller's response.
|
|
5840
|
-
if (
|
|
5857
|
+
if (caps.recallDirectAnswer && sessionKey) {
|
|
5841
5858
|
try {
|
|
5842
5859
|
this.enqueueDirectAnswerObservation(
|
|
5843
5860
|
prompt,
|
|
5844
5861
|
sessionKey,
|
|
5845
5862
|
options.namespace?.trim() || undefined,
|
|
5846
5863
|
options.principalOverride,
|
|
5864
|
+
caps,
|
|
5847
5865
|
);
|
|
5848
5866
|
} catch (err) {
|
|
5849
5867
|
log.debug(`direct-answer observation setup failed: ${err}`);
|
|
@@ -5912,6 +5930,7 @@ export class Orchestrator {
|
|
|
5912
5930
|
sessionKey: string,
|
|
5913
5931
|
namespaceOverride: string | undefined,
|
|
5914
5932
|
principalOverride: string | undefined,
|
|
5933
|
+
caps: CapabilitySet,
|
|
5915
5934
|
): void {
|
|
5916
5935
|
const expectedSnapshot = this.lastRecall.get(sessionKey);
|
|
5917
5936
|
if (expectedSnapshot === null) return;
|
|
@@ -5992,6 +6011,7 @@ export class Orchestrator {
|
|
|
5992
6011
|
sessionKey,
|
|
5993
6012
|
observationNamespaces,
|
|
5994
6013
|
expectedIdentity,
|
|
6014
|
+
caps,
|
|
5995
6015
|
undefined,
|
|
5996
6016
|
);
|
|
5997
6017
|
} catch (err) {
|
|
@@ -6007,6 +6027,7 @@ export class Orchestrator {
|
|
|
6007
6027
|
expectedIdentity:
|
|
6008
6028
|
| { writeNonce?: string; traceId?: string; recordedAt?: string }
|
|
6009
6029
|
| undefined,
|
|
6030
|
+
caps: CapabilitySet,
|
|
6010
6031
|
_parentAbortSignal?: AbortSignal,
|
|
6011
6032
|
): Promise<void> {
|
|
6012
6033
|
const tierStart = Date.now();
|
|
@@ -6091,6 +6112,7 @@ export class Orchestrator {
|
|
|
6091
6112
|
query: prompt,
|
|
6092
6113
|
namespace: ns,
|
|
6093
6114
|
config: this.config,
|
|
6115
|
+
enabled: caps.recallDirectAnswer,
|
|
6094
6116
|
sources,
|
|
6095
6117
|
});
|
|
6096
6118
|
if (r.eligible && r.winner) {
|
|
@@ -7277,6 +7299,7 @@ export class Orchestrator {
|
|
|
7277
7299
|
prompt: string,
|
|
7278
7300
|
sessionKey?: string,
|
|
7279
7301
|
options: RecallInvocationOptions = {},
|
|
7302
|
+
caps: CapabilitySet = resolveCapabilities(this.config),
|
|
7280
7303
|
): Promise<string> {
|
|
7281
7304
|
const recallStart = Date.now();
|
|
7282
7305
|
// Backend degradations observed by this recall's QMD searches (#1536):
|
|
@@ -7436,11 +7459,10 @@ export class Orchestrator {
|
|
|
7436
7459
|
let identityInjectionTruncated = false;
|
|
7437
7460
|
timings.queryPolicy = `${queryPolicy.promptShape}/${queryPolicy.retrievalBudgetMode}${queryPolicy.skipConversationRecall ? "/skip-conv" : ""}`;
|
|
7438
7461
|
const recallModeDecisionOptions = {
|
|
7439
|
-
plannerEnabled:
|
|
7440
|
-
graphRecallEnabled:
|
|
7462
|
+
plannerEnabled: caps.recallPlanner,
|
|
7463
|
+
graphRecallEnabled: caps.graphRecall,
|
|
7441
7464
|
multiGraphMemoryEnabled: this.config.multiGraphMemoryEnabled,
|
|
7442
|
-
graphExpandedIntentEnabled:
|
|
7443
|
-
this.config.graphExpandedIntentEnabled === true,
|
|
7465
|
+
graphExpandedIntentEnabled: caps.graphExpandedIntent,
|
|
7444
7466
|
prompt,
|
|
7445
7467
|
};
|
|
7446
7468
|
const requestedMode = options.mode;
|
|
@@ -7454,6 +7476,7 @@ export class Orchestrator {
|
|
|
7454
7476
|
: await resolveRecallModeDecisionAsync({
|
|
7455
7477
|
...recallModeDecisionOptions,
|
|
7456
7478
|
config: this.config,
|
|
7479
|
+
caps,
|
|
7457
7480
|
signal: options.abortSignal,
|
|
7458
7481
|
});
|
|
7459
7482
|
if (
|
|
@@ -7779,7 +7802,7 @@ export class Orchestrator {
|
|
|
7779
7802
|
promptLength: prompt.length,
|
|
7780
7803
|
retrievalQueryHash,
|
|
7781
7804
|
retrievalQueryLength: retrievalQuery.length,
|
|
7782
|
-
plannerEnabled:
|
|
7805
|
+
plannerEnabled: caps.recallPlanner,
|
|
7783
7806
|
plannedMode: requestedMode ?? recallDecision.plannedMode,
|
|
7784
7807
|
effectiveMode: recallMode,
|
|
7785
7808
|
recallResultLimit,
|
|
@@ -7790,7 +7813,7 @@ export class Orchestrator {
|
|
|
7790
7813
|
reason: graphDecisionReason,
|
|
7791
7814
|
shadowMode: graphDecisionShadowMode,
|
|
7792
7815
|
qmdAvailable,
|
|
7793
|
-
graphRecallEnabled:
|
|
7816
|
+
graphRecallEnabled: caps.graphRecall,
|
|
7794
7817
|
multiGraphMemoryEnabled: this.config.multiGraphMemoryEnabled,
|
|
7795
7818
|
},
|
|
7796
7819
|
});
|
|
@@ -11012,7 +11035,7 @@ export class Orchestrator {
|
|
|
11012
11035
|
|
|
11013
11036
|
const isFullModeGraphAssist =
|
|
11014
11037
|
this.config.multiGraphMemoryEnabled &&
|
|
11015
|
-
|
|
11038
|
+
caps.graphAssistInFullMode &&
|
|
11016
11039
|
recallMode === "full" &&
|
|
11017
11040
|
memoryResults.length >=
|
|
11018
11041
|
Math.max(1, this.config.graphAssistMinSeedResults ?? 3);
|
|
@@ -11192,7 +11215,7 @@ export class Orchestrator {
|
|
|
11192
11215
|
timeoutMs: this.config.rerankTimeoutMs,
|
|
11193
11216
|
maxCandidates: this.config.rerankMaxCandidates,
|
|
11194
11217
|
cache: this.rerankCache,
|
|
11195
|
-
cacheEnabled:
|
|
11218
|
+
cacheEnabled: caps.rerankCache,
|
|
11196
11219
|
cacheTtlMs: this.config.rerankCacheTtlMs,
|
|
11197
11220
|
});
|
|
11198
11221
|
if (ranked && ranked.length > 0) {
|
|
@@ -11222,7 +11245,7 @@ export class Orchestrator {
|
|
|
11222
11245
|
// flips the default once bench shows tie-or-win. Fail-open: any
|
|
11223
11246
|
// lookup error leaves the original scores untouched rather than
|
|
11224
11247
|
// breaking recall for the whole namespace.
|
|
11225
|
-
if (
|
|
11248
|
+
if (caps.recallMemoryWorthFilter && memoryResults.length > 0) {
|
|
11226
11249
|
try {
|
|
11227
11250
|
memoryResults = await this.applyMemoryWorthRerank(memoryResults, recallNamespaces);
|
|
11228
11251
|
} catch (err) {
|
|
@@ -11260,7 +11283,7 @@ export class Orchestrator {
|
|
|
11260
11283
|
memoryResults.length,
|
|
11261
11284
|
);
|
|
11262
11285
|
let confidenceGateRejected = false;
|
|
11263
|
-
if (
|
|
11286
|
+
if (caps.recallConfidenceGate && effectiveGateScore > 0) {
|
|
11264
11287
|
if (effectiveGateScore < this.config.recallConfidenceGateThreshold) {
|
|
11265
11288
|
log.debug(
|
|
11266
11289
|
`recall: confidence gate rejected ${memoryResults.length} results (effective score ${effectiveGateScore.toFixed(3)} below ${this.config.recallConfidenceGateThreshold})`,
|
|
@@ -11278,6 +11301,7 @@ export class Orchestrator {
|
|
|
11278
11301
|
memoryResults,
|
|
11279
11302
|
recallResultLimit,
|
|
11280
11303
|
retrievalQuery,
|
|
11304
|
+
caps,
|
|
11281
11305
|
);
|
|
11282
11306
|
|
|
11283
11307
|
// E-Mem-inspired memory reconstruction: fill gaps for referenced entities
|
|
@@ -11394,6 +11418,7 @@ export class Orchestrator {
|
|
|
11394
11418
|
boostedScoped,
|
|
11395
11419
|
recallResultLimit,
|
|
11396
11420
|
retrievalQuery,
|
|
11421
|
+
caps,
|
|
11397
11422
|
);
|
|
11398
11423
|
},
|
|
11399
11424
|
[] as QmdSearchResult[],
|
|
@@ -11431,6 +11456,7 @@ export class Orchestrator {
|
|
|
11431
11456
|
recallNamespaces,
|
|
11432
11457
|
recallResultLimit,
|
|
11433
11458
|
recallMode,
|
|
11459
|
+
caps,
|
|
11434
11460
|
queryAwarePrefilter,
|
|
11435
11461
|
abortSignal: options.abortSignal,
|
|
11436
11462
|
onDegradation: (degradation) => {
|
|
@@ -11551,6 +11577,7 @@ export class Orchestrator {
|
|
|
11551
11577
|
boostedScoped,
|
|
11552
11578
|
recallResultLimit,
|
|
11553
11579
|
retrievalQuery,
|
|
11580
|
+
caps,
|
|
11554
11581
|
);
|
|
11555
11582
|
},
|
|
11556
11583
|
[] as QmdSearchResult[],
|
|
@@ -11656,6 +11683,7 @@ export class Orchestrator {
|
|
|
11656
11683
|
recallNamespaces,
|
|
11657
11684
|
recallResultLimit,
|
|
11658
11685
|
recallMode,
|
|
11686
|
+
caps,
|
|
11659
11687
|
queryAwarePrefilter,
|
|
11660
11688
|
abortSignal: options.abortSignal,
|
|
11661
11689
|
onDegradation: (degradation) => {
|
|
@@ -11730,6 +11758,7 @@ export class Orchestrator {
|
|
|
11730
11758
|
boostedRecent,
|
|
11731
11759
|
recallResultLimit,
|
|
11732
11760
|
retrievalQuery,
|
|
11761
|
+
caps,
|
|
11733
11762
|
);
|
|
11734
11763
|
},
|
|
11735
11764
|
[] as QmdSearchResult[],
|
|
@@ -11768,6 +11797,7 @@ export class Orchestrator {
|
|
|
11768
11797
|
recallNamespaces,
|
|
11769
11798
|
recallResultLimit,
|
|
11770
11799
|
recallMode,
|
|
11800
|
+
caps,
|
|
11771
11801
|
queryAwarePrefilter,
|
|
11772
11802
|
abortSignal: options.abortSignal,
|
|
11773
11803
|
onDegradation: (degradation) => {
|
|
@@ -11815,6 +11845,7 @@ export class Orchestrator {
|
|
|
11815
11845
|
recallNamespaces,
|
|
11816
11846
|
recallResultLimit,
|
|
11817
11847
|
recallMode,
|
|
11848
|
+
caps,
|
|
11818
11849
|
queryAwarePrefilter,
|
|
11819
11850
|
abortSignal: options.abortSignal,
|
|
11820
11851
|
onDegradation: (degradation) => {
|
|
@@ -18361,6 +18392,11 @@ export class Orchestrator {
|
|
|
18361
18392
|
results: QmdSearchResult[],
|
|
18362
18393
|
limit: number,
|
|
18363
18394
|
retrievalQuery?: string,
|
|
18395
|
+
// `caps` is additive AND last (issue #1523) so the positional call shape
|
|
18396
|
+
// stays backward-compatible: the recall pipeline threads a resolved set,
|
|
18397
|
+
// but callers that omit it (e.g. direct unit-test invocations) get an
|
|
18398
|
+
// equivalent set derived from the same config — behavior-preserving.
|
|
18399
|
+
caps: CapabilitySet = resolveCapabilities(this.config),
|
|
18364
18400
|
): QmdSearchResult[] {
|
|
18365
18401
|
const safeLimit =
|
|
18366
18402
|
typeof limit === "number" && Number.isFinite(limit)
|
|
@@ -18377,13 +18413,13 @@ export class Orchestrator {
|
|
|
18377
18413
|
// facts/decisions before MMR picks the final section. No-op when the
|
|
18378
18414
|
// flag is off or the query is not a problem-solving ask.
|
|
18379
18415
|
const boosted =
|
|
18380
|
-
|
|
18416
|
+
caps.recallReasoningTraceBoost && typeof retrievalQuery === "string"
|
|
18381
18417
|
? applyReasoningTraceBoost(results, {
|
|
18382
18418
|
enabled: true,
|
|
18383
18419
|
query: retrievalQuery,
|
|
18384
18420
|
})
|
|
18385
18421
|
: results;
|
|
18386
|
-
const diversified = this.applyMmrToQmdResults(sectionId, boosted);
|
|
18422
|
+
const diversified = this.applyMmrToQmdResults(sectionId, boosted, caps);
|
|
18387
18423
|
return diversified.slice(0, safeLimit);
|
|
18388
18424
|
}
|
|
18389
18425
|
|
|
@@ -18398,8 +18434,11 @@ export class Orchestrator {
|
|
|
18398
18434
|
private applyMmrToQmdResults(
|
|
18399
18435
|
sectionId: string,
|
|
18400
18436
|
results: QmdSearchResult[],
|
|
18437
|
+
// Additive `caps` (issue #1523); defaults to a config-derived set so direct
|
|
18438
|
+
// callers that omit it behave identically to the threaded recall path.
|
|
18439
|
+
caps: CapabilitySet = resolveCapabilities(this.config),
|
|
18401
18440
|
): QmdSearchResult[] {
|
|
18402
|
-
if (
|
|
18441
|
+
if (!caps.recallMmr) return results;
|
|
18403
18442
|
if (!Array.isArray(results) || results.length < 2) return results;
|
|
18404
18443
|
|
|
18405
18444
|
// Config is runtime API (see AGENTS.md §4): preserve `0` as a true zero
|
|
@@ -18698,6 +18737,13 @@ export class Orchestrator {
|
|
|
18698
18737
|
recallNamespaces: string[];
|
|
18699
18738
|
recallResultLimit: number;
|
|
18700
18739
|
recallMode: RecallPlanMode;
|
|
18740
|
+
/**
|
|
18741
|
+
* Recall-operation capability gates resolved once at recall entry (#1523).
|
|
18742
|
+
* OPTIONAL and additive: the recall pipeline threads a resolved set, but
|
|
18743
|
+
* callers that omit it (e.g. direct unit-test invocations) get an
|
|
18744
|
+
* equivalent config-derived set — behavior-preserving.
|
|
18745
|
+
*/
|
|
18746
|
+
caps?: CapabilitySet;
|
|
18701
18747
|
queryAwarePrefilter?: QueryAwarePrefilter;
|
|
18702
18748
|
abortSignal?: AbortSignal;
|
|
18703
18749
|
/** Backend degradation observer — cold-tier QMD must report like hot (#1536). */
|
|
@@ -18717,6 +18763,9 @@ export class Orchestrator {
|
|
|
18717
18763
|
/** Issue #681 — when true, bypass graphTraversalConfidenceFloor. */
|
|
18718
18764
|
includeLowConfidence?: boolean;
|
|
18719
18765
|
}): Promise<QmdSearchResult[]> {
|
|
18766
|
+
// Prefer the threaded set; fall back to a config-derived set so direct
|
|
18767
|
+
// callers (unit tests) behave identically to the recall pipeline (#1523).
|
|
18768
|
+
const caps = options.caps ?? resolveCapabilities(this.config);
|
|
18720
18769
|
if (options.queryAwarePrefilter?.candidatePaths?.size === 0) {
|
|
18721
18770
|
if (options.xrayPoolSizeSink) options.xrayPoolSizeSink.size = 0;
|
|
18722
18771
|
return [];
|
|
@@ -18932,7 +18981,7 @@ export class Orchestrator {
|
|
|
18932
18981
|
const isFullModeGraphAssist =
|
|
18933
18982
|
this.config.qmdTierParityGraphEnabled &&
|
|
18934
18983
|
this.config.multiGraphMemoryEnabled &&
|
|
18935
|
-
|
|
18984
|
+
caps.graphAssistInFullMode &&
|
|
18936
18985
|
options.recallMode === "full" &&
|
|
18937
18986
|
results.length >= Math.max(1, this.config.graphAssistMinSeedResults ?? 3);
|
|
18938
18987
|
const shouldRunGraphExpansion =
|
|
@@ -19028,7 +19077,7 @@ export class Orchestrator {
|
|
|
19028
19077
|
timeoutMs: this.config.rerankTimeoutMs,
|
|
19029
19078
|
maxCandidates: this.config.rerankMaxCandidates,
|
|
19030
19079
|
cache: this.rerankCache,
|
|
19031
|
-
cacheEnabled:
|
|
19080
|
+
cacheEnabled: caps.rerankCache,
|
|
19032
19081
|
cacheTtlMs: this.config.rerankCacheTtlMs,
|
|
19033
19082
|
});
|
|
19034
19083
|
if (ranked && ranked.length > 0) {
|
|
@@ -19054,7 +19103,7 @@ export class Orchestrator {
|
|
|
19054
19103
|
// Memory Worth filter — must fire on the cold fallback path too, or the
|
|
19055
19104
|
// feature flag produces divergent behavior by retrieval path (CLAUDE.md
|
|
19056
19105
|
// rule 39). Fail-open on lookup errors.
|
|
19057
|
-
if (
|
|
19106
|
+
if (caps.recallMemoryWorthFilter && results.length > 0) {
|
|
19058
19107
|
try {
|
|
19059
19108
|
results = await this.applyMemoryWorthRerank(results, options.recallNamespaces);
|
|
19060
19109
|
} catch (err) {
|
|
@@ -19079,6 +19128,7 @@ export class Orchestrator {
|
|
|
19079
19128
|
results,
|
|
19080
19129
|
options.recallResultLimit,
|
|
19081
19130
|
options.prompt,
|
|
19131
|
+
caps,
|
|
19082
19132
|
);
|
|
19083
19133
|
}
|
|
19084
19134
|
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
|
|
4
4
|
import { parseConfig } from "./config.js";
|
|
5
|
+
import { resolveCapabilities } from "./capabilities.js";
|
|
5
6
|
import {
|
|
6
7
|
planRecallModeLLM,
|
|
7
8
|
resolveRecallPlannerLlmOptions,
|
|
@@ -40,7 +41,7 @@ test("returns heuristic without calling the LLM when recallPlannerLlmEnabled is
|
|
|
40
41
|
const captured: Array<Record<string, unknown>> = [];
|
|
41
42
|
const llm = stubLlm({ capturedOptions: captured, result: { mode: "no_recall" } });
|
|
42
43
|
|
|
43
|
-
const result = await planRecallModeLLM("what did we decide about auth?", undefined, config, llm);
|
|
44
|
+
const result = await planRecallModeLLM("what did we decide about auth?", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
44
45
|
|
|
45
46
|
assert.equal(captured.length, 0, "LLM must not be contacted when disabled");
|
|
46
47
|
assert.equal(result.source, "heuristic");
|
|
@@ -54,7 +55,7 @@ test("uses the LLM classification when enabled", async () => {
|
|
|
54
55
|
const config = parseConfig({ recallPlannerLlmEnabled: true });
|
|
55
56
|
const llm = stubLlm({ result: { mode: "graph_mode", reason: "asks for root cause" }, modelUsed: "anthropic/claude" });
|
|
56
57
|
|
|
57
|
-
const result = await planRecallModeLLM("restart the gateway", undefined, config, llm);
|
|
58
|
+
const result = await planRecallModeLLM("restart the gateway", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
58
59
|
|
|
59
60
|
assert.equal(result.source, "llm");
|
|
60
61
|
assert.equal(result.mode, "graph_mode");
|
|
@@ -74,7 +75,7 @@ test("forwards taskModelChain AND recallPlannerModel in gateway mode (provider-a
|
|
|
74
75
|
const captured: Array<Record<string, unknown>> = [];
|
|
75
76
|
const llm = stubLlm({ capturedOptions: captured, result: { mode: "minimal" } });
|
|
76
77
|
|
|
77
|
-
await planRecallModeLLM("check status", undefined, config, llm);
|
|
78
|
+
await planRecallModeLLM("check status", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
78
79
|
|
|
79
80
|
assert.equal(captured.length, 1);
|
|
80
81
|
// recallPlannerModel is tried first (prepended), taskModelChain is the fallback chain.
|
|
@@ -97,7 +98,7 @@ test("plugin mode passes only the explicit model, no gateway chain", async () =>
|
|
|
97
98
|
const captured: Array<Record<string, unknown>> = [];
|
|
98
99
|
const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
|
|
99
100
|
|
|
100
|
-
await planRecallModeLLM("summarize the project", undefined, config, llm);
|
|
101
|
+
await planRecallModeLLM("summarize the project", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
101
102
|
|
|
102
103
|
assert.equal(captured.length, 1);
|
|
103
104
|
assert.equal(captured[0]?.model, "openai/gpt-5.5");
|
|
@@ -109,7 +110,7 @@ test("falls back to heuristic when the LLM throws", async () => {
|
|
|
109
110
|
const config = parseConfig({ recallPlannerLlmEnabled: true });
|
|
110
111
|
const llm = stubLlm({ throwError: "boom" });
|
|
111
112
|
|
|
112
|
-
const result = await planRecallModeLLM("what happened during the outage?", undefined, config, llm);
|
|
113
|
+
const result = await planRecallModeLLM("what happened during the outage?", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
113
114
|
|
|
114
115
|
assert.equal(result.source, "heuristic-fallback");
|
|
115
116
|
assert.equal(result.fallbackUsed, true);
|
|
@@ -123,7 +124,7 @@ test("falls back to heuristic when the LLM returns no parseable result", async (
|
|
|
123
124
|
const config = parseConfig({ recallPlannerLlmEnabled: true });
|
|
124
125
|
const llm = stubLlm({ result: null });
|
|
125
126
|
|
|
126
|
-
const result = await planRecallModeLLM("how did we get here?", undefined, config, llm);
|
|
127
|
+
const result = await planRecallModeLLM("how did we get here?", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
127
128
|
|
|
128
129
|
assert.equal(result.source, "heuristic-fallback");
|
|
129
130
|
assert.equal(result.fallbackUsed, true);
|
|
@@ -139,7 +140,7 @@ test("falls back without a network attempt when the chain is empty and the model
|
|
|
139
140
|
const captured: Array<Record<string, unknown>> = [];
|
|
140
141
|
const llm = stubLlm({ available: false, capturedOptions: captured, result: { mode: "full" } });
|
|
141
142
|
|
|
142
|
-
const result = await planRecallModeLLM("anything", undefined, config, llm);
|
|
143
|
+
const result = await planRecallModeLLM("anything", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
143
144
|
|
|
144
145
|
assert.equal(captured.length, 0, "no network attempt when nothing is routable");
|
|
145
146
|
assert.equal(result.source, "heuristic-fallback");
|
|
@@ -155,7 +156,7 @@ test("attempts the call (and falls back) when a provider-qualified model overrid
|
|
|
155
156
|
const captured: Array<Record<string, unknown>> = [];
|
|
156
157
|
const llm = stubLlm({ available: false, capturedOptions: captured, result: null });
|
|
157
158
|
|
|
158
|
-
const result = await planRecallModeLLM("anything", undefined, config, llm);
|
|
159
|
+
const result = await planRecallModeLLM("anything", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
159
160
|
|
|
160
161
|
assert.equal(captured.length, 1, "qualified model override → still attempt the call");
|
|
161
162
|
assert.equal(captured[0]?.model, "openai/gpt-5.5");
|
|
@@ -170,7 +171,7 @@ test("an already-aborted recall short-circuits to the heuristic without an LLM c
|
|
|
170
171
|
const ac = new AbortController();
|
|
171
172
|
ac.abort();
|
|
172
173
|
|
|
173
|
-
const result = await planRecallModeLLM("what did we decide?", undefined, config, llm, ac.signal);
|
|
174
|
+
const result = await planRecallModeLLM("what did we decide?", undefined, config, llm, ac.signal, resolveCapabilities(config));
|
|
174
175
|
|
|
175
176
|
assert.equal(captured.length, 0, "no LLM call when the recall is already aborted");
|
|
176
177
|
assert.equal(result.source, "heuristic-fallback");
|
|
@@ -184,7 +185,7 @@ test("forwards the abort signal into the LLM call (cancellation contract)", asyn
|
|
|
184
185
|
const llm = stubLlm({ capturedOptions: captured, result: { mode: "minimal" } });
|
|
185
186
|
const ac = new AbortController();
|
|
186
187
|
|
|
187
|
-
await planRecallModeLLM("check status", undefined, config, llm, ac.signal);
|
|
188
|
+
await planRecallModeLLM("check status", undefined, config, llm, ac.signal, resolveCapabilities(config));
|
|
188
189
|
|
|
189
190
|
assert.equal(captured.length, 1);
|
|
190
191
|
assert.equal(captured[0]?.signal, ac.signal, "recall abort signal must reach FallbackLlmClient");
|
|
@@ -195,7 +196,7 @@ test("empty prompts skip the LLM entirely", async () => {
|
|
|
195
196
|
const captured: Array<Record<string, unknown>> = [];
|
|
196
197
|
const llm = stubLlm({ capturedOptions: captured, result: { mode: "full" } });
|
|
197
198
|
|
|
198
|
-
const result = await planRecallModeLLM(" ", undefined, config, llm);
|
|
199
|
+
const result = await planRecallModeLLM(" ", undefined, config, llm, undefined, resolveCapabilities(config));
|
|
199
200
|
|
|
200
201
|
assert.equal(captured.length, 0);
|
|
201
202
|
assert.equal(result.mode, "no_recall"); // heuristic returns no_recall for empty
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
import type { PluginConfig, RecallPlanMode } from "./types.js";
|
|
4
|
+
import type { CapabilitySet } from "./capabilities.js";
|
|
4
5
|
import { planRecallMode } from "./intent.js";
|
|
5
6
|
import {
|
|
6
7
|
FallbackLlmClient,
|
|
@@ -189,10 +190,15 @@ export async function planRecallModeLLM(
|
|
|
189
190
|
config: PluginConfig,
|
|
190
191
|
llm?: FallbackLlmClient,
|
|
191
192
|
signal?: AbortSignal,
|
|
193
|
+
caps?: CapabilitySet,
|
|
192
194
|
): Promise<RecallPlannerLlmResult> {
|
|
193
195
|
const heuristicMode = planRecallMode(prompt);
|
|
194
196
|
|
|
195
|
-
|
|
197
|
+
// `caps` is OPTIONAL and additive (issue #1523). Prefer the resolved
|
|
198
|
+
// capability when supplied; fall back to the config flag so existing callers
|
|
199
|
+
// that pass only `config` keep identical gating.
|
|
200
|
+
const plannerLlmEnabled = caps?.recallPlannerLlm ?? config.recallPlannerLlmEnabled;
|
|
201
|
+
if (!plannerLlmEnabled) {
|
|
196
202
|
return heuristicResult(heuristicMode, "heuristic", "llm-disabled", 0, false);
|
|
197
203
|
}
|
|
198
204
|
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import assert from "node:assert/strict";
|
|
30
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
30
|
+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
|
31
31
|
import os from "node:os";
|
|
32
32
|
import path from "node:path";
|
|
33
33
|
import test from "node:test";
|
|
@@ -356,6 +356,155 @@ test("question-queue items written by writeQuestion() do NOT leak into fallback
|
|
|
356
356
|
* directly with a config object that explicitly disables QMD, proving the disk
|
|
357
357
|
* scan a QMD-disabled deployment relies on returns every category.
|
|
358
358
|
*/
|
|
359
|
+
/**
|
|
360
|
+
* Symlink containment (same walker-hardening class as PR #1563 / issue #1546).
|
|
361
|
+
*
|
|
362
|
+
* `collectActiveMemoryPaths()` walks the RECALL_FALLBACK_DIRS category dirs for
|
|
363
|
+
* the QMD-unavailable filesystem recall fallback. A category dir symlinked
|
|
364
|
+
* outside `memoryDir` (e.g. `decisions/` -> an external directory) must NOT be
|
|
365
|
+
* followed — otherwise out-of-store files leak into recall results. The walker
|
|
366
|
+
* now `lstat`s each dir (skipping symlinks/non-dirs), realpaths it, and asserts
|
|
367
|
+
* it stays inside the memory root; per entry it skips symlinks and asserts the
|
|
368
|
+
* file resolves inside the root before including it.
|
|
369
|
+
*/
|
|
370
|
+
test("category dir symlinked outside memoryDir does NOT leak files into fallback recall", async (t) => {
|
|
371
|
+
if (process.platform === "win32") {
|
|
372
|
+
t.skip("directory symlink setup is platform-specific");
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-dir-"));
|
|
376
|
+
const outsideDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-out-"));
|
|
377
|
+
const storage = new StorageManager(baseDir);
|
|
378
|
+
StorageManager.clearAllStaticCaches();
|
|
379
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
380
|
+
try {
|
|
381
|
+
// A genuine in-store memory that MUST still be recalled.
|
|
382
|
+
await seedMemory(baseDir, "facts", "real-fact", "fact", "A genuine in-store fact.");
|
|
383
|
+
|
|
384
|
+
// An out-of-store memory reachable only via a symlinked category dir.
|
|
385
|
+
await writeFile(
|
|
386
|
+
path.join(outsideDir, "leaked.md"),
|
|
387
|
+
memoryFile("leaked-secret", "decision", "Out-of-store file must never be recalled."),
|
|
388
|
+
"utf-8",
|
|
389
|
+
);
|
|
390
|
+
// `decisions/` is a RECALL_FALLBACK_DIRS category dir; point it outside the store.
|
|
391
|
+
await symlink(outsideDir, path.join(baseDir, "decisions"), "dir");
|
|
392
|
+
|
|
393
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
394
|
+
const memories = await storage.readAllMemories();
|
|
395
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
396
|
+
|
|
397
|
+
assert.ok(foundIds.has("real-fact"), "the genuine in-store fact must still be recalled");
|
|
398
|
+
assert.ok(
|
|
399
|
+
!foundIds.has("leaked-secret"),
|
|
400
|
+
"a symlinked-out category dir must not leak files into recall",
|
|
401
|
+
);
|
|
402
|
+
assert.equal(memories.length, 1, "only the in-store memory should be returned");
|
|
403
|
+
} finally {
|
|
404
|
+
StorageManager.clearAllStaticCaches();
|
|
405
|
+
await rm(baseDir, { recursive: true, force: true });
|
|
406
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("symlinked entry nested inside a category dir does NOT leak into fallback recall", async (t) => {
|
|
411
|
+
if (process.platform === "win32") {
|
|
412
|
+
t.skip("directory symlink setup is platform-specific");
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-entry-"));
|
|
416
|
+
const outsideDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-symlink-entry-out-"));
|
|
417
|
+
const storage = new StorageManager(baseDir);
|
|
418
|
+
StorageManager.clearAllStaticCaches();
|
|
419
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
420
|
+
try {
|
|
421
|
+
// A genuine in-store memory in the same category dir that holds the symlink.
|
|
422
|
+
await seedMemory(baseDir, "facts", "real-fact", "fact", "A genuine in-store fact.");
|
|
423
|
+
|
|
424
|
+
await writeFile(
|
|
425
|
+
path.join(outsideDir, "leaked.md"),
|
|
426
|
+
memoryFile("nested-leak", "fact", "Out-of-store nested file must never be recalled."),
|
|
427
|
+
"utf-8",
|
|
428
|
+
);
|
|
429
|
+
// A symlinked *entry* inside facts/ that escapes the store.
|
|
430
|
+
await symlink(outsideDir, path.join(baseDir, "facts", "escape"), "dir");
|
|
431
|
+
|
|
432
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
433
|
+
const memories = await storage.readAllMemories();
|
|
434
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
435
|
+
|
|
436
|
+
assert.ok(foundIds.has("real-fact"), "the genuine in-store fact must still be recalled");
|
|
437
|
+
assert.ok(
|
|
438
|
+
!foundIds.has("nested-leak"),
|
|
439
|
+
"a symlinked entry escaping the store must not leak files into recall",
|
|
440
|
+
);
|
|
441
|
+
assert.equal(memories.length, 1, "only the in-store memory should be returned");
|
|
442
|
+
} finally {
|
|
443
|
+
StorageManager.clearAllStaticCaches();
|
|
444
|
+
await rm(baseDir, { recursive: true, force: true });
|
|
445
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Regression for Cursor Bugbot "Poisoned md skips sibling subdirs".
|
|
451
|
+
*
|
|
452
|
+
* Per-entry containment/realpath failures must be isolated so they cannot drop
|
|
453
|
+
* sibling `.md` files or, crucially, the nested-subdir recursion. This asserts
|
|
454
|
+
* that a category dir which also contains a symlinked-out entry still recurses
|
|
455
|
+
* into its real nested subdirectory and returns every in-store memory, while
|
|
456
|
+
* the out-of-store target stays excluded.
|
|
457
|
+
*/
|
|
458
|
+
test("a guarded/skipped sibling entry does NOT drop nested in-store memories", async (t) => {
|
|
459
|
+
if (process.platform === "win32") {
|
|
460
|
+
t.skip("directory symlink setup is platform-specific");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-sibling-"));
|
|
464
|
+
const outsideDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-sibling-out-"));
|
|
465
|
+
const storage = new StorageManager(baseDir);
|
|
466
|
+
StorageManager.clearAllStaticCaches();
|
|
467
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
468
|
+
try {
|
|
469
|
+
const factsDir = path.join(baseDir, "facts");
|
|
470
|
+
await mkdir(factsDir, { recursive: true });
|
|
471
|
+
// A top-level in-store memory and a nested in-store memory in the SAME
|
|
472
|
+
// category dir that also holds the symlinked-out sibling.
|
|
473
|
+
await writeFile(
|
|
474
|
+
path.join(factsDir, "top.md"),
|
|
475
|
+
memoryFile("top-fact", "fact", "Top-level in-store fact."),
|
|
476
|
+
"utf-8",
|
|
477
|
+
);
|
|
478
|
+
await seedMemory(baseDir, "facts", "deep-fact", "fact", "Deeply nested in-store fact.", {
|
|
479
|
+
nested: true,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
await writeFile(
|
|
483
|
+
path.join(outsideDir, "leaked.md"),
|
|
484
|
+
memoryFile("leaked-secret", "fact", "Out-of-store file must never be recalled."),
|
|
485
|
+
"utf-8",
|
|
486
|
+
);
|
|
487
|
+
// A symlinked-out sibling entry alongside the real files/subdir.
|
|
488
|
+
await symlink(outsideDir, path.join(factsDir, "escape"), "dir");
|
|
489
|
+
|
|
490
|
+
storage.invalidateAllMemoriesCacheForDir();
|
|
491
|
+
const memories = await storage.readAllMemories();
|
|
492
|
+
const foundIds = new Set(memories.map((m) => m.frontmatter.id));
|
|
493
|
+
|
|
494
|
+
assert.ok(foundIds.has("top-fact"), "the top-level in-store fact must be recalled");
|
|
495
|
+
assert.ok(
|
|
496
|
+
foundIds.has("deep-fact"),
|
|
497
|
+
"the nested in-store fact must still be recalled past a skipped sibling",
|
|
498
|
+
);
|
|
499
|
+
assert.ok(!foundIds.has("leaked-secret"), "the symlinked-out file must not leak");
|
|
500
|
+
assert.equal(memories.length, 2, "exactly the two in-store memories should be returned");
|
|
501
|
+
} finally {
|
|
502
|
+
StorageManager.clearAllStaticCaches();
|
|
503
|
+
await rm(baseDir, { recursive: true, force: true });
|
|
504
|
+
await rm(outsideDir, { recursive: true, force: true });
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
|
|
359
508
|
test("QMD-disabled deployment: disk-scan collector returns all categories", async () => {
|
|
360
509
|
const baseDir = await mkdtemp(path.join(os.tmpdir(), "engram-1497-qmd-off-"));
|
|
361
510
|
// entitySchemas is the only other constructor arg; QMD is never constructed by
|