memory-braid 0.6.0 → 0.7.0

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/src/index.ts CHANGED
@@ -11,11 +11,25 @@ import {
11
11
  normalizeHookMessages,
12
12
  } from "./capture.js";
13
13
  import { parseConfig, pluginConfigSchema } from "./config.js";
14
+ import {
15
+ buildConsolidationDrafts,
16
+ findSupersededSemanticMemories,
17
+ } from "./consolidation.js";
14
18
  import { stagedDedupe } from "./dedupe.js";
15
19
  import { EntityExtractionManager } from "./entities.js";
16
20
  import { extractCandidates } from "./extract.js";
17
21
  import { MemoryBraidLogger } from "./logger.js";
18
22
  import { resolveLocalTools, runLocalGet, runLocalSearch } from "./local-memory.js";
23
+ import {
24
+ buildTaxonomy,
25
+ formatTaxonomySummary,
26
+ inferMemoryLayer as inferNormalizedMemoryLayer,
27
+ normalizeTaxonomy,
28
+ } from "./memory-model.js";
29
+ import {
30
+ scoreObservedMemory,
31
+ scoreProceduralMemory,
32
+ } from "./memory-selection.js";
19
33
  import { Mem0Adapter } from "./mem0-client.js";
20
34
  import { mergeWithRrf } from "./merge.js";
21
35
  import {
@@ -36,20 +50,32 @@ import {
36
50
  createStatePaths,
37
51
  ensureStateDir,
38
52
  readCaptureDedupeState,
53
+ readConsolidationState,
39
54
  readLifecycleState,
40
55
  readRemediationState,
41
56
  readStatsState,
42
57
  type StatePaths,
43
58
  withStateLock,
44
59
  writeCaptureDedupeState,
60
+ writeConsolidationState,
45
61
  writeLifecycleState,
46
62
  writeRemediationState,
47
63
  writeStatsState,
48
64
  } from "./state.js";
65
+ import {
66
+ buildTimeRange,
67
+ formatTimeRange,
68
+ inferQuerySpecificity,
69
+ isResultInTimeRange,
70
+ resolveResultTimeMs,
71
+ type TimeRange,
72
+ } from "./temporal.js";
49
73
  import type {
50
74
  CaptureIntent,
75
+ ConsolidationReason,
51
76
  LifecycleEntry,
52
77
  MemoryKind,
78
+ MemoryLayer,
53
79
  MemoryOwner,
54
80
  MemoryBraidResult,
55
81
  PendingInboundTurn,
@@ -71,7 +97,7 @@ function jsonToolResult(payload: unknown) {
71
97
  return {
72
98
  content: [
73
99
  {
74
- type: "text",
100
+ type: "text" as const,
75
101
  text: JSON.stringify(payload, null, 2),
76
102
  },
77
103
  ],
@@ -163,6 +189,13 @@ function resolveCommandScope(config?: unknown): {
163
189
  };
164
190
  }
165
191
 
192
+ function resolveCommandPersistentScope(config?: unknown): ScopeKey {
193
+ return {
194
+ workspaceHash: workspaceHashFromDir(resolveWorkspaceDirFromConfig(config)),
195
+ agentId: "main",
196
+ };
197
+ }
198
+
166
199
  function resolveLatestUserTurnSignature(messages?: unknown[]): string | undefined {
167
200
  if (!Array.isArray(messages) || messages.length === 0) {
168
201
  return undefined;
@@ -183,6 +216,26 @@ function resolveLatestUserTurnSignature(messages?: unknown[]): string | undefine
183
216
  return undefined;
184
217
  }
185
218
 
219
+ function resolveLatestUserTurnText(messages?: unknown[]): string | undefined {
220
+ if (!Array.isArray(messages) || messages.length === 0) {
221
+ return undefined;
222
+ }
223
+
224
+ const normalized = normalizeHookMessages(messages);
225
+ for (let i = normalized.length - 1; i >= 0; i -= 1) {
226
+ const message = normalized[i];
227
+ if (!message || message.role !== "user") {
228
+ continue;
229
+ }
230
+ const text = normalizeWhitespace(message.text);
231
+ if (!text) {
232
+ continue;
233
+ }
234
+ return text;
235
+ }
236
+ return undefined;
237
+ }
238
+
186
239
  function resolvePromptTurnSignature(prompt: string): string | undefined {
187
240
  const normalized = normalizeForHash(prompt);
188
241
  if (!normalized) {
@@ -452,6 +505,10 @@ function inferRecallTarget(result: MemoryBraidResult): RecallTarget {
452
505
  return normalizeRecallTarget(metadata.recallTarget) ?? "both";
453
506
  }
454
507
 
508
+ function inferMemoryLayer(result: MemoryBraidResult): MemoryLayer {
509
+ return inferNormalizedMemoryLayer(result);
510
+ }
511
+
455
512
  function normalizeSessionKey(raw: unknown): string | undefined {
456
513
  if (typeof raw !== "string") {
457
514
  return undefined;
@@ -460,6 +517,16 @@ function normalizeSessionKey(raw: unknown): string | undefined {
460
517
  return trimmed || undefined;
461
518
  }
462
519
 
520
+ function summarizeResultTaxonomy(result: MemoryBraidResult): string {
521
+ const metadata = asRecord(result.metadata);
522
+ const taxonomy = buildTaxonomy({
523
+ text: result.snippet,
524
+ entities: metadata.entities,
525
+ existingTaxonomy: metadata.taxonomy,
526
+ });
527
+ return formatTaxonomySummary(taxonomy) || "n/a";
528
+ }
529
+
463
530
  function isGenericUserSummary(text: string): boolean {
464
531
  const normalized = text.trim().toLowerCase();
465
532
  return (
@@ -484,6 +551,7 @@ function applyMem0QualityAdjustments(params: {
484
551
  query: string;
485
552
  scope: ScopeKey;
486
553
  nowMs: number;
554
+ timeRange?: TimeRange;
487
555
  }): {
488
556
  results: MemoryBraidResult[];
489
557
  adjusted: number;
@@ -508,6 +576,7 @@ function applyMem0QualityAdjustments(params: {
508
576
  }
509
577
 
510
578
  const queryTokens = tokenizeForOverlap(params.query);
579
+ const specificity = inferQuerySpecificity(params.query);
511
580
  let adjusted = 0;
512
581
  let overlapBoosted = 0;
513
582
  let overlapPenalized = 0;
@@ -522,6 +591,7 @@ function applyMem0QualityAdjustments(params: {
522
591
  const overlap = lexicalOverlap(queryTokens, result.snippet);
523
592
  const category = normalizeCategory(metadata.category);
524
593
  const isGeneric = isGenericUserSummary(result.snippet);
594
+ const layer = inferMemoryLayer(result);
525
595
  const ts = resolveTimestampMs(result);
526
596
  const ageDays = ts ? Math.max(0, (params.nowMs - ts) / (24 * 60 * 60 * 1000)) : undefined;
527
597
 
@@ -570,6 +640,26 @@ function applyMem0QualityAdjustments(params: {
570
640
  genericPenalized += 1;
571
641
  }
572
642
 
643
+ if (metadata.supersededBy || metadata.supersededAt) {
644
+ multiplier *= 0.2;
645
+ }
646
+
647
+ if (params.timeRange) {
648
+ if (isResultInTimeRange(result, params.timeRange)) {
649
+ multiplier *= layer === "episodic" ? 1.6 : 1.05;
650
+ } else {
651
+ multiplier *= layer === "episodic" ? 0.15 : 0.55;
652
+ }
653
+ } else if (specificity === "broad") {
654
+ if (layer === "semantic") {
655
+ multiplier *= 1.18;
656
+ } else if (layer === "episodic" && (metadata.consolidatedAt || metadata.compendiumKey)) {
657
+ multiplier *= 0.82;
658
+ }
659
+ } else if (specificity === "specific" && layer === "episodic") {
660
+ multiplier *= 1.1;
661
+ }
662
+
573
663
  const normalizedMultiplier = Math.min(2.5, Math.max(0.1, multiplier));
574
664
  const nextScore = result.score * normalizedMultiplier;
575
665
  if (nextScore !== result.score) {
@@ -734,6 +824,10 @@ function resolveDateFromPath(pathValue?: string): number | undefined {
734
824
  }
735
825
 
736
826
  function resolveTimestampMs(result: MemoryBraidResult): number | undefined {
827
+ const normalized = resolveResultTimeMs(result);
828
+ if (normalized) {
829
+ return normalized;
830
+ }
737
831
  const metadata = asRecord(result.metadata);
738
832
  const fields = [
739
833
  metadata.indexedAt,
@@ -1086,13 +1180,21 @@ async function runMem0Recall(params: {
1086
1180
  statePaths?: StatePaths | null;
1087
1181
  runId: string;
1088
1182
  }): Promise<MemoryBraidResult[]> {
1183
+ const builtTimeRange = buildTimeRange({
1184
+ query: params.query,
1185
+ enabled: params.cfg.consolidation.timeQueryParsing,
1186
+ });
1187
+ const effectiveQuery = builtTimeRange.queryWithoutTime || params.query;
1188
+ const fetchLimit = builtTimeRange.range
1189
+ ? Math.max(params.maxResults, Math.min(200, params.maxResults * 4))
1190
+ : params.maxResults;
1089
1191
  const remediationState = params.statePaths
1090
1192
  ? await readRemediationState(params.statePaths)
1091
1193
  : undefined;
1092
1194
 
1093
1195
  const persistentRaw = await params.mem0.searchMemories({
1094
- query: params.query,
1095
- maxResults: params.maxResults,
1196
+ query: effectiveQuery,
1197
+ maxResults: fetchLimit,
1096
1198
  scope: params.persistentScope,
1097
1199
  runId: params.runId,
1098
1200
  });
@@ -1109,8 +1211,8 @@ async function runMem0Recall(params: {
1109
1211
  params.legacyScope.sessionKey !== params.persistentScope.sessionKey
1110
1212
  ) {
1111
1213
  const legacyRaw = await params.mem0.searchMemories({
1112
- query: params.query,
1113
- maxResults: params.maxResults,
1214
+ query: effectiveQuery,
1215
+ maxResults: fetchLimit,
1114
1216
  scope: params.legacyScope,
1115
1217
  runId: params.runId,
1116
1218
  });
@@ -1123,6 +1225,14 @@ async function runMem0Recall(params: {
1123
1225
  }
1124
1226
 
1125
1227
  let combined = [...persistentFiltered.results, ...legacyFiltered];
1228
+ if (builtTimeRange.range) {
1229
+ combined = combined.filter((result) => {
1230
+ if (inferMemoryLayer(result) === "semantic") {
1231
+ return true;
1232
+ }
1233
+ return isResultInTimeRange(result, builtTimeRange.range);
1234
+ });
1235
+ }
1126
1236
  if (params.cfg.timeDecay.enabled) {
1127
1237
  const coreDecay = resolveCoreTemporalDecay({
1128
1238
  config: params.coreConfig,
@@ -1139,9 +1249,10 @@ async function runMem0Recall(params: {
1139
1249
 
1140
1250
  combined = applyMem0QualityAdjustments({
1141
1251
  results: combined,
1142
- query: params.query,
1252
+ query: effectiveQuery,
1143
1253
  scope: params.runtimeScope,
1144
1254
  nowMs: Date.now(),
1255
+ timeRange: builtTimeRange.range,
1145
1256
  }).results;
1146
1257
 
1147
1258
  const deduped = await stagedDedupe(sortMemoriesStable(combined), {
@@ -1166,6 +1277,7 @@ async function runMem0Recall(params: {
1166
1277
  legacyCount: legacyFiltered.length,
1167
1278
  quarantinedFiltered:
1168
1279
  persistentFiltered.quarantinedFiltered + legacyQuarantinedFiltered,
1280
+ timeRange: builtTimeRange.range ? formatTimeRange(builtTimeRange.range) : "n/a",
1169
1281
  dedupedCount: deduped.length,
1170
1282
  });
1171
1283
 
@@ -1345,6 +1457,41 @@ function parseIntegerFlag(tokens: string[], flag: string, fallback: number): num
1345
1457
  return Math.max(1, Math.round(raw));
1346
1458
  }
1347
1459
 
1460
+ function parseStringFlag(tokens: string[], flag: string): string | undefined {
1461
+ const index = tokens.findIndex((token) => token === flag);
1462
+ if (index < 0 || index === tokens.length - 1) {
1463
+ return undefined;
1464
+ }
1465
+ const raw = tokens[index + 1]?.trim();
1466
+ return raw || undefined;
1467
+ }
1468
+
1469
+ function hasFlag(tokens: string[], flag: string): boolean {
1470
+ return tokens.includes(flag);
1471
+ }
1472
+
1473
+ function stripFlags(tokens: string[]): string[] {
1474
+ const out: string[] = [];
1475
+ for (let index = 0; index < tokens.length; index += 1) {
1476
+ const token = tokens[index];
1477
+ if (
1478
+ token === "--limit" ||
1479
+ token === "--layer" ||
1480
+ token === "--kind" ||
1481
+ token === "--from" ||
1482
+ token === "--to"
1483
+ ) {
1484
+ index += 1;
1485
+ continue;
1486
+ }
1487
+ if (token === "--include-quarantined") {
1488
+ continue;
1489
+ }
1490
+ out.push(token);
1491
+ }
1492
+ return out;
1493
+ }
1494
+
1348
1495
  function resolveRecordScope(
1349
1496
  memory: MemoryBraidResult,
1350
1497
  fallbackScope: { workspaceHash: string; agentId?: string; sessionKey?: string },
@@ -1537,6 +1684,7 @@ const memoryBraidPlugin = {
1537
1684
  const assistantLearningWritesByRunScope = new Map<string, number[]>();
1538
1685
 
1539
1686
  let lifecycleTimer: NodeJS.Timeout | null = null;
1687
+ let consolidationTimer: NodeJS.Timeout | null = null;
1540
1688
  let statePaths: StatePaths | null = null;
1541
1689
 
1542
1690
  async function ensureRuntimeStatePaths(): Promise<StatePaths | null> {
@@ -1596,6 +1744,7 @@ const memoryBraidPlugin = {
1596
1744
  }): Promise<{ accepted: boolean; reason: string; normalizedText: string; memoryId?: string }> {
1597
1745
  const validated = validateAtomicMemoryText(params.text);
1598
1746
  if (!validated.ok) {
1747
+ const failedReason = validated.reason;
1599
1748
  if (params.runtimeStatePaths) {
1600
1749
  await withStateLock(params.runtimeStatePaths.stateLockFile, async () => {
1601
1750
  const stats = await readStatsState(params.runtimeStatePaths!);
@@ -1605,7 +1754,7 @@ const memoryBraidPlugin = {
1605
1754
  }
1606
1755
  return {
1607
1756
  accepted: false,
1608
- reason: validated.reason,
1757
+ reason: failedReason,
1609
1758
  normalizedText: normalizeWhitespace(params.text),
1610
1759
  };
1611
1760
  }
@@ -1659,8 +1808,56 @@ const memoryBraidPlugin = {
1659
1808
  };
1660
1809
  }
1661
1810
 
1811
+ const selection = scoreProceduralMemory({
1812
+ text: validated.normalized,
1813
+ confidence: params.confidence,
1814
+ captureIntent: params.captureIntent,
1815
+ cfg,
1816
+ });
1817
+ if (selection.decision !== "procedural") {
1818
+ log.debug("memory_braid.capture.selection", {
1819
+ runId: params.runId,
1820
+ target: "procedural",
1821
+ decision: selection.decision,
1822
+ kind: params.kind,
1823
+ captureIntent: params.captureIntent,
1824
+ score: selection.score,
1825
+ reasons: selection.reasons,
1826
+ workspaceHash: params.runtimeScope.workspaceHash,
1827
+ agentId: params.runtimeScope.agentId,
1828
+ sessionKey: params.runtimeScope.sessionKey,
1829
+ contentHashPrefix: exactHash.slice(0, 12),
1830
+ });
1831
+ if (params.runtimeStatePaths) {
1832
+ await withStateLock(params.runtimeStatePaths.stateLockFile, async () => {
1833
+ const stats = await readStatsState(params.runtimeStatePaths!);
1834
+ stats.capture.agentLearningRejectedSelection += 1;
1835
+ await writeStatsState(params.runtimeStatePaths!, stats);
1836
+ });
1837
+ }
1838
+ return {
1839
+ accepted: false,
1840
+ reason: "selection_rejected",
1841
+ normalizedText: validated.normalized,
1842
+ };
1843
+ }
1844
+ log.debug("memory_braid.capture.selection", {
1845
+ runId: params.runId,
1846
+ target: "procedural",
1847
+ decision: selection.decision,
1848
+ kind: params.kind,
1849
+ captureIntent: params.captureIntent,
1850
+ score: selection.score,
1851
+ reasons: selection.reasons,
1852
+ workspaceHash: params.runtimeScope.workspaceHash,
1853
+ agentId: params.runtimeScope.agentId,
1854
+ sessionKey: params.runtimeScope.sessionKey,
1855
+ contentHashPrefix: exactHash.slice(0, 12),
1856
+ });
1857
+
1662
1858
  const metadata: Record<string, unknown> = {
1663
1859
  sourceType: "agent_learning",
1860
+ memoryLayer: "procedural",
1664
1861
  memoryOwner: "agent",
1665
1862
  memoryKind: params.kind,
1666
1863
  captureIntent: params.captureIntent,
@@ -1670,7 +1867,13 @@ const memoryBraidPlugin = {
1670
1867
  agentId: params.runtimeScope.agentId,
1671
1868
  sessionKey: params.runtimeScope.sessionKey,
1672
1869
  indexedAt: new Date().toISOString(),
1870
+ firstSeenAt: new Date().toISOString(),
1871
+ lastSeenAt: new Date().toISOString(),
1872
+ eventAt: new Date().toISOString(),
1673
1873
  contentHash: exactHash,
1874
+ selectionDecision: selection.decision,
1875
+ rememberabilityScore: selection.score,
1876
+ rememberabilityReasons: selection.reasons,
1674
1877
  };
1675
1878
  if (typeof params.confidence === "number") {
1676
1879
  metadata.confidence = Math.max(0, Math.min(1, params.confidence));
@@ -1705,6 +1908,288 @@ const memoryBraidPlugin = {
1705
1908
  };
1706
1909
  }
1707
1910
 
1911
+ async function runConsolidationOnce(params: {
1912
+ scope: ScopeKey;
1913
+ reason: ConsolidationReason;
1914
+ runtimeStatePaths?: StatePaths | null;
1915
+ runId: string;
1916
+ }): Promise<{
1917
+ candidates: number;
1918
+ clusters: number;
1919
+ created: number;
1920
+ updated: number;
1921
+ episodicMarked: number;
1922
+ contradictions: number;
1923
+ superseded: number;
1924
+ }> {
1925
+ if (!cfg.consolidation.enabled) {
1926
+ return {
1927
+ candidates: 0,
1928
+ clusters: 0,
1929
+ created: 0,
1930
+ updated: 0,
1931
+ episodicMarked: 0,
1932
+ contradictions: 0,
1933
+ superseded: 0,
1934
+ };
1935
+ }
1936
+ const runtimeStatePaths = params.runtimeStatePaths ?? (await ensureRuntimeStatePaths());
1937
+ if (!runtimeStatePaths) {
1938
+ return {
1939
+ candidates: 0,
1940
+ clusters: 0,
1941
+ created: 0,
1942
+ updated: 0,
1943
+ episodicMarked: 0,
1944
+ contradictions: 0,
1945
+ superseded: 0,
1946
+ };
1947
+ }
1948
+
1949
+ const [allMemories, remediationState, lifecycle, consolidation] = await Promise.all([
1950
+ mem0.getAllMemories({
1951
+ scope: params.scope,
1952
+ limit: 500,
1953
+ runId: params.runId,
1954
+ }),
1955
+ readRemediationState(runtimeStatePaths),
1956
+ readLifecycleState(runtimeStatePaths),
1957
+ readConsolidationState(runtimeStatePaths),
1958
+ ]);
1959
+
1960
+ const activeMemories = allMemories.filter((memory) => !isQuarantinedMemory(memory, remediationState).quarantined);
1961
+ const episodic = activeMemories.filter((memory) => inferMemoryLayer(memory) === "episodic");
1962
+ const semantic = activeMemories.filter((memory) => inferMemoryLayer(memory) === "semantic");
1963
+
1964
+ const draftPlan = await buildConsolidationDrafts({
1965
+ episodic,
1966
+ existingSemantic: semantic,
1967
+ lifecycleEntries: lifecycle.entries,
1968
+ cfg,
1969
+ minSupportCount: cfg.consolidation.minSupportCount,
1970
+ minRecallCount: cfg.consolidation.minRecallCount,
1971
+ semanticMaxSourceIds: cfg.consolidation.semanticMaxSourceIds,
1972
+ state: consolidation,
1973
+ semanticSimilarity: async (leftText, rightText) =>
1974
+ mem0.semanticSimilarity({
1975
+ leftText,
1976
+ rightText,
1977
+ scope: params.scope,
1978
+ runId: params.runId,
1979
+ }),
1980
+ });
1981
+ log.debug("memory_braid.consolidation.plan", {
1982
+ runId: params.runId,
1983
+ reason: params.reason,
1984
+ workspaceHash: params.scope.workspaceHash,
1985
+ agentId: params.scope.agentId,
1986
+ candidates: draftPlan.candidates,
1987
+ clusters: draftPlan.clustersFormed,
1988
+ promotedDrafts: draftPlan.drafts.length,
1989
+ drafts: draftPlan.drafts.map((draft) => ({
1990
+ compendiumKeyPrefix: draft.compendiumKey.slice(0, 12),
1991
+ existingMemoryId: draft.existingMemoryId ?? null,
1992
+ kind: draft.kind,
1993
+ anchor: draft.anchor ?? null,
1994
+ sourceCount: draft.sourceMemories.length,
1995
+ supportCount:
1996
+ typeof asRecord(draft.metadata).supportCount === "number"
1997
+ ? asRecord(draft.metadata).supportCount
1998
+ : null,
1999
+ promotionScore:
2000
+ typeof asRecord(draft.metadata).promotionScore === "number"
2001
+ ? asRecord(draft.metadata).promotionScore
2002
+ : null,
2003
+ promotionReasons: asRecord(draft.metadata).promotionReasons,
2004
+ })),
2005
+ });
2006
+
2007
+ let created = 0;
2008
+ let updated = 0;
2009
+ let episodicMarked = 0;
2010
+ const semanticByKey = { ...consolidation.semanticByCompendiumKey };
2011
+ const semanticMemoryIds = new Map<string, string>();
2012
+ const contradictionCandidates = [...semantic];
2013
+
2014
+ for (const draft of draftPlan.drafts) {
2015
+ let semanticMemoryId = draft.existingMemoryId;
2016
+ if (draft.existingMemoryId) {
2017
+ const updatedRemote = await mem0.updateMemoryMetadata({
2018
+ memoryId: draft.existingMemoryId,
2019
+ scope: params.scope,
2020
+ text: draft.text,
2021
+ metadata: draft.metadata,
2022
+ runId: params.runId,
2023
+ });
2024
+ if (!updatedRemote) {
2025
+ continue;
2026
+ }
2027
+ updated += 1;
2028
+ } else {
2029
+ const createdRemote = await mem0.addMemory({
2030
+ text: draft.text,
2031
+ scope: params.scope,
2032
+ metadata: draft.metadata,
2033
+ runId: params.runId,
2034
+ });
2035
+ if (!createdRemote.id) {
2036
+ continue;
2037
+ }
2038
+ semanticMemoryId = createdRemote.id;
2039
+ created += 1;
2040
+ }
2041
+
2042
+ if (!semanticMemoryId) {
2043
+ continue;
2044
+ }
2045
+ semanticMemoryIds.set(draft.compendiumKey, semanticMemoryId);
2046
+ semanticByKey[draft.compendiumKey] = {
2047
+ memoryId: semanticMemoryId,
2048
+ updatedAt: Date.now(),
2049
+ };
2050
+
2051
+ contradictionCandidates.push({
2052
+ id: semanticMemoryId,
2053
+ source: "mem0",
2054
+ snippet: draft.text,
2055
+ score: 0,
2056
+ metadata: draft.metadata,
2057
+ });
2058
+
2059
+ for (const sourceMemory of draft.sourceMemories) {
2060
+ if (!sourceMemory.id) {
2061
+ continue;
2062
+ }
2063
+ const updatedSource = await mem0.updateMemoryMetadata({
2064
+ memoryId: sourceMemory.id,
2065
+ scope: params.scope,
2066
+ text: sourceMemory.snippet,
2067
+ metadata: {
2068
+ ...asRecord(sourceMemory.metadata),
2069
+ memoryLayer: "episodic",
2070
+ consolidatedAt: new Date().toISOString(),
2071
+ compendiumKey: draft.compendiumKey,
2072
+ semanticMemoryId,
2073
+ },
2074
+ runId: params.runId,
2075
+ });
2076
+ if (updatedSource) {
2077
+ episodicMarked += 1;
2078
+ }
2079
+ }
2080
+ }
2081
+
2082
+ const superseded = await findSupersededSemanticMemories({
2083
+ semanticMemories: contradictionCandidates,
2084
+ semanticSimilarity: async (leftText, rightText) =>
2085
+ mem0.semanticSimilarity({
2086
+ leftText,
2087
+ rightText,
2088
+ scope: params.scope,
2089
+ runId: params.runId,
2090
+ }),
2091
+ });
2092
+ let supersededMarked = 0;
2093
+ if (superseded.length > 0) {
2094
+ log.debug("memory_braid.consolidation.supersede", {
2095
+ runId: params.runId,
2096
+ reason: params.reason,
2097
+ workspaceHash: params.scope.workspaceHash,
2098
+ agentId: params.scope.agentId,
2099
+ count: superseded.length,
2100
+ updates: superseded.map((target) => ({
2101
+ memoryId: target.memoryId,
2102
+ supersededBy:
2103
+ typeof asRecord(target.metadata).supersededBy === "string"
2104
+ ? asRecord(target.metadata).supersededBy
2105
+ : null,
2106
+ })),
2107
+ });
2108
+ }
2109
+ for (const target of superseded) {
2110
+ const updatedRemote = await mem0.updateMemoryMetadata({
2111
+ memoryId: target.memoryId,
2112
+ scope: params.scope,
2113
+ text: target.text,
2114
+ metadata: target.metadata,
2115
+ runId: params.runId,
2116
+ });
2117
+ if (updatedRemote) {
2118
+ supersededMarked += 1;
2119
+ }
2120
+ }
2121
+
2122
+ const nowIso = new Date().toISOString();
2123
+ await withStateLock(runtimeStatePaths.stateLockFile, async () => {
2124
+ const stats = await readStatsState(runtimeStatePaths);
2125
+ const next = await readConsolidationState(runtimeStatePaths);
2126
+ next.lastConsolidationAt = nowIso;
2127
+ next.lastConsolidationReason = params.reason;
2128
+ next.newEpisodicSinceLastRun = 0;
2129
+ next.semanticByCompendiumKey = semanticByKey;
2130
+ stats.capture.consolidationRuns += 1;
2131
+ stats.capture.consolidationCandidates += draftPlan.candidates;
2132
+ stats.capture.clustersFormed += draftPlan.clustersFormed;
2133
+ stats.capture.semanticCreated += created;
2134
+ stats.capture.semanticUpdated += updated;
2135
+ stats.capture.episodicMarkedConsolidated += episodicMarked;
2136
+ stats.capture.contradictionsDetected += superseded.length;
2137
+ stats.capture.supersededMarked += supersededMarked;
2138
+ stats.capture.lastConsolidationAt = nowIso;
2139
+ await writeConsolidationState(runtimeStatePaths, next);
2140
+ await writeStatsState(runtimeStatePaths, stats);
2141
+ });
2142
+
2143
+ log.debug("memory_braid.consolidation.run", {
2144
+ runId: params.runId,
2145
+ reason: params.reason,
2146
+ workspaceHash: params.scope.workspaceHash,
2147
+ agentId: params.scope.agentId,
2148
+ candidates: draftPlan.candidates,
2149
+ clusters: draftPlan.clustersFormed,
2150
+ created,
2151
+ updated,
2152
+ episodicMarked,
2153
+ contradictions: superseded.length,
2154
+ supersededMarked,
2155
+ });
2156
+
2157
+ return {
2158
+ candidates: draftPlan.candidates,
2159
+ clusters: draftPlan.clustersFormed,
2160
+ created,
2161
+ updated,
2162
+ episodicMarked,
2163
+ contradictions: superseded.length,
2164
+ superseded: supersededMarked,
2165
+ };
2166
+ }
2167
+
2168
+ async function maybeRunOpportunisticConsolidation(params: {
2169
+ scope: ScopeKey;
2170
+ runtimeStatePaths: StatePaths;
2171
+ runId: string;
2172
+ }): Promise<void> {
2173
+ if (!cfg.consolidation.enabled) {
2174
+ return;
2175
+ }
2176
+ const state = await readConsolidationState(params.runtimeStatePaths);
2177
+ const lastRunAt = state.lastConsolidationAt ? Date.parse(state.lastConsolidationAt) : 0;
2178
+ const minutesSinceLastRun =
2179
+ lastRunAt > 0 ? (Date.now() - lastRunAt) / (60 * 1000) : Number.POSITIVE_INFINITY;
2180
+ const enoughNew = state.newEpisodicSinceLastRun >= cfg.consolidation.opportunisticNewMemoryThreshold;
2181
+ const enoughTime = minutesSinceLastRun >= cfg.consolidation.opportunisticMinMinutesSinceLastRun;
2182
+ if (!enoughNew && !enoughTime) {
2183
+ return;
2184
+ }
2185
+ await runConsolidationOnce({
2186
+ scope: params.scope,
2187
+ reason: "opportunistic",
2188
+ runtimeStatePaths: params.runtimeStatePaths,
2189
+ runId: params.runId,
2190
+ });
2191
+ }
2192
+
1708
2193
  api.registerTool(
1709
2194
  (ctx) => {
1710
2195
  const local = resolveLocalTools(api, ctx);
@@ -1895,7 +2380,8 @@ const memoryBraidPlugin = {
1895
2380
 
1896
2381
  api.registerCommand({
1897
2382
  name: "memorybraid",
1898
- description: "Memory Braid status, stats, remediation, lifecycle cleanup, and entity extraction warmup.",
2383
+ description:
2384
+ "Memory Braid status, stats, search, consolidation, remediation, lifecycle cleanup, and entity extraction warmup.",
1899
2385
  acceptsArgs: true,
1900
2386
  handler: async (ctx) => {
1901
2387
  const args = ctx.args?.trim() ?? "";
@@ -1907,6 +2393,15 @@ const memoryBraidPlugin = {
1907
2393
  config: ctx.config,
1908
2394
  });
1909
2395
  const paths = await ensureRuntimeStatePaths();
2396
+ const consolidation = paths
2397
+ ? await readConsolidationState(paths)
2398
+ : {
2399
+ version: 1 as const,
2400
+ semanticByCompendiumKey: {},
2401
+ newEpisodicSinceLastRun: 0,
2402
+ lastConsolidationAt: undefined,
2403
+ lastConsolidationReason: undefined,
2404
+ };
1910
2405
  const lifecycle =
1911
2406
  cfg.lifecycle.enabled && paths
1912
2407
  ? await readLifecycleState(paths)
@@ -1915,6 +2410,11 @@ const memoryBraidPlugin = {
1915
2410
  text: [
1916
2411
  `capture.mode: ${cfg.capture.mode}`,
1917
2412
  `capture.includeAssistant: ${cfg.capture.includeAssistant}`,
2413
+ `capture.selection.minPreferenceDecisionScore: ${cfg.capture.selection.minPreferenceDecisionScore}`,
2414
+ `capture.selection.minFactScore: ${cfg.capture.selection.minFactScore}`,
2415
+ `capture.selection.minTaskScore: ${cfg.capture.selection.minTaskScore}`,
2416
+ `capture.selection.minOtherScore: ${cfg.capture.selection.minOtherScore}`,
2417
+ `capture.selection.minProceduralScore: ${cfg.capture.selection.minProceduralScore}`,
1918
2418
  `capture.assistant.autoCapture: ${cfg.capture.assistant.autoCapture}`,
1919
2419
  `capture.assistant.explicitTool: ${cfg.capture.assistant.explicitTool}`,
1920
2420
  `recall.user.injectTopK: ${cfg.recall.user.injectTopK}`,
@@ -1927,6 +2427,14 @@ const memoryBraidPlugin = {
1927
2427
  `lifecycle.captureTtlDays: ${cfg.lifecycle.captureTtlDays}`,
1928
2428
  `lifecycle.cleanupIntervalMinutes: ${cfg.lifecycle.cleanupIntervalMinutes}`,
1929
2429
  `lifecycle.reinforceOnRecall: ${cfg.lifecycle.reinforceOnRecall}`,
2430
+ `consolidation.enabled: ${cfg.consolidation.enabled}`,
2431
+ `consolidation.startupRun: ${cfg.consolidation.startupRun}`,
2432
+ `consolidation.intervalMinutes: ${cfg.consolidation.intervalMinutes}`,
2433
+ `consolidation.minSelectionScore: ${cfg.consolidation.minSelectionScore}`,
2434
+ `consolidation.newEpisodicSinceLastRun: ${consolidation.newEpisodicSinceLastRun}`,
2435
+ `consolidation.semanticTracked: ${Object.keys(consolidation.semanticByCompendiumKey).length}`,
2436
+ `consolidation.lastConsolidationAt: ${consolidation.lastConsolidationAt ?? "n/a"}`,
2437
+ `consolidation.lastConsolidationReason: ${consolidation.lastConsolidationReason ?? "n/a"}`,
1930
2438
  `lifecycle.tracked: ${Object.keys(lifecycle.entries).length}`,
1931
2439
  `lifecycle.lastCleanupAt: ${lifecycle.lastCleanupAt ?? "n/a"}`,
1932
2440
  `lifecycle.lastCleanupReason: ${lifecycle.lastCleanupReason ?? "n/a"}`,
@@ -1946,6 +2454,7 @@ const memoryBraidPlugin = {
1946
2454
 
1947
2455
  const stats = await readStatsState(paths);
1948
2456
  const lifecycle = await readLifecycleState(paths);
2457
+ const consolidation = await readConsolidationState(paths);
1949
2458
  const capture = stats.capture;
1950
2459
  const mem0SuccessRate =
1951
2460
  capture.mem0AddAttempts > 0
@@ -1990,8 +2499,19 @@ const memoryBraidPlugin = {
1990
2499
  `- agentLearningAutoRejected: ${capture.agentLearningAutoRejected}`,
1991
2500
  `- agentLearningInjected: ${capture.agentLearningInjected}`,
1992
2501
  `- agentLearningRecallHits: ${capture.agentLearningRecallHits}`,
2502
+ `- selectionSkipped: ${capture.selectionSkipped}`,
2503
+ `- agentLearningRejectedSelection: ${capture.agentLearningRejectedSelection}`,
2504
+ `- consolidationRuns: ${capture.consolidationRuns}`,
2505
+ `- consolidationCandidates: ${capture.consolidationCandidates}`,
2506
+ `- clustersFormed: ${capture.clustersFormed}`,
2507
+ `- semanticCreated: ${capture.semanticCreated}`,
2508
+ `- semanticUpdated: ${capture.semanticUpdated}`,
2509
+ `- episodicMarkedConsolidated: ${capture.episodicMarkedConsolidated}`,
2510
+ `- contradictionsDetected: ${capture.contradictionsDetected}`,
2511
+ `- supersededMarked: ${capture.supersededMarked}`,
1993
2512
  `- lastRunAt: ${capture.lastRunAt ?? "n/a"}`,
1994
2513
  `- lastRemediationAt: ${capture.lastRemediationAt ?? "n/a"}`,
2514
+ `- lastConsolidationAt: ${capture.lastConsolidationAt ?? "n/a"}`,
1995
2515
  "",
1996
2516
  "Lifecycle:",
1997
2517
  `- enabled: ${cfg.lifecycle.enabled}`,
@@ -2005,10 +2525,144 @@ const memoryBraidPlugin = {
2005
2525
  `- lastCleanupExpired: ${lifecycle.lastCleanupExpired ?? "n/a"}`,
2006
2526
  `- lastCleanupDeleted: ${lifecycle.lastCleanupDeleted ?? "n/a"}`,
2007
2527
  `- lastCleanupFailed: ${lifecycle.lastCleanupFailed ?? "n/a"}`,
2528
+ "",
2529
+ "Consolidation:",
2530
+ `- enabled: ${cfg.consolidation.enabled}`,
2531
+ `- startupRun: ${cfg.consolidation.startupRun}`,
2532
+ `- intervalMinutes: ${cfg.consolidation.intervalMinutes}`,
2533
+ `- minSelectionScore: ${cfg.consolidation.minSelectionScore}`,
2534
+ `- newEpisodicSinceLastRun: ${consolidation.newEpisodicSinceLastRun}`,
2535
+ `- semanticTracked: ${Object.keys(consolidation.semanticByCompendiumKey).length}`,
2536
+ `- lastConsolidationAt: ${consolidation.lastConsolidationAt ?? "n/a"}`,
2537
+ `- lastConsolidationReason: ${consolidation.lastConsolidationReason ?? "n/a"}`,
2008
2538
  ].join("\n"),
2009
2539
  };
2010
2540
  }
2011
2541
 
2542
+ if (action === "search") {
2543
+ const paths = await ensureRuntimeStatePaths();
2544
+ if (!paths) {
2545
+ return {
2546
+ text: "Search unavailable: state directory is not ready.",
2547
+ isError: true,
2548
+ };
2549
+ }
2550
+
2551
+ const queryTokens = stripFlags(tokens.slice(1));
2552
+ const queryText = normalizeWhitespace(queryTokens.join(" "));
2553
+ if (!queryText) {
2554
+ return {
2555
+ text:
2556
+ "Usage: /memorybraid search <query> [--limit N] [--layer episodic|semantic|procedural|all] [--kind fact|preference|decision|task|heuristic|lesson|strategy|other] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--include-quarantined]",
2557
+ isError: true,
2558
+ };
2559
+ }
2560
+
2561
+ const layer = parseStringFlag(tokens, "--layer") ?? "all";
2562
+ const allowedLayers = new Set(["episodic", "semantic", "procedural", "all"]);
2563
+ if (!allowedLayers.has(layer)) {
2564
+ return {
2565
+ text: "Invalid --layer. Use episodic, semantic, procedural, or all.",
2566
+ isError: true,
2567
+ };
2568
+ }
2569
+
2570
+ const kind = parseStringFlag(tokens, "--kind");
2571
+ if (
2572
+ kind &&
2573
+ ![
2574
+ "fact",
2575
+ "preference",
2576
+ "decision",
2577
+ "task",
2578
+ "heuristic",
2579
+ "lesson",
2580
+ "strategy",
2581
+ "other",
2582
+ ].includes(kind)
2583
+ ) {
2584
+ return {
2585
+ text: "Invalid --kind.",
2586
+ isError: true,
2587
+ };
2588
+ }
2589
+
2590
+ const limit = Math.min(50, Math.max(1, parseIntegerFlag(tokens, "--limit", 10)));
2591
+ const includeQuarantined = hasFlag(tokens, "--include-quarantined");
2592
+ const timeRangeResult = buildTimeRange({
2593
+ query: queryText,
2594
+ from: parseStringFlag(tokens, "--from"),
2595
+ to: parseStringFlag(tokens, "--to"),
2596
+ enabled: cfg.consolidation.timeQueryParsing,
2597
+ });
2598
+ const effectiveQuery = timeRangeResult.queryWithoutTime || queryText;
2599
+ const commandScope = resolveCommandPersistentScope(ctx.config);
2600
+ const fetched = await mem0.searchMemories({
2601
+ query: effectiveQuery,
2602
+ maxResults: Math.min(200, Math.max(25, limit * 5)),
2603
+ scope: commandScope,
2604
+ runId: log.newRunId(),
2605
+ });
2606
+ const remediationState = await readRemediationState(paths);
2607
+ const lifecycle = await readLifecycleState(paths);
2608
+ let hiddenQuarantined = 0;
2609
+ const filtered = fetched.filter((result) => {
2610
+ const quarantine = isQuarantinedMemory(result, remediationState);
2611
+ if (quarantine.quarantined && !includeQuarantined) {
2612
+ hiddenQuarantined += 1;
2613
+ return false;
2614
+ }
2615
+ if (layer !== "all" && inferMemoryLayer(result) !== layer) {
2616
+ return false;
2617
+ }
2618
+ if (kind && inferMemoryKind(result) !== kind) {
2619
+ return false;
2620
+ }
2621
+ if (timeRangeResult.range && !isResultInTimeRange(result, timeRangeResult.range)) {
2622
+ return false;
2623
+ }
2624
+ return true;
2625
+ });
2626
+ const rows = filtered.slice(0, limit);
2627
+ const lines = [
2628
+ "Memory Braid search",
2629
+ `- query: ${queryText}`,
2630
+ `- effectiveQuery: ${effectiveQuery}`,
2631
+ `- scope.workspaceHash: ${commandScope.workspaceHash}`,
2632
+ `- scope.agentId: ${commandScope.agentId}`,
2633
+ `- layer: ${layer}`,
2634
+ `- kind: ${kind ?? "all"}`,
2635
+ `- timeRange: ${formatTimeRange(timeRangeResult.range)}`,
2636
+ `- fetched: ${fetched.length}`,
2637
+ `- shown: ${rows.length}`,
2638
+ `- hiddenQuarantined: ${hiddenQuarantined}`,
2639
+ ];
2640
+ if (rows.length === 0) {
2641
+ lines.push("", "No matching memories.");
2642
+ return { text: lines.join("\n") };
2643
+ }
2644
+ for (const [index, result] of rows.entries()) {
2645
+ const metadata = asRecord(result.metadata);
2646
+ const quarantine = isQuarantinedMemory(result, remediationState);
2647
+ const lifecycleEntry =
2648
+ result.id && lifecycle.entries[result.id] ? lifecycle.entries[result.id] : undefined;
2649
+ lines.push(
2650
+ "",
2651
+ `${index + 1}. [${result.score.toFixed(3)}] ${result.id ?? "unknown"} ${result.snippet}`,
2652
+ ` sourceType=${metadata.sourceType ?? "n/a"} layer=${inferMemoryLayer(result)} owner=${inferMemoryOwner(result)} kind=${inferMemoryKind(result)} stability=${metadata.stability ?? "n/a"}`,
2653
+ ` selection=decision:${metadata.selectionDecision ?? "n/a"} score:${typeof metadata.rememberabilityScore === "number" ? metadata.rememberabilityScore.toFixed(2) : "n/a"} reasons:${Array.isArray(metadata.rememberabilityReasons) ? metadata.rememberabilityReasons.join(", ") : "n/a"}`,
2654
+ ` timestamps=eventAt:${metadata.eventAt ?? "n/a"} firstSeenAt:${metadata.firstSeenAt ?? "n/a"} indexedAt:${metadata.indexedAt ?? "n/a"} updatedAt:${metadata.updatedAt ?? "n/a"}`,
2655
+ ` taxonomy=${summarizeResultTaxonomy(result)}`,
2656
+ ` provenance=captureOrigin:${metadata.captureOrigin ?? "n/a"} capturePath:${metadata.capturePath ?? "n/a"} pluginCaptureVersion:${metadata.pluginCaptureVersion ?? "n/a"}`,
2657
+ ` quarantine=${quarantine.quarantined ? `yes (${quarantine.reason ?? "n/a"})` : "no"}`,
2658
+ ` lifecycle=recallCount:${lifecycleEntry?.recallCount ?? "n/a"} lastRecalledAt:${lifecycleEntry?.lastRecalledAt ? new Date(lifecycleEntry.lastRecalledAt).toISOString() : "n/a"}`,
2659
+ );
2660
+ }
2661
+ return {
2662
+ text: lines.join("\n"),
2663
+ };
2664
+ }
2665
+
2012
2666
  if (action === "audit" || action === "remediate") {
2013
2667
  const subAction = action === "audit" ? "audit" : (tokens[1] ?? "audit").toLowerCase();
2014
2668
  if (
@@ -2081,6 +2735,34 @@ const memoryBraidPlugin = {
2081
2735
  };
2082
2736
  }
2083
2737
 
2738
+ if (action === "consolidate") {
2739
+ const paths = await ensureRuntimeStatePaths();
2740
+ if (!paths) {
2741
+ return {
2742
+ text: "Consolidation unavailable: state directory is not ready.",
2743
+ isError: true,
2744
+ };
2745
+ }
2746
+ const summary = await runConsolidationOnce({
2747
+ scope: resolveCommandPersistentScope(ctx.config),
2748
+ reason: "command",
2749
+ runtimeStatePaths: paths,
2750
+ runId: log.newRunId(),
2751
+ });
2752
+ return {
2753
+ text: [
2754
+ "Consolidation complete.",
2755
+ `- candidates: ${summary.candidates}`,
2756
+ `- clusters: ${summary.clusters}`,
2757
+ `- semanticCreated: ${summary.created}`,
2758
+ `- semanticUpdated: ${summary.updated}`,
2759
+ `- episodicMarked: ${summary.episodicMarked}`,
2760
+ `- contradictions: ${summary.contradictions}`,
2761
+ `- supersededMarked: ${summary.superseded}`,
2762
+ ].join("\n"),
2763
+ };
2764
+ }
2765
+
2084
2766
  if (action === "warmup") {
2085
2767
  const runId = log.newRunId();
2086
2768
  const forceReload = tokens.some((token) => token === "--force");
@@ -2114,7 +2796,7 @@ const memoryBraidPlugin = {
2114
2796
 
2115
2797
  return {
2116
2798
  text:
2117
- "Usage: /memorybraid [status|stats|audit|remediate <audit|quarantine|delete|purge-all-captured> [--apply] [--limit N] [--sample N]|cleanup|warmup [--force]]",
2799
+ "Usage: /memorybraid [status|stats|search <query> [--limit N] [--layer episodic|semantic|procedural|all] [--kind fact|preference|decision|task|heuristic|lesson|strategy|other] [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--include-quarantined]|consolidate|audit|remediate <audit|quarantine|delete|purge-all-captured> [--apply] [--limit N] [--sample N]|cleanup|warmup [--force]]",
2118
2800
  };
2119
2801
  },
2120
2802
  });
@@ -2248,7 +2930,8 @@ const memoryBraidPlugin = {
2248
2930
  return baseResult;
2249
2931
  }
2250
2932
 
2251
- const recallQuery = sanitizeRecallQuery(event.prompt);
2933
+ const latestUserTurnText = resolveLatestUserTurnText(event.messages);
2934
+ const recallQuery = sanitizeRecallQuery(latestUserTurnText ?? event.prompt);
2252
2935
  if (!recallQuery) {
2253
2936
  return baseResult;
2254
2937
  }
@@ -2554,6 +3237,7 @@ const memoryBraidPlugin = {
2554
3237
  let mem0AddWithId = 0;
2555
3238
  let mem0AddWithoutId = 0;
2556
3239
  let remoteQuarantineFiltered = 0;
3240
+ let selectionSkipped = 0;
2557
3241
  const remediationState = await readRemediationState(runtimeStatePaths);
2558
3242
  const successfulAdds: Array<{
2559
3243
  memoryId: string;
@@ -2623,8 +3307,10 @@ const memoryBraidPlugin = {
2623
3307
  continue;
2624
3308
  }
2625
3309
 
3310
+ const indexedAt = new Date().toISOString();
2626
3311
  const metadata: Record<string, unknown> = {
2627
3312
  sourceType: "capture",
3313
+ memoryLayer: "episodic",
2628
3314
  memoryOwner: "user",
2629
3315
  memoryKind: mapCategoryToMemoryKind(candidate.category),
2630
3316
  captureIntent: "observed",
@@ -2642,7 +3328,11 @@ const memoryBraidPlugin = {
2642
3328
  capturePath: captureInput.capturePath,
2643
3329
  pluginCaptureVersion: PLUGIN_CAPTURE_VERSION,
2644
3330
  contentHash: hash,
2645
- indexedAt: new Date().toISOString(),
3331
+ indexedAt,
3332
+ eventAt: indexedAt,
3333
+ firstSeenAt: indexedAt,
3334
+ lastSeenAt: indexedAt,
3335
+ supportCount: 1,
2646
3336
  };
2647
3337
 
2648
3338
  if (cfg.entityExtraction.enabled) {
@@ -2657,6 +3347,50 @@ const memoryBraidPlugin = {
2657
3347
  metadata.entities = entities;
2658
3348
  }
2659
3349
  }
3350
+ metadata.taxonomy = buildTaxonomy({
3351
+ text: candidate.text,
3352
+ entities: metadata.entities,
3353
+ existingTaxonomy: metadata.taxonomy,
3354
+ });
3355
+ metadata.taxonomySummary = formatTaxonomySummary(normalizeTaxonomy(metadata.taxonomy));
3356
+ const selection = scoreObservedMemory({
3357
+ text: candidate.text,
3358
+ kind: mapCategoryToMemoryKind(candidate.category),
3359
+ extractionScore: candidate.score,
3360
+ taxonomy: normalizeTaxonomy(metadata.taxonomy),
3361
+ source: candidate.source,
3362
+ cfg,
3363
+ });
3364
+ metadata.selectionDecision = selection.decision;
3365
+ metadata.rememberabilityScore = selection.score;
3366
+ metadata.rememberabilityReasons = selection.reasons;
3367
+ log.debug("memory_braid.capture.selection", {
3368
+ runId,
3369
+ target: "episodic",
3370
+ decision: selection.decision,
3371
+ kind: mapCategoryToMemoryKind(candidate.category),
3372
+ source: candidate.source,
3373
+ score: selection.score,
3374
+ reasons: selection.reasons,
3375
+ workspaceHash: scope.workspaceHash,
3376
+ agentId: scope.agentId,
3377
+ sessionKey: scope.sessionKey,
3378
+ contentHashPrefix: hash.slice(0, 12),
3379
+ });
3380
+ if (selection.decision !== "episodic") {
3381
+ selectionSkipped += 1;
3382
+ log.debug("memory_braid.capture.skip", {
3383
+ runId,
3384
+ reason: "selection_rejected",
3385
+ workspaceHash: scope.workspaceHash,
3386
+ agentId: scope.agentId,
3387
+ sessionKey: scope.sessionKey,
3388
+ category: candidate.category,
3389
+ score: selection.score,
3390
+ reasons: selection.reasons,
3391
+ });
3392
+ continue;
3393
+ }
2660
3394
 
2661
3395
  const quarantine = isQuarantinedMemory(
2662
3396
  {
@@ -2702,6 +3436,7 @@ const memoryBraidPlugin = {
2702
3436
 
2703
3437
  await withStateLock(runtimeStatePaths.stateLockFile, async () => {
2704
3438
  const dedupe = await readCaptureDedupeState(runtimeStatePaths);
3439
+ const consolidation = await readConsolidationState(runtimeStatePaths);
2705
3440
  const stats = await readStatsState(runtimeStatePaths);
2706
3441
  const lifecycle = cfg.lifecycle.enabled
2707
3442
  ? await readLifecycleState(runtimeStatePaths)
@@ -2752,11 +3487,14 @@ const memoryBraidPlugin = {
2752
3487
  stats.capture.provenanceSkipped += provenanceSkipped;
2753
3488
  stats.capture.transcriptShapeSkipped += transcriptShapeSkipped;
2754
3489
  stats.capture.quarantinedFiltered += remoteQuarantineFiltered;
3490
+ stats.capture.selectionSkipped += selectionSkipped;
2755
3491
  stats.capture.agentLearningAutoCaptured += agentLearningAutoCaptured;
2756
3492
  stats.capture.agentLearningAutoRejected += agentLearningAutoRejected;
2757
3493
  stats.capture.lastRunAt = new Date(now).toISOString();
3494
+ consolidation.newEpisodicSinceLastRun += persisted;
2758
3495
 
2759
3496
  await writeCaptureDedupeState(runtimeStatePaths, dedupe);
3497
+ await writeConsolidationState(runtimeStatePaths, consolidation);
2760
3498
  if (lifecycle) {
2761
3499
  await writeLifecycleState(runtimeStatePaths, lifecycle);
2762
3500
  }
@@ -2786,6 +3524,12 @@ const memoryBraidPlugin = {
2786
3524
  agentLearningAutoRejected,
2787
3525
  }, true);
2788
3526
  });
3527
+
3528
+ await maybeRunOpportunisticConsolidation({
3529
+ scope: persistentScope,
3530
+ runtimeStatePaths,
3531
+ runId,
3532
+ });
2789
3533
  });
2790
3534
 
2791
3535
  api.registerService({
@@ -2827,6 +3571,12 @@ const memoryBraidPlugin = {
2827
3571
  lifecycleCaptureTtlDays: cfg.lifecycle.captureTtlDays,
2828
3572
  lifecycleCleanupIntervalMinutes: cfg.lifecycle.cleanupIntervalMinutes,
2829
3573
  lifecycleReinforceOnRecall: cfg.lifecycle.reinforceOnRecall,
3574
+ consolidationEnabled: cfg.consolidation.enabled,
3575
+ consolidationStartupRun: cfg.consolidation.startupRun,
3576
+ consolidationIntervalMinutes: cfg.consolidation.intervalMinutes,
3577
+ consolidationMinSupportCount: cfg.consolidation.minSupportCount,
3578
+ consolidationMinRecallCount: cfg.consolidation.minRecallCount,
3579
+ consolidationTimeQueryParsing: cfg.consolidation.timeQueryParsing,
2830
3580
  entityExtractionEnabled: cfg.entityExtraction.enabled,
2831
3581
  entityProvider: cfg.entityExtraction.provider,
2832
3582
  entityModel: cfg.entityExtraction.model,
@@ -2885,12 +3635,48 @@ const memoryBraidPlugin = {
2885
3635
  });
2886
3636
  }, intervalMs);
2887
3637
  }
3638
+
3639
+ if (cfg.consolidation.enabled && cfg.consolidation.startupRun) {
3640
+ void runConsolidationOnce({
3641
+ scope: resolveCommandPersistentScope(api.config),
3642
+ reason: "startup",
3643
+ runtimeStatePaths: statePaths,
3644
+ runId,
3645
+ }).catch((err) => {
3646
+ log.warn("memory_braid.consolidation.run", {
3647
+ runId,
3648
+ reason: "startup",
3649
+ error: err instanceof Error ? err.message : String(err),
3650
+ });
3651
+ });
3652
+ }
3653
+
3654
+ if (cfg.consolidation.enabled) {
3655
+ const intervalMs = cfg.consolidation.intervalMinutes * 60 * 1000;
3656
+ consolidationTimer = setInterval(() => {
3657
+ void runConsolidationOnce({
3658
+ scope: resolveCommandPersistentScope(api.config),
3659
+ reason: "interval",
3660
+ runtimeStatePaths: statePaths!,
3661
+ runId: log.newRunId(),
3662
+ }).catch((err) => {
3663
+ log.warn("memory_braid.consolidation.run", {
3664
+ reason: "interval",
3665
+ error: err instanceof Error ? err.message : String(err),
3666
+ });
3667
+ });
3668
+ }, intervalMs);
3669
+ }
2888
3670
  },
2889
3671
  stop: async () => {
2890
3672
  if (lifecycleTimer) {
2891
3673
  clearInterval(lifecycleTimer);
2892
3674
  lifecycleTimer = null;
2893
3675
  }
3676
+ if (consolidationTimer) {
3677
+ clearInterval(consolidationTimer);
3678
+ consolidationTimer = null;
3679
+ }
2894
3680
  },
2895
3681
  });
2896
3682
  },