@remnic/core 10.0.64 → 10.0.66

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 (104) hide show
  1. package/dist/access-admin-ops-surface.js +2 -2
  2. package/dist/access-authorization-probe.js +3 -3
  3. package/dist/access-boundary.js +2 -2
  4. package/dist/access-cli.js +21 -21
  5. package/dist/access-extraction-force-flush.js +2 -2
  6. package/dist/access-http-briefing.js +3 -3
  7. package/dist/access-http-query.js +2 -2
  8. package/dist/access-http.js +14 -14
  9. package/dist/access-identity-continuity-surface.js +2 -2
  10. package/dist/access-lcm-surface.js +2 -2
  11. package/dist/access-mcp.js +9 -9
  12. package/dist/access-namespace-preflight.js +2 -2
  13. package/dist/access-observe-write-surface.js +2 -2
  14. package/dist/access-operations-batch.js +3 -3
  15. package/dist/access-operations.js +8 -8
  16. package/dist/access-recall-concurrency.js +2 -2
  17. package/dist/access-recall-response.js +2 -2
  18. package/dist/access-recall-surface.js +2 -2
  19. package/dist/access-service.js +2 -2
  20. package/dist/access-surface-catalog.js +4 -4
  21. package/dist/buffer.js +2 -2
  22. package/dist/{chunk-3LSTL6EY.js → chunk-3H25IRJJ.js} +2 -2
  23. package/dist/{chunk-HRRAOTOQ.js → chunk-3NJJQAL3.js} +8 -8
  24. package/dist/{chunk-FKGLJJVS.js → chunk-A5FOXI3B.js} +2 -2
  25. package/dist/{chunk-JPY3RMG2.js → chunk-E6VQJ75T.js} +2 -2
  26. package/dist/{chunk-MRF5V5CD.js → chunk-F32JRMX2.js} +2 -2
  27. package/dist/{chunk-N4T5FW6U.js → chunk-FMYAU5MP.js} +2 -2
  28. package/dist/{chunk-OVL5AD3N.js → chunk-IPCSZLLF.js} +2 -2
  29. package/dist/{chunk-7JYK7NNY.js → chunk-JYXFIILI.js} +2 -2
  30. package/dist/{chunk-HOR4RMUM.js → chunk-KFPJIKUW.js} +5 -5
  31. package/dist/{chunk-KQJUEQ2E.js → chunk-PL75KQGF.js} +3 -3
  32. package/dist/{chunk-KNMSD2MQ.js → chunk-QAN5ZDRS.js} +2 -2
  33. package/dist/{chunk-6D3IWYNY.js → chunk-QNZPYH36.js} +6 -6
  34. package/dist/chunk-QNZPYH36.js.map +1 -0
  35. package/dist/{chunk-GRAUQXQB.js → chunk-RAU25Q6J.js} +2 -2
  36. package/dist/{chunk-HHL4MX3W.js → chunk-SH6ZYUTX.js} +2 -2
  37. package/dist/{chunk-T22FBDG3.js → chunk-T763ADEK.js} +2 -2
  38. package/dist/{chunk-UH6ROXFJ.js → chunk-VC65GNUS.js} +2 -2
  39. package/dist/{chunk-YJQJRXUI.js → chunk-VC7GKR2V.js} +2 -2
  40. package/dist/{chunk-R7WYAXPO.js → chunk-VHSQU3RO.js} +81 -42
  41. package/dist/chunk-VHSQU3RO.js.map +1 -0
  42. package/dist/{chunk-JL5HXL25.js → chunk-VSZNPAGL.js} +2 -2
  43. package/dist/{chunk-2ZSRACNK.js → chunk-XMBDI725.js} +15 -38
  44. package/dist/chunk-XMBDI725.js.map +1 -0
  45. package/dist/{chunk-P2EM5LKH.js → chunk-ZGHPELZ6.js} +2 -2
  46. package/dist/cli.js +16 -16
  47. package/dist/external-wiki-access.js +3 -3
  48. package/dist/extraction.js +3 -3
  49. package/dist/index.js +21 -21
  50. package/dist/memory-browse-operations.js +3 -3
  51. package/dist/orchestration/buffer-drain-runner.js +21 -21
  52. package/dist/orchestration/extraction-persist-deps.d.ts +0 -2
  53. package/dist/orchestration/extraction-persist-deps.d.ts.map +1 -1
  54. package/dist/orchestration/extraction-persist.d.ts +10 -4
  55. package/dist/orchestration/extraction-persist.d.ts.map +1 -1
  56. package/dist/orchestration/extraction-run-helpers.d.ts +3 -2
  57. package/dist/orchestration/extraction-run-helpers.d.ts.map +1 -1
  58. package/dist/orchestration/extraction-run.d.ts +3 -6
  59. package/dist/orchestration/extraction-run.d.ts.map +1 -1
  60. package/dist/orchestrator.d.ts +2 -17
  61. package/dist/orchestrator.d.ts.map +1 -1
  62. package/dist/orchestrator.js +21 -21
  63. package/dist/recall-navigation-operations.js +3 -3
  64. package/dist/recall-navigation.js +2 -2
  65. package/dist/source-agent-qualifier.js +2 -2
  66. package/dist/support-passport/index.js +5 -5
  67. package/dist/threading.d.ts +21 -4
  68. package/dist/threading.d.ts.map +1 -1
  69. package/dist/threading.js +1 -1
  70. package/package.json +2 -2
  71. package/src/access-observe-replay-durability.test.ts +1 -1
  72. package/src/connector-provenance-lifecycle.test.ts +5 -8
  73. package/src/faithfulness-graph-guard.test.ts +11 -6
  74. package/src/harmonic-construction.test.ts +16 -10
  75. package/src/orchestration/extraction-persist-deps.ts +0 -2
  76. package/src/orchestration/extraction-persist.ts +18 -10
  77. package/src/orchestration/extraction-run-helpers.ts +5 -4
  78. package/src/orchestration/extraction-run.test.ts +1 -2
  79. package/src/orchestration/extraction-run.ts +6 -6
  80. package/src/orchestrator.ts +6 -22
  81. package/src/testing/orchestrator-lite.ts +1 -1
  82. package/src/threading.test.ts +153 -0
  83. package/src/threading.ts +95 -45
  84. package/dist/chunk-2ZSRACNK.js.map +0 -1
  85. package/dist/chunk-6D3IWYNY.js.map +0 -1
  86. package/dist/chunk-R7WYAXPO.js.map +0 -1
  87. /package/dist/{chunk-3LSTL6EY.js.map → chunk-3H25IRJJ.js.map} +0 -0
  88. /package/dist/{chunk-HRRAOTOQ.js.map → chunk-3NJJQAL3.js.map} +0 -0
  89. /package/dist/{chunk-FKGLJJVS.js.map → chunk-A5FOXI3B.js.map} +0 -0
  90. /package/dist/{chunk-JPY3RMG2.js.map → chunk-E6VQJ75T.js.map} +0 -0
  91. /package/dist/{chunk-MRF5V5CD.js.map → chunk-F32JRMX2.js.map} +0 -0
  92. /package/dist/{chunk-N4T5FW6U.js.map → chunk-FMYAU5MP.js.map} +0 -0
  93. /package/dist/{chunk-OVL5AD3N.js.map → chunk-IPCSZLLF.js.map} +0 -0
  94. /package/dist/{chunk-7JYK7NNY.js.map → chunk-JYXFIILI.js.map} +0 -0
  95. /package/dist/{chunk-HOR4RMUM.js.map → chunk-KFPJIKUW.js.map} +0 -0
  96. /package/dist/{chunk-KQJUEQ2E.js.map → chunk-PL75KQGF.js.map} +0 -0
  97. /package/dist/{chunk-KNMSD2MQ.js.map → chunk-QAN5ZDRS.js.map} +0 -0
  98. /package/dist/{chunk-GRAUQXQB.js.map → chunk-RAU25Q6J.js.map} +0 -0
  99. /package/dist/{chunk-HHL4MX3W.js.map → chunk-SH6ZYUTX.js.map} +0 -0
  100. /package/dist/{chunk-T22FBDG3.js.map → chunk-T763ADEK.js.map} +0 -0
  101. /package/dist/{chunk-UH6ROXFJ.js.map → chunk-VC65GNUS.js.map} +0 -0
  102. /package/dist/{chunk-YJQJRXUI.js.map → chunk-VC7GKR2V.js.map} +0 -0
  103. /package/dist/{chunk-JL5HXL25.js.map → chunk-VSZNPAGL.js.map} +0 -0
  104. /package/dist/{chunk-P2EM5LKH.js.map → chunk-ZGHPELZ6.js.map} +0 -0
@@ -127,6 +127,16 @@ import {
127
127
  normalizeStoredHashSource as normalizeStoredHashSourceContent,
128
128
  } from "./extraction-persist-citation.js";
129
129
 
130
+ /** One extraction run's persist outcome: per-run values, no shared side channels (#1526). */
131
+ export interface ExtractionPersistResult {
132
+ persistedIds: string[];
133
+ memoryPathById: Map<string, string>;
134
+ /** Facts the judge deferred — drives this run's buffer retention (#562). */
135
+ deferredCount: number;
136
+ /** Persisted pending_review ids, excluded from the thread episode set (#1635). */
137
+ pendingReviewIds: string[];
138
+ }
139
+
130
140
  export class ExtractionPersistCoordinator {
131
141
  constructor(
132
142
  private readonly deps: ExtractionPersistDeps,
@@ -149,7 +159,7 @@ export class ExtractionPersistCoordinator {
149
159
  sourceText?: string,
150
160
  graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.deps.config),
151
161
  lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.deps.config),
152
- ): Promise<{ persistedIds: string[]; memoryPathById: Map<string, string> }> {
162
+ ): Promise<ExtractionPersistResult> {
153
163
  const citationEnabled = resolvePipelineProcessingCapabilities(this.deps.config).inlineSourceAttribution === true;
154
164
  const citationTemplate = this.deps.config.inlineSourceAttributionFormat;
155
165
  const factDedupEnabled = resolveRecallAuxiliaryCapabilities(this.deps.config).factDeduplication;
@@ -190,6 +200,10 @@ export class ExtractionPersistCoordinator {
190
200
  validAt && validAt.length > 0 ? validAt : new Date().toISOString();
191
201
  // #1635: pending_review persisted ids, excluded from the thread episode set below.
192
202
  const pendingReviewPersistedIds: string[] = [];
203
+ // This run's judge-deferred count, returned to the caller (issue #562 PR 2)
204
+ // instead of shared side-channel state, so concurrent extractions cannot
205
+ // read each other's retention decision.
206
+ let deferredCount = 0;
193
207
  const persistedIdsByStorage = new Map<string, { storage: StorageManager; ids: string[] }>();
194
208
  const trackPersistedId = (
195
209
  targetStorage: StorageManager,
@@ -1071,7 +1085,7 @@ export class ExtractionPersistCoordinator {
1071
1085
  "persistExtraction: result or result.facts is invalid, skipping",
1072
1086
  { resultType: typeof result, factsType: typeof result?.facts },
1073
1087
  );
1074
- return { persistedIds, memoryPathById };
1088
+ return { persistedIds, memoryPathById, deferredCount, pendingReviewIds: pendingReviewPersistedIds };
1075
1089
  }
1076
1090
 
1077
1091
  // Chunking config from plugin settings
@@ -1243,10 +1257,6 @@ export class ExtractionPersistCoordinator {
1243
1257
  // be persisted, not the raw extraction-time category.
1244
1258
  let judgeVerdictsByFactIndex: Map<number, import("../extraction-judge.js").JudgeVerdict> | null = null;
1245
1259
  let judgeGatedCount = 0;
1246
- // Reset the side-channel defer count at the start of every
1247
- // persistExtraction call so stale state from a prior call cannot leak
1248
- // into the caller's buffer-retention decision.
1249
- this.deps.setLastPersistExtractionDeferredCount(0);
1250
1260
  if (lifecycleCaps.extractionJudge) {
1251
1261
  // #1669 P1 + #1713 Item 1: pre-filter redacted facts from judge candidates
1252
1262
  // so never-store content is not persisted as judge training data.
@@ -1433,7 +1443,7 @@ export class ExtractionPersistCoordinator {
1433
1443
  );
1434
1444
  // Expose defer count to the caller (issue #562 PR 2) so it can decide
1435
1445
  // whether to retain buffer turns for the next extraction pass.
1436
- this.deps.setLastPersistExtractionDeferredCount(judgeResult.deferred);
1446
+ deferredCount = judgeResult.deferred;
1437
1447
  } catch (err) {
1438
1448
  // Fail-open: if the entire judge pipeline errors, proceed without filtering
1439
1449
  log.warn(
@@ -2978,9 +2988,7 @@ export class ExtractionPersistCoordinator {
2978
2988
  `persisted: ${facts.length - dedupedCount - importanceGatedCount - judgeGatedCount - redactionGatedCount - semanticMergedCount} facts${dedupSuffix}${gatedSuffix}${judgeSuffix}${redactionSuffix}, ${entities.length} entities, ${questions.length} questions, ${profileUpdates.length} profile updates`,
2979
2989
  );
2980
2990
 
2981
- // #1635: surface pending_review ids so the thread episode set excludes them.
2982
- this.deps.setLastPersistExtractionPendingReviewIds(pendingReviewPersistedIds);
2983
2991
  // Return the persisted fact IDs for threading
2984
- return { persistedIds, memoryPathById };
2992
+ return { persistedIds, memoryPathById, deferredCount, pendingReviewIds: pendingReviewPersistedIds };
2985
2993
  }
2986
2994
  }
@@ -177,7 +177,7 @@ export async function runExtractionPostPersistBestEffort(
177
177
  export interface ExtractionDeferRetentionOptions {
178
178
  clearBufferAfterExtraction: boolean;
179
179
  extractionJudgeShadow: boolean;
180
- getDeferredCount: () => number;
180
+ deferredCount: number;
181
181
  normalizedTurns: BufferTurn[];
182
182
  bufferKey: string;
183
183
  retainDeferredTurns: (bufferKey: string, turns: BufferTurn[], max: number) => Promise<void>;
@@ -193,7 +193,7 @@ export async function runExtractionDeferRetention(
193
193
  ): Promise<void> {
194
194
  await runPostPersistBestEffort("during_defer_retention", async () => {
195
195
  if (options.clearBufferAfterExtraction && !options.extractionJudgeShadow) {
196
- const deferredCount = options.getDeferredCount();
196
+ const deferredCount = options.deferredCount;
197
197
  if (deferredCount > 0 && options.normalizedTurns.length > 0) {
198
198
  await options.retainDeferredTurns(options.bufferKey, options.normalizedTurns, 10);
199
199
  } else {
@@ -229,7 +229,8 @@ export interface ExtractionPostPersistOptions {
229
229
  options?: { ignoreAbort?: boolean; ignoreDeadline?: boolean; propagateErrors?: boolean },
230
230
  ) => Promise<void>;
231
231
  extractionJudgeShadow: boolean;
232
- getDeferredCount: () => number;
232
+ /** Facts this run's persist deferred to the judge — drives retention (#1523). */
233
+ deferredCount: number;
233
234
  normalizedTurns: BufferTurn[];
234
235
  bufferKey: string;
235
236
  retainDeferredTurns: (bufferKey: string, turns: BufferTurn[], max: number) => Promise<void>;
@@ -283,7 +284,7 @@ export async function runExtractionPostPersist(
283
284
  await runExtractionDeferRetention(runBestEffort, {
284
285
  clearBufferAfterExtraction: options.clearBufferAfterExtraction,
285
286
  extractionJudgeShadow: options.extractionJudgeShadow,
286
- getDeferredCount: options.getDeferredCount,
287
+ deferredCount: options.deferredCount,
287
288
  normalizedTurns: options.normalizedTurns,
288
289
  bufferKey: options.bufferKey,
289
290
  retainDeferredTurns: options.retainDeferredTurns,
@@ -175,7 +175,7 @@ async function makeHarness(overrides: Record<string, unknown> = {}): Promise<Har
175
175
  persistExtraction: async () => {
176
176
  persistCalls += 1;
177
177
  await persistHandler();
178
- return { persistedIds: ["memory-1"], memoryPathById: new Map() };
178
+ return { persistedIds: ["memory-1"], memoryPathById: new Map(), deferredCount: 0, pendingReviewIds: [] };
179
179
  },
180
180
  maybeCapturePassiveCorrections: async (_turns, options) => {
181
181
  await passiveCaptureHandler();
@@ -193,7 +193,6 @@ async function makeHarness(overrides: Record<string, unknown> = {}): Promise<Har
193
193
  maybeScheduleConsolidation: () => {},
194
194
  requestQmdMaintenance: () => {},
195
195
  runTierMigrationCycle: async () => migrationSummary,
196
- getLastPersistExtractionDeferredCount: () => 0,
197
196
  recordProcessedExtractionFingerprint: async (storage, fingerprint, meta) => {
198
197
  recordedProcessedCount += 1;
199
198
  await fingerprintRecorder(storage, fingerprint, meta);
@@ -71,7 +71,7 @@ export interface ExtractionRunCoordinatorDeps {
71
71
  sourceText?: string,
72
72
  graphCaps?: GraphConstructionCapabilitySet,
73
73
  lifecycleCaps?: MemoryLifecycleCapabilitySet
74
- ) => Promise<{ persistedIds: string[]; memoryPathById: Map<string, string> }>;
74
+ ) => Promise<ExtractionPersistResult>;
75
75
 
76
76
  maybeCapturePassiveCorrections: (
77
77
  turns: readonly BufferTurn[],
@@ -89,7 +89,7 @@ export interface ExtractionRunCoordinatorDeps {
89
89
  getCodingContextForSession: (sessionKey: string) => CodingContext | null;
90
90
  applyCodingNamespaceOverlay: (sessionKey: string, namespace: string) => string;
91
91
  boxBuilderFor: (storage: StorageManager) => Pick<BoxBuilder, "onExtraction">;
92
- appendPersistedThreadEpisodes: (threadId: string, ids: string[]) => Promise<void>;
92
+ appendPersistedThreadEpisodes: (threadId: string, ids: string[], pendingReviewIds: string[]) => Promise<void>;
93
93
  maybeScheduleConsolidation: (nonZero: boolean) => void;
94
94
  requestQmdMaintenance: () => void;
95
95
  runTierMigrationCycle: (
@@ -101,7 +101,6 @@ export interface ExtractionRunCoordinatorDeps {
101
101
  force?: boolean;
102
102
  }
103
103
  ) => Promise<TierMigrationCycleSummary>;
104
- getLastPersistExtractionDeferredCount: () => number;
105
104
  recordProcessedExtractionFingerprint: (
106
105
  storage: StorageManager,
107
106
  fingerprint: string,
@@ -125,6 +124,7 @@ import {
125
124
  EXTRACTION_RETRY_STATE_MAX_ENTRIES,
126
125
  } from "./extraction-run-helpers.js";
127
126
  import type { ExtractionResilienceStatus, ExtractionRetryStateEntry } from "./extraction-run-helpers.js";
127
+ import type { ExtractionPersistResult } from "./extraction-persist.js";
128
128
 
129
129
  export {
130
130
  capExtractionRetryStateEntries,
@@ -1069,7 +1069,7 @@ export class ExtractionRunCoordinator {
1069
1069
  throwIfDeadlineExceeded("before_persist");
1070
1070
  throwIfAborted("before_persist");
1071
1071
 
1072
- const { persistedIds } = await this.deps.persistExtraction(
1072
+ const { persistedIds, deferredCount, pendingReviewIds } = await this.deps.persistExtraction(
1073
1073
  result,
1074
1074
  storage,
1075
1075
  threadIdForExtraction,
@@ -1106,7 +1106,7 @@ export class ExtractionRunCoordinator {
1106
1106
  clearBufferAfterExtraction,
1107
1107
  runPostPersistBestEffort,
1108
1108
  extractionJudgeShadow: this.config.extractionJudgeShadow,
1109
- getDeferredCount: () => this.deps.getLastPersistExtractionDeferredCount(),
1109
+ deferredCount,
1110
1110
  normalizedTurns: normalizedTurns as BufferTurn[],
1111
1111
  bufferKey,
1112
1112
  retainDeferredTurns: (key, retainedTurns, max) =>
@@ -1155,7 +1155,7 @@ export class ExtractionRunCoordinator {
1155
1155
  // always attached to the thread. The helper excludes pending_review ids (#1635).
1156
1156
  if (resolvePresentationCapabilities(this.config).threading && threadIdForExtraction && persistedIds.length > 0) {
1157
1157
  await runPostPersistBestEffort("during_thread_episode_append", () =>
1158
- this.deps.appendPersistedThreadEpisodes(threadIdForExtraction, persistedIds),
1158
+ this.deps.appendPersistedThreadEpisodes(threadIdForExtraction, persistedIds, pendingReviewIds),
1159
1159
  );
1160
1160
  }
1161
1161
 
@@ -123,7 +123,7 @@ import {
123
123
  type ExtractionResilienceStatus,
124
124
  } from "./orchestration/extraction-run.js";
125
125
  import { ConsolidationRunCoordinator } from "./orchestration/consolidation-run.js";
126
- import { ExtractionPersistCoordinator } from "./orchestration/extraction-persist.js";
126
+ import { ExtractionPersistCoordinator, type ExtractionPersistResult } from "./orchestration/extraction-persist.js";
127
127
  import type { ExtractionSourceContext } from "./orchestration/extraction-origin-context.js";
128
128
  import { drainExtractionAndShutdownDependencyPropagation } from "./orchestration/extraction-shutdown.js";
129
129
  import {
@@ -614,19 +614,6 @@ export class Orchestrator {
614
614
  * (rule 11) — these hang off the orchestrator instance.
615
615
  */
616
616
  private faithfulnessCounters: FaithfulnessGateCounters = createFaithfulnessCounters();
617
- /**
618
- * Side-channel: number of facts deferred in the most recent
619
- * `persistExtraction` call (issue #562, PR 2). The caller reads this after
620
- * `persistExtraction` returns to decide whether to retain buffer turns for
621
- * the next extraction pass. Not part of the return signature because many
622
- * callers already destructure `persistedIds` by position.
623
- */
624
- private lastPersistExtractionDeferredCount: number = 0;
625
- /**
626
- * Side-channel (#1635): pending_review persisted ids from the last
627
- * persistExtraction; runExtraction excludes them from the thread episode set.
628
- */
629
- private lastPersistExtractionPendingReviewIds: string[] = [];
630
617
  private readonly _fastGatewayLlm: FallbackLlmClient | null;
631
618
 
632
619
  get fastGatewayLlm(): FallbackLlmClient | null {
@@ -785,11 +772,11 @@ export class Orchestrator {
785
772
  getCodingContextForSession: (sessionKey) => this.getCodingContextForSession(sessionKey),
786
773
  applyCodingNamespaceOverlay: (sessionKey, namespace) => this.applyCodingNamespaceOverlay(sessionKey, namespace),
787
774
  boxBuilderFor: (storage) => this.boxBuilderFor(storage),
788
- appendPersistedThreadEpisodes: (threadId, ids) => this.appendPersistedThreadEpisodes(threadId, ids),
775
+ appendPersistedThreadEpisodes: (threadId, ids, pendingReviewIds) =>
776
+ this.appendPersistedThreadEpisodes(threadId, ids, pendingReviewIds),
789
777
  maybeScheduleConsolidation: (nonZero) => this.maybeScheduleConsolidation(nonZero),
790
778
  requestQmdMaintenance: () => this.requestQmdMaintenance(),
791
779
  runTierMigrationCycle: (storage, trigger, options) => this.runTierMigrationCycle(storage, trigger, options),
792
- getLastPersistExtractionDeferredCount: () => this.lastPersistExtractionDeferredCount,
793
780
  recordProcessedExtractionFingerprint: (storage, fingerprint, preloadedMeta) =>
794
781
  this.recordProcessedExtractionFingerprint(storage, fingerprint, preloadedMeta),
795
782
  });
@@ -841,8 +828,6 @@ export class Orchestrator {
841
828
  getJudgeDeferCounts: () => this.judgeDeferCounts,
842
829
  getFaithfulnessCounters: () => this.faithfulnessCounters,
843
830
  getEmbeddingFallback: () => this.embeddingFallback,
844
- setLastPersistExtractionDeferredCount: (v) => { this.lastPersistExtractionDeferredCount = v; },
845
- setLastPersistExtractionPendingReviewIds: (ids) => { this.lastPersistExtractionPendingReviewIds = ids; },
846
831
  addContentHashDedup: (targetStorage, content) => this.addContentHashDedup(targetStorage, content),
847
832
  hasContentHashDedup: (targetStorage, content) => this.hasContentHashDedup(targetStorage, content),
848
833
  backfillTemporalBoundsOnDedupHit: (targetStorage, dedupContent, bounds, entityRef, sourceConnector) =>
@@ -3197,8 +3182,8 @@ export class Orchestrator {
3197
3182
  sourceText?: string,
3198
3183
  graphCaps: GraphConstructionCapabilitySet = resolveGraphConstructionCapabilities(this.config),
3199
3184
  lifecycleCaps: MemoryLifecycleCapabilitySet = resolveMemoryLifecycleCapabilities(this.config),
3200
- ): Promise<{ persistedIds: string[]; memoryPathById: Map<string, string> }> {
3201
- const { persistedIds, memoryPathById } = await this.extractionPersistCoordinator.persistExtraction(
3185
+ ): Promise<ExtractionPersistResult> {
3186
+ return await this.extractionPersistCoordinator.persistExtraction(
3202
3187
  result,
3203
3188
  storage,
3204
3189
  threadIdForExtraction,
@@ -3209,7 +3194,6 @@ export class Orchestrator {
3209
3194
  graphCaps,
3210
3195
  lifecycleCaps,
3211
3196
  );
3212
- return { persistedIds, memoryPathById };
3213
3197
  }
3214
3198
  /**
3215
3199
  * Append persisted ids to the thread episode set, excluding pending_review
@@ -3219,8 +3203,8 @@ export class Orchestrator {
3219
3203
  private async appendPersistedThreadEpisodes(
3220
3204
  threadId: string,
3221
3205
  persistedIds: string[],
3206
+ pendingReviewIds: string[],
3222
3207
  ): Promise<void> {
3223
- const pendingReviewIds = this.lastPersistExtractionPendingReviewIds ?? [];
3224
3208
  const episodeIds =
3225
3209
  pendingReviewIds.length > 0
3226
3210
  ? persistedIds.filter((id) => !pendingReviewIds.includes(id))
@@ -166,7 +166,7 @@ export function stubPersistExtraction(
166
166
  const impl: PersistExtractionFn = async (...args) => {
167
167
  calls.push(args);
168
168
  const persistedIds = factory ? await factory(args, calls.length) : [];
169
- return { persistedIds, memoryPathById: new Map() };
169
+ return { persistedIds, memoryPathById: new Map(), deferredCount: 0, pendingReviewIds: [] };
170
170
  };
171
171
  seam.persistExtraction = impl;
172
172
  return calls;
@@ -0,0 +1,153 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { ThreadingManager } from "./threading.js";
7
+ import type { BufferTurn, ConversationThread } from "./types.js";
8
+
9
+ function turn(sessionKey: string, content: string): BufferTurn {
10
+ return { role: "user", content, timestamp: "2026-11-04T10:00:00.000Z", sessionKey };
11
+ }
12
+
13
+ async function makeThreading(): Promise<{ threading: ThreadingManager; cleanup: () => Promise<void> }> {
14
+ const dir = await mkdtemp(path.join(os.tmpdir(), "remnic-threads-"));
15
+ return { threading: new ThreadingManager(dir), cleanup: () => rm(dir, { recursive: true, force: true }) };
16
+ }
17
+
18
+ test("the state being inserted is never evicted, even with every older session in flight", async () => {
19
+ const { threading, cleanup } = await makeThreading();
20
+ try {
21
+ const realSave = threading.saveThread.bind(threading);
22
+ const floodGate = Promise.withResolvers<void>();
23
+ const allEntered = Promise.withResolvers<void>();
24
+ let entered = 0;
25
+ threading.saveThread = async (thread: ConversationThread): Promise<void> => {
26
+ if (thread.sessionKey?.startsWith("flood-")) {
27
+ entered += 1;
28
+ if (entered === 512) allEntered.resolve();
29
+ await floodGate.promise;
30
+ }
31
+ await realSave(thread);
32
+ };
33
+
34
+ // Park all 512 flood sessions inside saveThread so every one of their run
35
+ // states is in-flight while the next session inserts its own.
36
+ const floodRuns: Promise<ConversationThread["id"]>[] = [];
37
+ for (let i = 0; i < 512; i++) {
38
+ floodRuns.push(threading.processTurn(turn(`flood-${i}`, `flood ${i}`), []));
39
+ }
40
+ await allEntered.promise;
41
+
42
+ // The new session's state is the map's last entry and not yet registered
43
+ // in-flight when runStateFor's eviction loop runs; eviction must skip it
44
+ // or this turn's state is orphaned and the follow-up turn forks a new
45
+ // thread (PRRT_kwDORJXyws6ztlO2).
46
+ const idN1 = await threading.processTurn(turn("session-new", "n1"), []);
47
+ const idN2 = await threading.processTurn(turn("session-new", "n2"), []);
48
+ assert.equal(idN2, idN1, "follow-up turn must append to the same thread");
49
+
50
+ floodGate.resolve();
51
+ await Promise.all(floodRuns);
52
+ } finally {
53
+ await cleanup();
54
+ }
55
+ });
56
+
57
+ test("a recently used session is not evicted FIFO when the cap overflows", async () => {
58
+ const { threading, cleanup } = await makeThreading();
59
+ try {
60
+ const threadA = await threading.processTurn(turn("session-a", "a1"), []);
61
+ // Fill the map to exactly the cap with inactive sessions (session-a is
62
+ // now the OLDEST entry).
63
+ for (let i = 0; i < 511; i++) {
64
+ await threading.processTurn(turn(`flood-${i}`, `flood ${i}`), []);
65
+ }
66
+ // Touch session-a: under LRU this makes it the most recent entry; under
67
+ // FIFO it stays oldest and the next insertion evicts it.
68
+ await threading.processTurn(turn("session-a", "a2"), ["episode-a2"]);
69
+ await threading.processTurn(turn("flood-new", "the straw"), []);
70
+
71
+ const idA3 = await threading.processTurn(turn("session-a", "a3"), ["episode-a3"]);
72
+ assert.equal(idA3, threadA, "a just-used session must not be evicted before inactive ones");
73
+ assert.ok((await threading.loadThread(threadA))?.episodeIds.includes("episode-a2"));
74
+ } finally {
75
+ await cleanup();
76
+ }
77
+ });
78
+
79
+ test("concurrent turns for two sessions never share thread run state", async () => {
80
+ const { threading, cleanup } = await makeThreading();
81
+ try {
82
+ const realSave = threading.saveThread.bind(threading);
83
+ const aSaveEntered = Promise.withResolvers<void>();
84
+ const aGate = Promise.withResolvers<void>();
85
+ let holdArmed = false;
86
+ // Hold session A's save open, and only let it resume after session B has
87
+ // run a full processTurn to completion through the shared manager — the
88
+ // exact interleaving a shared run-state slot corrupts.
89
+ threading.saveThread = async (thread: ConversationThread): Promise<void> => {
90
+ if (holdArmed && thread.sessionKey === "session-a") {
91
+ aSaveEntered.resolve();
92
+ await aGate.promise;
93
+ }
94
+ await realSave(thread);
95
+ };
96
+ holdArmed = true;
97
+ const pendingA = threading.processTurn(turn("session-a", "a1"), []);
98
+ await aSaveEntered.promise;
99
+ const idB = await threading.processTurn(turn("session-b", "b1"), []);
100
+ aGate.resolve();
101
+ const idA = await pendingA;
102
+
103
+ assert.notEqual(idA, idB);
104
+ assert.equal((await threading.loadThread(idA))?.sessionKey, "session-a");
105
+ assert.equal((await threading.loadThread(idB))?.sessionKey, "session-b");
106
+
107
+ // Each session's follow-up turn must append to its OWN thread. With the
108
+ // shared slot, A resumed last and left its session key in the global
109
+ // state, so B's next turn sees "session-a" and starts a fresh thread.
110
+ assert.equal(await threading.processTurn(turn("session-a", "a2"), []), idA);
111
+ assert.equal(await threading.processTurn(turn("session-b", "b2"), []), idB);
112
+ } finally {
113
+ await cleanup();
114
+ }
115
+ });
116
+
117
+ test("an in-flight session survives a 512-session flood arriving mid-await", async () => {
118
+ const { threading, cleanup } = await makeThreading();
119
+ try {
120
+ const threadA = await threading.processTurn(turn("session-a", "a1"), []);
121
+
122
+ const realSave = threading.saveThread.bind(threading);
123
+ const gate = Promise.withResolvers<void>();
124
+ const a2SaveEntered = Promise.withResolvers<void>();
125
+ let floodArmed = false;
126
+ threading.saveThread = async (thread: ConversationThread): Promise<void> => {
127
+ if (floodArmed && thread.id === threadA) {
128
+ a2SaveEntered.resolve();
129
+ await gate.promise;
130
+ }
131
+ await realSave(thread);
132
+ };
133
+ floodArmed = true;
134
+ const secondTurn = threading.processTurn(turn("session-a", "a2"), ["episode-a2"]);
135
+ await a2SaveEntered.promise;
136
+
137
+ // Session A is parked inside saveThread. 512 other sessions insert their
138
+ // run state while it waits; eviction must not take an in-flight session's
139
+ // state, or A's thread id is lost and its next turn starts a new thread.
140
+ for (let i = 0; i < 512; i++) {
141
+ await threading.processTurn(turn(`flood-${i}`, `flood ${i}`), []);
142
+ }
143
+ gate.resolve();
144
+
145
+ const idA2 = await secondTurn;
146
+ assert.equal(idA2, threadA, "resumed turn must still own its session's thread");
147
+ const idA3 = await threading.processTurn(turn("session-a", "a3"), ["episode-a3"]);
148
+ assert.equal(idA3, threadA);
149
+ assert.ok((await threading.loadThread(threadA))?.episodeIds.includes("episode-a2"));
150
+ } finally {
151
+ await cleanup();
152
+ }
153
+ });
package/src/threading.ts CHANGED
@@ -85,10 +85,56 @@ function parseConversationThread(raw: string): ConversationThread | null {
85
85
  }
86
86
  }
87
87
 
88
+ /** One session's thread-run state: its current thread and last turn time. */
89
+ interface ThreadingRunState {
90
+ currentThreadId: string | null;
91
+ lastTurnTimestamp: number | null;
92
+ }
93
+
88
94
  export class ThreadingManager {
89
- private currentThreadId: string | null = null;
90
- private lastTurnTimestamp: number | null = null;
91
- private lastSessionKey: string | null = null;
95
+ /**
96
+ * Per-session thread-run state. One global slot let two concurrent
97
+ * extractions (bounded-parallel queue) interleave sessions and attach
98
+ * turns to the wrong thread, so the run state is keyed by session.
99
+ * Bounded: a long-lived daemon sees one entry per active session.
100
+ */
101
+ private readonly runStateBySession = new Map<string, ThreadingRunState>();
102
+ /**
103
+ * Run states with a processTurn awaiting inside them. A session parked on
104
+ * `saveThread` can watch 512 other sessions insert and evict; dropping its
105
+ * state mid-await loses its thread id and forks its thread, so eviction
106
+ * never takes an in-flight state (the cap may be exceeded briefly by the
107
+ * number of in-flight extractions, which is bounded by the queue).
108
+ */
109
+ private readonly inFlightRunStates = new Set<ThreadingRunState>();
110
+ private static readonly RUN_STATE_MAX_SESSIONS = 512;
111
+
112
+ private runStateFor(turn: BufferTurn): ThreadingRunState {
113
+ const key = turn.sessionKey ?? "";
114
+ let state = this.runStateBySession.get(key);
115
+ if (state) {
116
+ // Refresh recency: Map iteration is insertion-ordered, so re-putting a
117
+ // touched session makes eviction true LRU instead of first-in-first-out —
118
+ // an active session must not be the oldest entry just because it started
119
+ // first.
120
+ this.runStateBySession.delete(key);
121
+ this.runStateBySession.set(key, state);
122
+ } else {
123
+ state = { currentThreadId: null, lastTurnTimestamp: null };
124
+ this.runStateBySession.set(key, state);
125
+ if (this.runStateBySession.size > ThreadingManager.RUN_STATE_MAX_SESSIONS) {
126
+ for (const [oldestKey, oldest] of this.runStateBySession) {
127
+ // Never evict the state being inserted: before the caller registers
128
+ // it in-flight, every older entry can be in flight and the loop
129
+ // would otherwise delete this turn's own state and fork the thread.
130
+ if (oldest === state || this.inFlightRunStates.has(oldest)) continue;
131
+ this.runStateBySession.delete(oldestKey);
132
+ if (this.runStateBySession.size <= ThreadingManager.RUN_STATE_MAX_SESSIONS) break;
133
+ }
134
+ }
135
+ }
136
+ return state;
137
+ }
92
138
 
93
139
  constructor(
94
140
  private readonly threadsDir: string,
@@ -164,18 +210,16 @@ export class ThreadingManager {
164
210
 
165
211
  /**
166
212
  * Check if we should start a new thread based on session key and time gap.
213
+ * State is per session, so the cross-session check the shared slot needed
214
+ * is gone: different sessions never share run state at all.
167
215
  */
168
216
  shouldStartNewThread(turn: BufferTurn): boolean {
169
217
  const turnTime = new Date(turn.timestamp).getTime();
170
-
171
- // Different session key = new thread
172
- if (turn.sessionKey && this.lastSessionKey && turn.sessionKey !== this.lastSessionKey) {
173
- return true;
174
- }
218
+ const state = this.runStateFor(turn);
175
219
 
176
220
  // Time gap > threshold = new thread
177
- if (this.lastTurnTimestamp) {
178
- const gapMs = turnTime - this.lastTurnTimestamp;
221
+ if (state.lastTurnTimestamp) {
222
+ const gapMs = turnTime - state.lastTurnTimestamp;
179
223
  const gapMinutes = gapMs / (1000 * 60);
180
224
  if (gapMinutes > this.gapMinutes) {
181
225
  return true;
@@ -183,7 +227,7 @@ export class ThreadingManager {
183
227
  }
184
228
 
185
229
  // No current thread = new thread
186
- if (!this.currentThreadId) {
230
+ if (!state.currentThreadId) {
187
231
  return true;
188
232
  }
189
233
 
@@ -198,41 +242,45 @@ export class ThreadingManager {
198
242
  await this.ensureDirectory();
199
243
 
200
244
  const turnTime = new Date(turn.timestamp).getTime();
245
+ const state = this.runStateFor(turn);
246
+ this.inFlightRunStates.add(state);
247
+ try {
248
+ if (this.shouldStartNewThread(turn)) {
249
+ // Create new thread
250
+ const threadId = `thread-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
251
+ const thread: ConversationThread = {
252
+ id: threadId,
253
+ title: "New Thread", // Will be updated later with keywords
254
+ createdAt: turn.timestamp,
255
+ updatedAt: turn.timestamp,
256
+ sessionKey: turn.sessionKey,
257
+ episodeIds: [...episodeIds],
258
+ linkedThreadIds: [],
259
+ };
201
260
 
202
- if (this.shouldStartNewThread(turn)) {
203
- // Create new thread
204
- const threadId = `thread-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
205
- const thread: ConversationThread = {
206
- id: threadId,
207
- title: "New Thread", // Will be updated later with keywords
208
- createdAt: turn.timestamp,
209
- updatedAt: turn.timestamp,
210
- sessionKey: turn.sessionKey,
211
- episodeIds: [...episodeIds],
212
- linkedThreadIds: [],
213
- };
214
-
215
- await this.saveThread(thread);
216
- this.currentThreadId = threadId;
217
- log.debug(`created new thread ${threadId}`);
218
- } else if (this.currentThreadId) {
219
- // Add episodes to current thread
220
- const thread = await this.loadThread(this.currentThreadId);
221
- if (thread) {
222
- for (const id of episodeIds) {
223
- if (!thread.episodeIds.includes(id)) {
224
- thread.episodeIds.push(id);
261
+ await this.saveThread(thread);
262
+ state.currentThreadId = threadId;
263
+ log.debug(`created new thread ${threadId}`);
264
+ } else if (state.currentThreadId) {
265
+ // Add episodes to current thread
266
+ const thread = await this.loadThread(state.currentThreadId);
267
+ if (thread) {
268
+ for (const id of episodeIds) {
269
+ if (!thread.episodeIds.includes(id)) {
270
+ thread.episodeIds.push(id);
271
+ }
225
272
  }
273
+ thread.updatedAt = turn.timestamp;
274
+ await this.saveThread(thread);
226
275
  }
227
- thread.updatedAt = turn.timestamp;
228
- await this.saveThread(thread);
229
276
  }
230
- }
231
277
 
232
- this.lastTurnTimestamp = turnTime;
233
- this.lastSessionKey = turn.sessionKey ?? null;
278
+ state.lastTurnTimestamp = turnTime;
234
279
 
235
- return this.currentThreadId!;
280
+ return state.currentThreadId!;
281
+ } finally {
282
+ this.inFlightRunStates.delete(state);
283
+ }
236
284
  }
237
285
 
238
286
  /**
@@ -378,18 +426,20 @@ export class ThreadingManager {
378
426
  }
379
427
 
380
428
  /**
381
- * Get the current thread ID (if any).
429
+ * Get the current thread ID (if any). With per-session run state there is
430
+ * no single current thread; null unless exactly one session is tracked.
382
431
  */
383
432
  getCurrentThreadId(): string | null {
384
- return this.currentThreadId;
433
+ if (this.runStateBySession.size === 1) {
434
+ return this.runStateBySession.values().next().value?.currentThreadId ?? null;
435
+ }
436
+ return null;
385
437
  }
386
438
 
387
439
  /**
388
440
  * Reset threading state (e.g., on plugin restart).
389
441
  */
390
442
  reset(): void {
391
- this.currentThreadId = null;
392
- this.lastTurnTimestamp = null;
393
- this.lastSessionKey = null;
443
+ this.runStateBySession.clear();
394
444
  }
395
445
  }