@remnic/core 9.3.727 → 9.3.728

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 (77) hide show
  1. package/dist/access-boundary.js +6 -6
  2. package/dist/access-cli.js +11 -11
  3. package/dist/access-http.js +10 -10
  4. package/dist/access-mcp.js +9 -9
  5. package/dist/access-operations-batch.js +7 -7
  6. package/dist/access-operations.d.ts +1 -1
  7. package/dist/access-operations.js +8 -8
  8. package/dist/access-schema.d.ts +4 -4
  9. package/dist/access-service.js +5 -5
  10. package/dist/capabilities.d.ts +43 -1
  11. package/dist/capabilities.js +5 -3
  12. package/dist/{chunk-DVRHLN2O.js → chunk-3RCBIOOJ.js} +18 -2
  13. package/dist/{chunk-DVRHLN2O.js.map → chunk-3RCBIOOJ.js.map} +1 -1
  14. package/dist/{chunk-R7IYU5DZ.js → chunk-4ESAR77N.js} +2 -2
  15. package/dist/{chunk-SNKPUJHR.js → chunk-76NHR6ZL.js} +3 -3
  16. package/dist/{chunk-6PY5YJAV.js → chunk-BQCHEV7O.js} +8 -3
  17. package/dist/{chunk-6PY5YJAV.js.map → chunk-BQCHEV7O.js.map} +1 -1
  18. package/dist/{chunk-ZA2S2VLL.js → chunk-C2PG7IEH.js} +5 -2
  19. package/dist/chunk-C2PG7IEH.js.map +1 -0
  20. package/dist/{chunk-MJIHJ7OG.js → chunk-H5BWGCRZ.js} +11 -10
  21. package/dist/chunk-H5BWGCRZ.js.map +1 -0
  22. package/dist/{chunk-N3VNUM4Z.js → chunk-HGNOCPZX.js} +4 -4
  23. package/dist/{chunk-CSRXQ6NB.js → chunk-I5REUWLH.js} +10 -4
  24. package/dist/chunk-I5REUWLH.js.map +1 -0
  25. package/dist/{chunk-NSA5ZRUL.js → chunk-M4AYVEWS.js} +3 -3
  26. package/dist/{chunk-OEEW4KJA.js → chunk-O6YH5F47.js} +3 -3
  27. package/dist/{chunk-XPHVAOBT.js → chunk-QNEUZIAH.js} +45 -36
  28. package/dist/chunk-QNEUZIAH.js.map +1 -0
  29. package/dist/{chunk-27QQC7ZX.js → chunk-UM5AWJNQ.js} +6 -5
  30. package/dist/chunk-UM5AWJNQ.js.map +1 -0
  31. package/dist/{chunk-2KAYTPPT.js → chunk-UTHQPSRC.js} +2 -2
  32. package/dist/{chunk-CTOQEZSN.js → chunk-V4GNSDFH.js} +2 -2
  33. package/dist/{chunk-EHISUJFN.js → chunk-WAS3NV46.js} +6 -3
  34. package/dist/chunk-WAS3NV46.js.map +1 -0
  35. package/dist/{chunk-DKJCRGWM.js → chunk-YGIQORJ3.js} +2 -2
  36. package/dist/cli.js +13 -13
  37. package/dist/embedding-fallback.js +2 -1
  38. package/dist/extraction.js +2 -1
  39. package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-22LPFHD5.js} +6 -2
  40. package/dist/first-start-migration-22LPFHD5.js.map +1 -0
  41. package/dist/index.js +16 -16
  42. package/dist/namespaces/migrate.js +5 -4
  43. package/dist/namespaces/search.js +4 -3
  44. package/dist/operator-toolkit.js +6 -6
  45. package/dist/orchestrator.js +7 -7
  46. package/dist/schemas.d.ts +22 -22
  47. package/dist/search/embed-helper.js +2 -1
  48. package/dist/search/factory.js +3 -2
  49. package/dist/search/index.js +3 -2
  50. package/dist/transfer/types.d.ts +12 -12
  51. package/package.json +2 -2
  52. package/src/access-service.ts +2 -1
  53. package/src/capabilities.test.ts +155 -0
  54. package/src/capabilities.ts +91 -0
  55. package/src/chat/chat-engine.test.ts +39 -1
  56. package/src/chat/chat-session.ts +25 -2
  57. package/src/cli.ts +3 -2
  58. package/src/embedding-fallback.ts +2 -1
  59. package/src/extraction.ts +7 -3
  60. package/src/maintenance/first-start-migration.ts +3 -1
  61. package/src/orchestrator.ts +38 -26
  62. package/src/search/embed-helper.ts +3 -2
  63. package/dist/chunk-27QQC7ZX.js.map +0 -1
  64. package/dist/chunk-CSRXQ6NB.js.map +0 -1
  65. package/dist/chunk-EHISUJFN.js.map +0 -1
  66. package/dist/chunk-MJIHJ7OG.js.map +0 -1
  67. package/dist/chunk-XPHVAOBT.js.map +0 -1
  68. package/dist/chunk-ZA2S2VLL.js.map +0 -1
  69. package/dist/first-start-migration-PG5HBC3K.js.map +0 -1
  70. /package/dist/{chunk-R7IYU5DZ.js.map → chunk-4ESAR77N.js.map} +0 -0
  71. /package/dist/{chunk-SNKPUJHR.js.map → chunk-76NHR6ZL.js.map} +0 -0
  72. /package/dist/{chunk-N3VNUM4Z.js.map → chunk-HGNOCPZX.js.map} +0 -0
  73. /package/dist/{chunk-NSA5ZRUL.js.map → chunk-M4AYVEWS.js.map} +0 -0
  74. /package/dist/{chunk-OEEW4KJA.js.map → chunk-O6YH5F47.js.map} +0 -0
  75. /package/dist/{chunk-2KAYTPPT.js.map → chunk-UTHQPSRC.js.map} +0 -0
  76. /package/dist/{chunk-CTOQEZSN.js.map → chunk-V4GNSDFH.js.map} +0 -0
  77. /package/dist/{chunk-DKJCRGWM.js.map → chunk-YGIQORJ3.js.map} +0 -0
@@ -203,3 +203,94 @@ export function resolveAccessSetupCapabilities(
203
203
  recallAuditAnomalyDetection: config.recallAuditAnomalyDetectionEnabled === true,
204
204
  });
205
205
  }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Memory-lifecycle capability set (issue #1523 batch 3).
209
+ //
210
+ // The flags below gate memory WRITE/EXTRACTION (extraction judge, scope
211
+ // classification, dedupe, telemetry prefilter), temporal supersession of
212
+ // stored facts, and the lifecycle policy pass (promotion / decay / stale
213
+ // filtering). They are read on the persistExtraction write path, the recall
214
+ // candidate-filter path, and the maintenance / lifecycle-pass path — so, like
215
+ // the graph-construction set above, they get their own projection resolved
216
+ // ONCE at each operation entry that reads them (never re-derived from raw
217
+ // config mid-operation — the gate-divergence defect class #1523 targets).
218
+ //
219
+ // Every field projects from an already-resolved PluginConfig boolean (defaults
220
+ // are applied at the config-parse boundary, rule 36), so the resolver is a
221
+ // pure projection — no `!== false` / `=== true` re-coercion here.
222
+ // ---------------------------------------------------------------------------
223
+
224
+ /**
225
+ * Frozen projection of memory-lifecycle feature gates (issue #1523 batch 3).
226
+ *
227
+ * Resolved once per write / recall-filter / maintenance operation and threaded
228
+ * down. Composition lives ONLY in {@link resolveMemoryLifecycleCapabilities}.
229
+ */
230
+ export interface MemoryLifecycleCapabilitySet {
231
+ /** `temporalSupersessionEnabled` — supersede stale structured facts on write, filter on recall. */
232
+ readonly temporalSupersession: boolean;
233
+ /** `temporalMemoryTreeEnabled` — temporal-memory-tree build + recall tier. */
234
+ readonly temporalMemoryTree: boolean;
235
+ /** `lifecyclePolicyEnabled` — lifecycle promotion / decay metadata pass. */
236
+ readonly lifecyclePolicy: boolean;
237
+ /** `lifecycleFilterStaleEnabled` — filter stale-lifecycle memories from recall. */
238
+ readonly lifecycleFilterStale: boolean;
239
+ /** `lifecycleMetricsEnabled` — emit lifecycle-pass metrics. */
240
+ readonly lifecycleMetrics: boolean;
241
+ /** `extractionScopeClassificationEnabled` — LLM classifies each fact's scope. */
242
+ readonly extractionScopeClassification: boolean;
243
+ /** `extractionJudgeEnabled` — LLM-as-judge fact-worthiness gate. */
244
+ readonly extractionJudge: boolean;
245
+ /** `extractionDedupeEnabled` — dedupe against recent extractions. */
246
+ readonly extractionDedupe: boolean;
247
+ /** `extractionTelemetryPrefilterEnabled` — skip mechanical-telemetry transcripts. */
248
+ readonly extractionTelemetryPrefilter: boolean;
249
+ /** `extractionJudgeTelemetryEnabled` — collect judge verdict telemetry. */
250
+ readonly extractionJudgeTelemetry: boolean;
251
+ /** `embeddingFallbackEnabled` — semantic-dedup / archive-search embedding fallback. */
252
+ readonly embeddingFallback: boolean;
253
+ }
254
+
255
+ /**
256
+ * Config projection consumed by {@link resolveMemoryLifecycleCapabilities}.
257
+ */
258
+ export type MemoryLifecycleConfigProjection = Pick<
259
+ PluginConfig,
260
+ | "temporalSupersessionEnabled"
261
+ | "temporalMemoryTreeEnabled"
262
+ | "lifecyclePolicyEnabled"
263
+ | "lifecycleFilterStaleEnabled"
264
+ | "lifecycleMetricsEnabled"
265
+ | "extractionScopeClassificationEnabled"
266
+ | "extractionJudgeEnabled"
267
+ | "extractionDedupeEnabled"
268
+ | "extractionTelemetryPrefilterEnabled"
269
+ | "extractionJudgeTelemetryEnabled"
270
+ | "embeddingFallbackEnabled"
271
+ >;
272
+
273
+ /**
274
+ * Resolve the {@link MemoryLifecycleCapabilitySet} from parsed config.
275
+ *
276
+ * Call this ONCE per write / recall-filter / maintenance operation entry and
277
+ * thread the result down — exactly the pattern {@link resolveCapabilities}
278
+ * established for recall.
279
+ */
280
+ export function resolveMemoryLifecycleCapabilities(
281
+ config: MemoryLifecycleConfigProjection,
282
+ ): MemoryLifecycleCapabilitySet {
283
+ return Object.freeze({
284
+ temporalSupersession: config.temporalSupersessionEnabled,
285
+ temporalMemoryTree: config.temporalMemoryTreeEnabled,
286
+ lifecyclePolicy: config.lifecyclePolicyEnabled,
287
+ lifecycleFilterStale: config.lifecycleFilterStaleEnabled,
288
+ lifecycleMetrics: config.lifecycleMetricsEnabled,
289
+ extractionScopeClassification: config.extractionScopeClassificationEnabled,
290
+ extractionJudge: config.extractionJudgeEnabled,
291
+ extractionDedupe: config.extractionDedupeEnabled,
292
+ extractionTelemetryPrefilter: config.extractionTelemetryPrefilterEnabled,
293
+ extractionJudgeTelemetry: config.extractionJudgeTelemetryEnabled,
294
+ embeddingFallback: config.embeddingFallbackEnabled,
295
+ });
296
+ }
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { strict as assert } from "node:assert";
10
- import { mkdir, rm, readFile, utimes, unlink } from "node:fs/promises";
10
+ import { mkdir, rm, readFile, utimes, unlink, appendFile } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
12
  import { tmpdir } from "node:os";
13
13
  import { test } from "node:test";
@@ -22,6 +22,7 @@ import {
22
22
  sessionBelongsToPrincipal,
23
23
  chatSessionFile,
24
24
  cleanupExpiredChatSessions,
25
+ __resetTranscriptSeqForTest,
25
26
  } from "./chat-session.js";
26
27
  import type { ChatSessionState } from "./chat-types.js";
27
28
  import type { ChatToolExecutor } from "./chat-engine.js";
@@ -335,3 +336,40 @@ test("appendTranscriptEntry refuses to recreate a swept session (chat_session_ex
335
336
  /chat_session_expired/,
336
337
  );
337
338
  });
339
+
340
+
341
+ test("loadChatSession raises the seq counter above the on-disk max on backward clock skew (issue #1718)", async () => {
342
+ const dir = await makeTempDir();
343
+ const session = await createChatSession(dir, { principal: "skew" });
344
+ // Simulate a previous process that ran with a forward-skewed clock: append a
345
+ // transcript line whose seq is far in the future relative to the current
346
+ // Date.now(). A later restart with a normal (or backward-stepped) clock must
347
+ // still issue seqs strictly greater than this persisted line.
348
+ const futureSeq = Date.now() + 10_000_000;
349
+ await appendFile(
350
+ chatSessionFile(dir, session.id),
351
+ JSON.stringify({
352
+ seq: futureSeq,
353
+ ts: new Date().toISOString(),
354
+ role: "user",
355
+ content: "future-dated line from a forward-skewed prior process",
356
+ }) + "\n",
357
+ "utf8",
358
+ );
359
+ // Simulate a process restart whose module-level counter starts below the
360
+ // persisted on-disk seq (the backward-clock-skew window).
361
+ __resetTranscriptSeqForTest();
362
+ // Resume the session: loadChatSession must raise the counter above the
363
+ // on-disk max so the next append is strictly greater, regardless of the
364
+ // current Date.now().
365
+ const loaded = await loadChatSession(dir, session.id);
366
+ assert.ok(loaded, "session must load");
367
+ const next = await appendTranscriptEntry(dir, session.id, {
368
+ role: "assistant",
369
+ content: "resumed after backward clock skew",
370
+ });
371
+ assert.ok(
372
+ next.seq > futureSeq,
373
+ `seq ${next.seq} must exceed the on-disk max ${futureSeq} after a backward clock skew (issue #1718)`,
374
+ );
375
+ });
@@ -50,8 +50,10 @@ export function chatSessionFile(memoryDir: string, chatSessionId: string): strin
50
50
  * incremented when two appends land in the same millisecond, so `seq` is
51
51
  * strictly increasing within a process (review thread: `Date.now()` alone is
52
52
  * not monotonic — same-ms entries would share a seq and break the SSE
53
- * reconnect dedup-by-seq contract). Across process restarts `Date.now()` is
54
- * already forward-moving, so no collision occurs in practice.
53
+ * reconnect dedup-by-seq contract). Across process restarts the counter is
54
+ * raised above the on-disk max by `loadChatSession` (issue #1718), so a
55
+ * backward clock step (NTP, VM migration, manual change) cannot re-seed it
56
+ * below lines already persisted for a resumed session.
55
57
  */
56
58
  let lastTranscriptSeq = 0;
57
59
 
@@ -61,6 +63,14 @@ function nextTranscriptSeq(): number {
61
63
  return lastTranscriptSeq;
62
64
  }
63
65
 
66
+ /**
67
+ * Test-only: reset the module-level transcript seq counter so a regression
68
+ * test can deterministically simulate a fresh process restart (issue #1718).
69
+ */
70
+ export function __resetTranscriptSeqForTest(): void {
71
+ lastTranscriptSeq = 0;
72
+ }
73
+
64
74
  /**
65
75
  * Create a new chat session. The session binds the caller's principal and
66
76
  * sessionKey at creation (rule 42); every tool call flows through
@@ -175,6 +185,19 @@ export async function loadChatSession(
175
185
  // tolerated, never crash the engine).
176
186
  }
177
187
  }
188
+ // Issue #1718 — backward-clock-skew robustness: raise the module-level seq
189
+ // counter so the next append is strictly greater than any line already on
190
+ // disk for this session, regardless of clock direction across restarts
191
+ // (NTP step, VM migration, manual change). Without this, a backward clock
192
+ // skew would re-seed the counter below the on-disk max and the SSE
193
+ // reconnect dedup-by-seq contract could drop freshly-appended entries as
194
+ // duplicates. The transcript is already fully parsed above, so this is a
195
+ // single pass over the in-memory array.
196
+ for (const e of transcript) {
197
+ if (typeof e.seq === "number" && e.seq + 1 > lastTranscriptSeq) {
198
+ lastTranscriptSeq = e.seq + 1;
199
+ }
200
+ }
178
201
  return {
179
202
  id: chatSessionId,
180
203
  principal,
package/src/cli.ts CHANGED
@@ -4,6 +4,7 @@ import { access, lstat, readFile, readdir, realpath, unlink } from "node:fs/prom
4
4
  import { createHash } from "node:crypto";
5
5
  import type { Readable, Writable } from "node:stream";
6
6
  import type { Orchestrator } from "./orchestrator.js";
7
+ import { resolveMemoryLifecycleCapabilities } from "./capabilities.js";
7
8
  import { ThreadingManager } from "./threading.js";
8
9
  import { utcDayRange } from "./transcript.js";
9
10
  import { runWearablesCliCommand } from "./wearables/cli.js";
@@ -3728,7 +3729,7 @@ export function registerCli(
3728
3729
  console.log("=== Extraction Judge Verdict Stats ===\n");
3729
3730
  console.log(`Total verdicts: ${stats.total}`);
3730
3731
  if (stats.total === 0) {
3731
- if (!orchestrator.config.extractionJudgeTelemetryEnabled) {
3732
+ if (!resolveMemoryLifecycleCapabilities(orchestrator.config).extractionJudgeTelemetry) {
3732
3733
  console.log(
3733
3734
  "\nNote: extractionJudgeTelemetryEnabled is OFF. Enable it in plugin config to collect verdict telemetry.",
3734
3735
  );
@@ -8541,7 +8542,7 @@ export function registerCli(
8541
8542
  config: orchestrator.config,
8542
8543
  memoryDir: orchestrator.config.memoryDir,
8543
8544
  embeddingLookupFactory: (storage) => {
8544
- if (!orchestrator.config.embeddingFallbackEnabled) return undefined;
8545
+ if (!resolveMemoryLifecycleCapabilities(orchestrator.config).embeddingFallback) return undefined;
8545
8546
  return async (content: string, limit: number) => {
8546
8547
  try {
8547
8548
  return await orchestrator.semanticDedupLookup(content, limit, storage);
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { log } from "./logger.js";
4
+ import { resolveMemoryLifecycleCapabilities } from "./capabilities.js";
4
5
  import { readEnvVar } from "./runtime/env.js";
5
6
  import type { PluginConfig } from "./types.js";
6
7
  import {
@@ -397,7 +398,7 @@ export class EmbeddingFallback {
397
398
  private async resolveProvider(
398
399
  options: { includeHost?: boolean } = {},
399
400
  ): Promise<ProviderConfig | null> {
400
- if (!this.config.embeddingFallbackEnabled) return null;
401
+ if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) return null;
401
402
 
402
403
  if (
403
404
  options.includeHost !== false &&
package/src/extraction.ts CHANGED
@@ -38,6 +38,7 @@ import { normalizeProcedureSteps } from "./procedural/procedure-types.js";
38
38
  import { normalizeReasoningTrace } from "./reasoning-trace-types.js";
39
39
  import { looksLikeMechanicalTelemetryTranscript } from "./telemetry-transcript.js";
40
40
  import { buildFactProvenance, type ProvenanceTurnInput } from "./provenance.js";
41
+ import { resolveMemoryLifecycleCapabilities } from "./capabilities.js";
41
42
 
42
43
  type ExtractionQuestion = ExtractionResult["questions"][number];
43
44
  type ExtractedFactResult = ExtractionResult["facts"][number];
@@ -1055,6 +1056,7 @@ export class ExtractionEngine {
1055
1056
  }
1056
1057
 
1057
1058
  async extract(turns: BufferTurn[], existingEntities?: string[]): Promise<ExtractionResult> {
1059
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
1058
1060
 
1059
1061
  // Guard: skip if buffer is empty or all turns are whitespace-only
1060
1062
  const substantiveTurns = turns.filter((t) => t.content.trim().length > 0);
@@ -1083,7 +1085,7 @@ export class ExtractionEngine {
1083
1085
  return { facts: [], profileUpdates: [], entities: [], questions: [] };
1084
1086
  }
1085
1087
  if (
1086
- this.config.extractionTelemetryPrefilterEnabled &&
1088
+ lifecycleCaps.extractionTelemetryPrefilter &&
1087
1089
  looksLikeMechanicalTelemetryTranscript(conversation)
1088
1090
  ) {
1089
1091
  log.debug("extraction skipped — mechanical action/state telemetry without durable-memory cues");
@@ -1331,6 +1333,7 @@ export class ExtractionEngine {
1331
1333
  * Uses a minimal prompt to fit within local model context limits (typically 4k-8k).
1332
1334
  */
1333
1335
  private async extractWithLocalLlm(conversation: string, existingEntities?: string[]): Promise<ExtractionResult | null> {
1336
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
1334
1337
  log.debug(
1335
1338
  `extractWithLocalLlm: starting extraction, localLlmEnabled=${this.shouldUseLocalLlm}, model=${this.config.localLlmModel}`,
1336
1339
  );
@@ -1406,7 +1409,7 @@ These are durable insights - capture them:
1406
1409
  - CRITICAL: Use canonical hyphenated entity names (e.g., "jane-doe" not "janedoe")
1407
1410
  - CRITICAL: NEVER extract the same fact twice - check for duplicates before adding to facts array
1408
1411
  - CRITICAL: NEVER extract cron job schedules, automation configurations, or system monitoring details (these are operational noise)
1409
- - If uncertain about relevance, prefer NOT extracting${this.config.extractionScopeClassificationEnabled ? `
1412
+ - If uncertain about relevance, prefer NOT extracting${lifecycleCaps.extractionScopeClassification ? `
1410
1413
  - For each fact, set "scope" to "global" (cross-project knowledge: framework bugs, library behavior, user preferences, tool configs, general patterns) or "project" (codebase-specific: file paths, env configs, deployment details, project workarounds). When in doubt, prefer "project".` : ""}
1411
1414
 
1412
1415
  === Structured Attributes ===
@@ -1645,6 +1648,7 @@ Omit "eventTime" when the fact has no explicit temporal anchor — do NOT guess
1645
1648
  * Build extraction instructions shared between local and cloud LLM.
1646
1649
  */
1647
1650
  private buildExtractionInstructions(existingEntities?: string[]): string {
1651
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
1648
1652
  return `You are a memory extraction system. Analyze the following conversation and extract durable, reusable memories.
1649
1653
 
1650
1654
  Memory categories:
@@ -1681,7 +1685,7 @@ Rules:
1681
1685
  * Inferred (0.40-0.69): Pattern recognition — reasonable guess from limited evidence
1682
1686
  * Speculative (0.00-0.39): Tentative hypothesis — weak signal, needs future confirmation. Speculative memories auto-expire after 30 days if not confirmed.${this.config.provenance?.enabled ? `
1683
1687
  - Source quotes: For each fact, include a "quote" field containing the EXACT verbatim words from the conversation that support the fact. Copy a contiguous span from a single speaker turn (not a paraphrase, not a summary). Cap at ~300 characters. This grounds every memory in the literal utterance that created it.` : ""}
1684
- - For commitments: include any deadline or timeframe mentioned${this.config.extractionScopeClassificationEnabled ? `
1688
+ - For commitments: include any deadline or timeframe mentioned${lifecycleCaps.extractionScopeClassification ? `
1685
1689
 
1686
1690
  Scope classification:
1687
1691
  For each fact, set "scope" to one of:
@@ -18,6 +18,7 @@
18
18
 
19
19
  import path from "node:path";
20
20
  import { access, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
21
+ import { resolveMemoryLifecycleCapabilities } from "../capabilities.js";
21
22
  import type { StorageManager } from "../storage.js";
22
23
  import type { PluginConfig } from "../types.js";
23
24
  import {
@@ -179,6 +180,7 @@ export async function runFirstStartMigration(
179
180
  signal,
180
181
  } = options;
181
182
  const now = (options.now ?? (() => new Date()))();
183
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(config);
182
184
  const abortedResult = (candidateCount = 0, demotedCount = 0, failureCount = 0): FirstStartMigrationResult => ({
183
185
  skipped: candidateCount === 0 && demotedCount === 0 && failureCount === 0,
184
186
  skipReason: candidateCount === 0 && demotedCount === 0 && failureCount === 0 ? "aborted" : undefined,
@@ -193,7 +195,7 @@ export async function runFirstStartMigration(
193
195
  return abortedResult();
194
196
  }
195
197
 
196
- if (!config.lifecyclePolicyEnabled) {
198
+ if (!lifecycleCaps.lifecyclePolicy) {
197
199
  return {
198
200
  skipped: true,
199
201
  skipReason: "lifecyclePolicyEnabled is false",
@@ -269,8 +269,10 @@ import { tryDirectAnswer, type DirectAnswerSources } from "./direct-answer-wirin
269
269
  import {
270
270
  resolveCapabilities,
271
271
  resolveGraphConstructionCapabilities,
272
+ resolveMemoryLifecycleCapabilities,
272
273
  type CapabilitySet,
273
274
  type GraphConstructionCapabilitySet,
275
+ type MemoryLifecycleCapabilitySet,
274
276
  } from "./capabilities.js";
275
277
  import { DEFAULT_TAXONOMY } from "./taxonomy/index.js";
276
278
  import {
@@ -3094,9 +3096,10 @@ export class Orchestrator {
3094
3096
  );
3095
3097
  // BoxBuilders are created per-namespace on first use in runExtraction().
3096
3098
 
3099
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(config);
3097
3100
  // Temporal Memory Tree (v8.2) — lazy build during consolidation
3098
3101
  this.tmtBuilder = new TmtBuilder(config.memoryDir, {
3099
- temporalMemoryTreeEnabled: config.temporalMemoryTreeEnabled,
3102
+ temporalMemoryTreeEnabled: lifecycleCaps.temporalMemoryTree,
3100
3103
  tmtHourlyMinMemories: config.tmtHourlyMinMemories,
3101
3104
  tmtSummaryMaxTokens: config.tmtSummaryMaxTokens,
3102
3105
  });
@@ -3654,6 +3657,7 @@ export class Orchestrator {
3654
3657
  }
3655
3658
 
3656
3659
  private async deferredInitialize(signal: AbortSignal): Promise<void> {
3660
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
3657
3661
 
3658
3662
  // Sync QMD index with current disk state so recall finds recently-written
3659
3663
  // facts. Without this, the index stays stale from the last extraction-
@@ -3707,7 +3711,7 @@ export class Orchestrator {
3707
3711
  }),
3708
3712
  );
3709
3713
  }
3710
- if (this.config.embeddingFallbackEnabled) {
3714
+ if (resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) {
3711
3715
  warmupPromises.push(
3712
3716
  this.embeddingFallback
3713
3717
  .isAvailable()
@@ -3792,7 +3796,7 @@ export class Orchestrator {
3792
3796
  // sweep (capped at 50 demotions) so the hot tier isn't flooded on the
3793
3797
  // first real cron pass. Non-fatal — a failure here must not break init.
3794
3798
  if (signal.aborted) return;
3795
- if (this.config.lifecyclePolicyEnabled && this.config.qmdTierMigrationEnabled) {
3799
+ if (lifecycleCaps.lifecyclePolicy && this.config.qmdTierMigrationEnabled) {
3796
3800
  try {
3797
3801
  const { runFirstStartMigration } = await import(
3798
3802
  "./maintenance/first-start-migration.js"
@@ -4117,6 +4121,7 @@ export class Orchestrator {
4117
4121
  * default storage.
4118
4122
  */
4119
4123
  async runLifecyclePolicyFanout(): Promise<NamespaceMaintenanceSummary> {
4124
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
4120
4125
  return this.runNamespaceMaintenanceFanoutForJob(
4121
4126
  "lifecycle",
4122
4127
  async (ctx) => {
@@ -4125,7 +4130,7 @@ export class Orchestrator {
4125
4130
  const assessed = await this.runLifecyclePolicyPass(corpus, storage);
4126
4131
  return { itemCount: assessed };
4127
4132
  },
4128
- { enabled: this.config.lifecyclePolicyEnabled },
4133
+ { enabled: lifecycleCaps.lifecyclePolicy },
4129
4134
  );
4130
4135
  }
4131
4136
 
@@ -7438,6 +7443,7 @@ export class Orchestrator {
7438
7443
  options: RecallInvocationOptions = {},
7439
7444
  caps: CapabilitySet = resolveCapabilities(this.config),
7440
7445
  graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.config),
7446
+ lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.config),
7441
7447
  ): Promise<string> {
7442
7448
  const recallStart = Date.now();
7443
7449
  // Backend degradations observed by this recall's QMD searches (#1536):
@@ -10845,9 +10851,9 @@ export class Orchestrator {
10845
10851
  if (
10846
10852
  this.isRecallSectionEnabled(
10847
10853
  "temporal-memory-tree",
10848
- this.config.temporalMemoryTreeEnabled === true,
10854
+ lifecycleCaps.temporalMemoryTree,
10849
10855
  ) &&
10850
- this.config.temporalMemoryTreeEnabled &&
10856
+ lifecycleCaps.temporalMemoryTree &&
10851
10857
  recallMode !== "minimal" &&
10852
10858
  (recallMode as RecallPlanMode) !== "no_recall"
10853
10859
  ) {
@@ -11707,7 +11713,7 @@ export class Orchestrator {
11707
11713
  // Using the shared gate fixes both Finding 2 and Finding 3 from
11708
11714
  // PR #402 (round 6).
11709
11715
  const supersessionOptions = {
11710
- enabled: this.config.temporalSupersessionEnabled,
11716
+ enabled: lifecycleCaps.temporalSupersession,
11711
11717
  includeInRecall: this.config.temporalSupersessionIncludeInRecall,
11712
11718
  };
11713
11719
  // Cursor Medium on PR #713: when `as_of` is active, the
@@ -13231,7 +13237,8 @@ export class Orchestrator {
13231
13237
  turns: BufferTurn[],
13232
13238
  options: { commit?: boolean; bufferKey?: string } = {},
13233
13239
  ): boolean {
13234
- if (!this.config.extractionDedupeEnabled) return true;
13240
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
13241
+ if (!lifecycleCaps.extractionDedupe) return true;
13235
13242
  if (!Array.isArray(turns) || turns.length === 0) return false;
13236
13243
 
13237
13244
  const bufferKey = options.bufferKey ?? turns[0]?.sessionKey ?? "default";
@@ -14090,6 +14097,7 @@ export class Orchestrator {
14090
14097
  /** Verbatim source turn text the facts were extracted from (faithfulness gate #1576). */
14091
14098
  sourceText?: string,
14092
14099
  graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.config),
14100
+ lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.config),
14093
14101
  ): Promise<string[]> {
14094
14102
  // Inline source attribution (issue #369). When enabled, every extracted
14095
14103
  // fact is rewritten to carry a compact provenance tag inside its body so
@@ -14388,7 +14396,7 @@ export class Orchestrator {
14388
14396
  },
14389
14397
  );
14390
14398
  if (
14391
- this.config.temporalSupersessionEnabled &&
14399
+ lifecycleCaps.temporalSupersession &&
14392
14400
  options.category === "fact" &&
14393
14401
  options.entityRef &&
14394
14402
  options.structuredAttributes &&
@@ -14552,7 +14560,7 @@ export class Orchestrator {
14552
14560
  // would have run post-writeMemory. This is a best-effort / fail-open
14553
14561
  // step — if the lookup fails we skip silently (same as the normal path).
14554
14562
  if (
14555
- this.config.temporalSupersessionEnabled &&
14563
+ lifecycleCaps.temporalSupersession &&
14556
14564
  options.entityRef &&
14557
14565
  options.structuredAttributes &&
14558
14566
  Object.keys(options.structuredAttributes).length > 0
@@ -14714,7 +14722,7 @@ export class Orchestrator {
14714
14722
  // shared recall continues returning the stale state. Reuses the same
14715
14723
  // applyTemporalSupersession helper — no logic duplication.
14716
14724
  if (
14717
- this.config.temporalSupersessionEnabled &&
14725
+ lifecycleCaps.temporalSupersession &&
14718
14726
  options.entityRef &&
14719
14727
  options.structuredAttributes &&
14720
14728
  Object.keys(options.structuredAttributes).length > 0
@@ -15024,7 +15032,7 @@ export class Orchestrator {
15024
15032
  // persistExtraction call so stale state from a prior call cannot leak
15025
15033
  // into the caller's buffer-retention decision.
15026
15034
  this.lastPersistExtractionDeferredCount = 0;
15027
- if (this.config.extractionJudgeEnabled) {
15035
+ if (lifecycleCaps.extractionJudge) {
15028
15036
  // #1669 P1: pre-filter redacted facts from judge candidates so never-store
15029
15037
  // content is not persisted as judge training data.
15030
15038
  let preJudgeRedactionRules: CompiledRedactionRule[] = [];
@@ -15090,7 +15098,7 @@ export class Orchestrator {
15090
15098
  // off; the combined callback itself is undefined when both are
15091
15099
  // disabled so there is zero overhead in the default configuration.
15092
15100
  const judgeTelemetryOpts = {
15093
- enabled: this.config.extractionJudgeTelemetryEnabled === true,
15101
+ enabled: lifecycleCaps.extractionJudgeTelemetry,
15094
15102
  memoryDir: this.config.memoryDir,
15095
15103
  };
15096
15104
  const judgeTrainingOpts = {
@@ -15301,7 +15309,7 @@ export class Orchestrator {
15301
15309
  // do not block scope routing). Rule 30: gated by
15302
15310
  // extractionScopeClassificationEnabled.
15303
15311
  if (
15304
- this.config.extractionScopeClassificationEnabled &&
15312
+ lifecycleCaps.extractionScopeClassification &&
15305
15313
  this.config.namespacesEnabled &&
15306
15314
  fact.scope === "global" &&
15307
15315
  !routedNamespaceExplicit
@@ -15954,7 +15962,7 @@ export class Orchestrator {
15954
15962
  // #1578 r3: an extracted end-only bound (validFrom absent) is
15955
15963
  // historical, not a new authoritative state — never let it
15956
15964
  // supersede a later active fact (codex P1 on :15534).
15957
- enabled: this.config.temporalSupersessionEnabled &&
15965
+ enabled: lifecycleCaps.temporalSupersession &&
15958
15966
  !(biTemporal && !biTemporal.validFrom),
15959
15967
  });
15960
15968
  } catch (err) {
@@ -16190,7 +16198,7 @@ export class Orchestrator {
16190
16198
  entityRef: supersessionEntityRef,
16191
16199
  structuredAttributes: fact.structuredAttributes,
16192
16200
  createdAt: supersessionOrderingAt(biTemporal?.validFrom ?? sourceContext?.validAt),
16193
- enabled: this.config.temporalSupersessionEnabled &&
16201
+ enabled: lifecycleCaps.temporalSupersession &&
16194
16202
  !(biTemporal && !biTemporal.validFrom),
16195
16203
  });
16196
16204
  } catch (err) {
@@ -16557,7 +16565,7 @@ export class Orchestrator {
16557
16565
  storage: StorageManager,
16558
16566
  memoryId: string,
16559
16567
  ): Promise<void> {
16560
- if (!this.config.embeddingFallbackEnabled) return;
16568
+ if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) return;
16561
16569
  if (!(await this.embeddingFallback.isAvailable())) return;
16562
16570
  const memory = await storage.getMemoryById(memoryId);
16563
16571
  if (!memory) return;
@@ -16749,6 +16757,7 @@ export class Orchestrator {
16749
16757
  merged: number;
16750
16758
  invalidated: number;
16751
16759
  }> {
16760
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
16752
16761
  log.info("running consolidation pass");
16753
16762
  let merged = 0;
16754
16763
  let invalidated = 0;
@@ -16985,7 +16994,7 @@ export class Orchestrator {
16985
16994
  }
16986
16995
 
16987
16996
  // v8.3 Lifecycle policy pass — deterministic promotion/decay metadata
16988
- if (this.config.lifecyclePolicyEnabled) {
16997
+ if (lifecycleCaps.lifecyclePolicy) {
16989
16998
  try {
16990
16999
  const lightSleepStartedAt = new Date().toISOString();
16991
17000
  const lifecycleCorpus = await this.storage.readAllMemories();
@@ -17173,7 +17182,7 @@ export class Orchestrator {
17173
17182
  await this.storage.saveMeta(meta);
17174
17183
 
17175
17184
  // Temporal Memory Tree (v8.2) — rebuild nodes from all memories, fail-open
17176
- if (this.config.temporalMemoryTreeEnabled) {
17185
+ if (lifecycleCaps.temporalMemoryTree) {
17177
17186
  try {
17178
17187
  const tmtEntries = allMemories
17179
17188
  .filter(
@@ -17715,6 +17724,7 @@ export class Orchestrator {
17715
17724
  let updatedCount = 0;
17716
17725
  let disputedCount = 0;
17717
17726
  let evaluatedCount = 0;
17727
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
17718
17728
 
17719
17729
  const thresholds = this.effectiveLifecycleThresholds();
17720
17730
  const policy = {
@@ -17780,7 +17790,7 @@ export class Orchestrator {
17780
17790
 
17781
17791
  // Report how many memories had frontmatter rewritten so callers can record a
17782
17792
  // catalog write touch for lifecycle-only passes (codex NR-tS).
17783
- if (!this.config.lifecycleMetricsEnabled) return updatedCount;
17793
+ if (!lifecycleCaps.lifecycleMetrics) return updatedCount;
17784
17794
 
17785
17795
  const total = evaluatedCount;
17786
17796
  const metrics = {
@@ -19226,7 +19236,7 @@ export class Orchestrator {
19226
19236
  // • search() throws → re-throw (network/provider error).
19227
19237
  // • search() returns [] → return [] (empty index, not a
19228
19238
  // backend failure; decideSemanticDedup reports no_candidates).
19229
- if (!this.config.embeddingFallbackEnabled) {
19239
+ if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) {
19230
19240
  throw new Error("semantic dedup: embedding backend not configured");
19231
19241
  }
19232
19242
  if (!(await this.embeddingFallback.isAvailable())) {
@@ -19300,7 +19310,7 @@ export class Orchestrator {
19300
19310
  query: string,
19301
19311
  limit: number,
19302
19312
  ): Promise<QmdSearchResult[]> {
19303
- if (!this.config.embeddingFallbackEnabled) return [];
19313
+ if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) return [];
19304
19314
  if (!(await this.embeddingFallback.isAvailable())) return [];
19305
19315
  const hits = await this.embeddingFallback.search(query, limit);
19306
19316
  if (hits.length === 0) return [];
@@ -20010,6 +20020,7 @@ export class Orchestrator {
20010
20020
  blockedPaths?: Set<string>;
20011
20021
  },
20012
20022
  ): QmdSearchResult[] {
20023
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
20013
20024
  let lifecycleFilteredCount = 0;
20014
20025
  let temporalSupersededFilteredCount = 0;
20015
20026
  let biTemporalExpiredFilteredCount = 0;
@@ -20043,9 +20054,9 @@ export class Orchestrator {
20043
20054
  if (
20044
20055
  options?.allowLifecycleFiltered !== true &&
20045
20056
  shouldFilterLifecycleRecallCandidate(memory.frontmatter, {
20046
- lifecyclePolicyEnabled: this.config.lifecyclePolicyEnabled,
20057
+ lifecyclePolicyEnabled: lifecycleCaps.lifecyclePolicy,
20047
20058
  lifecycleFilterStaleEnabled:
20048
- this.config.lifecycleFilterStaleEnabled,
20059
+ lifecycleCaps.lifecycleFilterStale,
20049
20060
  })
20050
20061
  ) {
20051
20062
  lifecycleFilteredCount += 1;
@@ -20079,7 +20090,7 @@ export class Orchestrator {
20079
20090
  // half-open `[valid_at, invalid_at)` evaluation in isValidAsOf
20080
20091
  // is the authoritative gate for historical recall.
20081
20092
  shouldFilterSupersededFromRecall(memory.frontmatter, {
20082
- enabled: this.config.temporalSupersessionEnabled,
20093
+ enabled: lifecycleCaps.temporalSupersession,
20083
20094
  includeInRecall: this.config.temporalSupersessionIncludeInRecall,
20084
20095
  })
20085
20096
  ) {
@@ -20223,6 +20234,7 @@ export class Orchestrator {
20223
20234
  asOfMs?: number;
20224
20235
  },
20225
20236
  ): Promise<QmdSearchResult[]> {
20237
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.config);
20226
20238
  if (results.length === 0) return results;
20227
20239
 
20228
20240
  const safety = await this.filterSearchResultsForRecall(
@@ -20397,7 +20409,7 @@ export class Orchestrator {
20397
20409
  const lifecycleDelta = lifecycleRecallScoreAdjustment(
20398
20410
  memory.frontmatter,
20399
20411
  {
20400
- lifecyclePolicyEnabled: this.config.lifecyclePolicyEnabled,
20412
+ lifecyclePolicyEnabled: lifecycleCaps.lifecyclePolicy,
20401
20413
  },
20402
20414
  );
20403
20415
  score += applyUtilityRankingRuntimeDelta(
@@ -1,4 +1,5 @@
1
1
  import { log } from "../logger.js";
2
+ import { resolveMemoryLifecycleCapabilities } from "../capabilities.js";
2
3
  import type { PluginConfig } from "../types.js";
3
4
  import { isAbortError } from "../abort-error.js";
4
5
  import { withTimeoutSignal } from "./abort.js";
@@ -209,7 +210,7 @@ export class EmbedHelper {
209
210
  }
210
211
 
211
212
  private resolveProvider(options: { includeHost?: boolean } = {}): ProviderConfig | null {
212
- if (!this.config.embeddingFallbackEnabled) return null;
213
+ if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) return null;
213
214
 
214
215
  if (
215
216
  options.includeHost !== false &&
@@ -244,7 +245,7 @@ export class EmbedHelper {
244
245
  }
245
246
 
246
247
  private resolveFallbackProviderForIdentity(identity: EmbedProviderIdentity): ProviderConfig | null {
247
- if (!this.config.embeddingFallbackEnabled) return null;
248
+ if (!resolveMemoryLifecycleCapabilities(this.config).embeddingFallback) return null;
248
249
  const separator = identity.indexOf(":");
249
250
  if (separator <= 0 || separator === identity.length - 1) return null;
250
251
  const type = identity.slice(0, separator);