memory-braid 0.6.1 → 0.7.1

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;
@@ -472,6 +505,10 @@ function inferRecallTarget(result: MemoryBraidResult): RecallTarget {
472
505
  return normalizeRecallTarget(metadata.recallTarget) ?? "both";
473
506
  }
474
507
 
508
+ function inferMemoryLayer(result: MemoryBraidResult): MemoryLayer {
509
+ return inferNormalizedMemoryLayer(result);
510
+ }
511
+
475
512
  function normalizeSessionKey(raw: unknown): string | undefined {
476
513
  if (typeof raw !== "string") {
477
514
  return undefined;
@@ -480,6 +517,16 @@ function normalizeSessionKey(raw: unknown): string | undefined {
480
517
  return trimmed || undefined;
481
518
  }
482
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
+
483
530
  function isGenericUserSummary(text: string): boolean {
484
531
  const normalized = text.trim().toLowerCase();
485
532
  return (
@@ -504,6 +551,7 @@ function applyMem0QualityAdjustments(params: {
504
551
  query: string;
505
552
  scope: ScopeKey;
506
553
  nowMs: number;
554
+ timeRange?: TimeRange;
507
555
  }): {
508
556
  results: MemoryBraidResult[];
509
557
  adjusted: number;
@@ -528,6 +576,7 @@ function applyMem0QualityAdjustments(params: {
528
576
  }
529
577
 
530
578
  const queryTokens = tokenizeForOverlap(params.query);
579
+ const specificity = inferQuerySpecificity(params.query);
531
580
  let adjusted = 0;
532
581
  let overlapBoosted = 0;
533
582
  let overlapPenalized = 0;
@@ -542,6 +591,7 @@ function applyMem0QualityAdjustments(params: {
542
591
  const overlap = lexicalOverlap(queryTokens, result.snippet);
543
592
  const category = normalizeCategory(metadata.category);
544
593
  const isGeneric = isGenericUserSummary(result.snippet);
594
+ const layer = inferMemoryLayer(result);
545
595
  const ts = resolveTimestampMs(result);
546
596
  const ageDays = ts ? Math.max(0, (params.nowMs - ts) / (24 * 60 * 60 * 1000)) : undefined;
547
597
 
@@ -590,6 +640,26 @@ function applyMem0QualityAdjustments(params: {
590
640
  genericPenalized += 1;
591
641
  }
592
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
+
593
663
  const normalizedMultiplier = Math.min(2.5, Math.max(0.1, multiplier));
594
664
  const nextScore = result.score * normalizedMultiplier;
595
665
  if (nextScore !== result.score) {
@@ -754,6 +824,10 @@ function resolveDateFromPath(pathValue?: string): number | undefined {
754
824
  }
755
825
 
756
826
  function resolveTimestampMs(result: MemoryBraidResult): number | undefined {
827
+ const normalized = resolveResultTimeMs(result);
828
+ if (normalized) {
829
+ return normalized;
830
+ }
757
831
  const metadata = asRecord(result.metadata);
758
832
  const fields = [
759
833
  metadata.indexedAt,
@@ -1106,13 +1180,21 @@ async function runMem0Recall(params: {
1106
1180
  statePaths?: StatePaths | null;
1107
1181
  runId: string;
1108
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;
1109
1191
  const remediationState = params.statePaths
1110
1192
  ? await readRemediationState(params.statePaths)
1111
1193
  : undefined;
1112
1194
 
1113
1195
  const persistentRaw = await params.mem0.searchMemories({
1114
- query: params.query,
1115
- maxResults: params.maxResults,
1196
+ query: effectiveQuery,
1197
+ maxResults: fetchLimit,
1116
1198
  scope: params.persistentScope,
1117
1199
  runId: params.runId,
1118
1200
  });
@@ -1129,8 +1211,8 @@ async function runMem0Recall(params: {
1129
1211
  params.legacyScope.sessionKey !== params.persistentScope.sessionKey
1130
1212
  ) {
1131
1213
  const legacyRaw = await params.mem0.searchMemories({
1132
- query: params.query,
1133
- maxResults: params.maxResults,
1214
+ query: effectiveQuery,
1215
+ maxResults: fetchLimit,
1134
1216
  scope: params.legacyScope,
1135
1217
  runId: params.runId,
1136
1218
  });
@@ -1143,6 +1225,14 @@ async function runMem0Recall(params: {
1143
1225
  }
1144
1226
 
1145
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
+ }
1146
1236
  if (params.cfg.timeDecay.enabled) {
1147
1237
  const coreDecay = resolveCoreTemporalDecay({
1148
1238
  config: params.coreConfig,
@@ -1159,9 +1249,10 @@ async function runMem0Recall(params: {
1159
1249
 
1160
1250
  combined = applyMem0QualityAdjustments({
1161
1251
  results: combined,
1162
- query: params.query,
1252
+ query: effectiveQuery,
1163
1253
  scope: params.runtimeScope,
1164
1254
  nowMs: Date.now(),
1255
+ timeRange: builtTimeRange.range,
1165
1256
  }).results;
1166
1257
 
1167
1258
  const deduped = await stagedDedupe(sortMemoriesStable(combined), {
@@ -1186,6 +1277,7 @@ async function runMem0Recall(params: {
1186
1277
  legacyCount: legacyFiltered.length,
1187
1278
  quarantinedFiltered:
1188
1279
  persistentFiltered.quarantinedFiltered + legacyQuarantinedFiltered,
1280
+ timeRange: builtTimeRange.range ? formatTimeRange(builtTimeRange.range) : "n/a",
1189
1281
  dedupedCount: deduped.length,
1190
1282
  });
1191
1283
 
@@ -1365,6 +1457,41 @@ function parseIntegerFlag(tokens: string[], flag: string, fallback: number): num
1365
1457
  return Math.max(1, Math.round(raw));
1366
1458
  }
1367
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
+
1368
1495
  function resolveRecordScope(
1369
1496
  memory: MemoryBraidResult,
1370
1497
  fallbackScope: { workspaceHash: string; agentId?: string; sessionKey?: string },
@@ -1557,6 +1684,7 @@ const memoryBraidPlugin = {
1557
1684
  const assistantLearningWritesByRunScope = new Map<string, number[]>();
1558
1685
 
1559
1686
  let lifecycleTimer: NodeJS.Timeout | null = null;
1687
+ let consolidationTimer: NodeJS.Timeout | null = null;
1560
1688
  let statePaths: StatePaths | null = null;
1561
1689
 
1562
1690
  async function ensureRuntimeStatePaths(): Promise<StatePaths | null> {
@@ -1616,6 +1744,7 @@ const memoryBraidPlugin = {
1616
1744
  }): Promise<{ accepted: boolean; reason: string; normalizedText: string; memoryId?: string }> {
1617
1745
  const validated = validateAtomicMemoryText(params.text);
1618
1746
  if (!validated.ok) {
1747
+ const failedReason = validated.reason;
1619
1748
  if (params.runtimeStatePaths) {
1620
1749
  await withStateLock(params.runtimeStatePaths.stateLockFile, async () => {
1621
1750
  const stats = await readStatsState(params.runtimeStatePaths!);
@@ -1625,7 +1754,7 @@ const memoryBraidPlugin = {
1625
1754
  }
1626
1755
  return {
1627
1756
  accepted: false,
1628
- reason: validated.reason,
1757
+ reason: failedReason,
1629
1758
  normalizedText: normalizeWhitespace(params.text),
1630
1759
  };
1631
1760
  }
@@ -1679,8 +1808,56 @@ const memoryBraidPlugin = {
1679
1808
  };
1680
1809
  }
1681
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
+
1682
1858
  const metadata: Record<string, unknown> = {
1683
1859
  sourceType: "agent_learning",
1860
+ memoryLayer: "procedural",
1684
1861
  memoryOwner: "agent",
1685
1862
  memoryKind: params.kind,
1686
1863
  captureIntent: params.captureIntent,
@@ -1690,7 +1867,13 @@ const memoryBraidPlugin = {
1690
1867
  agentId: params.runtimeScope.agentId,
1691
1868
  sessionKey: params.runtimeScope.sessionKey,
1692
1869
  indexedAt: new Date().toISOString(),
1870
+ firstSeenAt: new Date().toISOString(),
1871
+ lastSeenAt: new Date().toISOString(),
1872
+ eventAt: new Date().toISOString(),
1693
1873
  contentHash: exactHash,
1874
+ selectionDecision: selection.decision,
1875
+ rememberabilityScore: selection.score,
1876
+ rememberabilityReasons: selection.reasons,
1694
1877
  };
1695
1878
  if (typeof params.confidence === "number") {
1696
1879
  metadata.confidence = Math.max(0, Math.min(1, params.confidence));
@@ -1725,6 +1908,288 @@ const memoryBraidPlugin = {
1725
1908
  };
1726
1909
  }
1727
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
+
1728
2193
  api.registerTool(
1729
2194
  (ctx) => {
1730
2195
  const local = resolveLocalTools(api, ctx);
@@ -1915,7 +2380,8 @@ const memoryBraidPlugin = {
1915
2380
 
1916
2381
  api.registerCommand({
1917
2382
  name: "memorybraid",
1918
- 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.",
1919
2385
  acceptsArgs: true,
1920
2386
  handler: async (ctx) => {
1921
2387
  const args = ctx.args?.trim() ?? "";
@@ -1927,6 +2393,15 @@ const memoryBraidPlugin = {
1927
2393
  config: ctx.config,
1928
2394
  });
1929
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
+ };
1930
2405
  const lifecycle =
1931
2406
  cfg.lifecycle.enabled && paths
1932
2407
  ? await readLifecycleState(paths)
@@ -1935,6 +2410,11 @@ const memoryBraidPlugin = {
1935
2410
  text: [
1936
2411
  `capture.mode: ${cfg.capture.mode}`,
1937
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}`,
1938
2418
  `capture.assistant.autoCapture: ${cfg.capture.assistant.autoCapture}`,
1939
2419
  `capture.assistant.explicitTool: ${cfg.capture.assistant.explicitTool}`,
1940
2420
  `recall.user.injectTopK: ${cfg.recall.user.injectTopK}`,
@@ -1947,6 +2427,14 @@ const memoryBraidPlugin = {
1947
2427
  `lifecycle.captureTtlDays: ${cfg.lifecycle.captureTtlDays}`,
1948
2428
  `lifecycle.cleanupIntervalMinutes: ${cfg.lifecycle.cleanupIntervalMinutes}`,
1949
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"}`,
1950
2438
  `lifecycle.tracked: ${Object.keys(lifecycle.entries).length}`,
1951
2439
  `lifecycle.lastCleanupAt: ${lifecycle.lastCleanupAt ?? "n/a"}`,
1952
2440
  `lifecycle.lastCleanupReason: ${lifecycle.lastCleanupReason ?? "n/a"}`,
@@ -1966,6 +2454,7 @@ const memoryBraidPlugin = {
1966
2454
 
1967
2455
  const stats = await readStatsState(paths);
1968
2456
  const lifecycle = await readLifecycleState(paths);
2457
+ const consolidation = await readConsolidationState(paths);
1969
2458
  const capture = stats.capture;
1970
2459
  const mem0SuccessRate =
1971
2460
  capture.mem0AddAttempts > 0
@@ -2010,8 +2499,19 @@ const memoryBraidPlugin = {
2010
2499
  `- agentLearningAutoRejected: ${capture.agentLearningAutoRejected}`,
2011
2500
  `- agentLearningInjected: ${capture.agentLearningInjected}`,
2012
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}`,
2013
2512
  `- lastRunAt: ${capture.lastRunAt ?? "n/a"}`,
2014
2513
  `- lastRemediationAt: ${capture.lastRemediationAt ?? "n/a"}`,
2514
+ `- lastConsolidationAt: ${capture.lastConsolidationAt ?? "n/a"}`,
2015
2515
  "",
2016
2516
  "Lifecycle:",
2017
2517
  `- enabled: ${cfg.lifecycle.enabled}`,
@@ -2025,10 +2525,144 @@ const memoryBraidPlugin = {
2025
2525
  `- lastCleanupExpired: ${lifecycle.lastCleanupExpired ?? "n/a"}`,
2026
2526
  `- lastCleanupDeleted: ${lifecycle.lastCleanupDeleted ?? "n/a"}`,
2027
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"}`,
2028
2538
  ].join("\n"),
2029
2539
  };
2030
2540
  }
2031
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
+
2032
2666
  if (action === "audit" || action === "remediate") {
2033
2667
  const subAction = action === "audit" ? "audit" : (tokens[1] ?? "audit").toLowerCase();
2034
2668
  if (
@@ -2101,6 +2735,34 @@ const memoryBraidPlugin = {
2101
2735
  };
2102
2736
  }
2103
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
+
2104
2766
  if (action === "warmup") {
2105
2767
  const runId = log.newRunId();
2106
2768
  const forceReload = tokens.some((token) => token === "--force");
@@ -2134,7 +2796,7 @@ const memoryBraidPlugin = {
2134
2796
 
2135
2797
  return {
2136
2798
  text:
2137
- "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]]",
2138
2800
  };
2139
2801
  },
2140
2802
  });
@@ -2575,6 +3237,7 @@ const memoryBraidPlugin = {
2575
3237
  let mem0AddWithId = 0;
2576
3238
  let mem0AddWithoutId = 0;
2577
3239
  let remoteQuarantineFiltered = 0;
3240
+ let selectionSkipped = 0;
2578
3241
  const remediationState = await readRemediationState(runtimeStatePaths);
2579
3242
  const successfulAdds: Array<{
2580
3243
  memoryId: string;
@@ -2644,8 +3307,10 @@ const memoryBraidPlugin = {
2644
3307
  continue;
2645
3308
  }
2646
3309
 
3310
+ const indexedAt = new Date().toISOString();
2647
3311
  const metadata: Record<string, unknown> = {
2648
3312
  sourceType: "capture",
3313
+ memoryLayer: "episodic",
2649
3314
  memoryOwner: "user",
2650
3315
  memoryKind: mapCategoryToMemoryKind(candidate.category),
2651
3316
  captureIntent: "observed",
@@ -2663,7 +3328,11 @@ const memoryBraidPlugin = {
2663
3328
  capturePath: captureInput.capturePath,
2664
3329
  pluginCaptureVersion: PLUGIN_CAPTURE_VERSION,
2665
3330
  contentHash: hash,
2666
- indexedAt: new Date().toISOString(),
3331
+ indexedAt,
3332
+ eventAt: indexedAt,
3333
+ firstSeenAt: indexedAt,
3334
+ lastSeenAt: indexedAt,
3335
+ supportCount: 1,
2667
3336
  };
2668
3337
 
2669
3338
  if (cfg.entityExtraction.enabled) {
@@ -2678,6 +3347,50 @@ const memoryBraidPlugin = {
2678
3347
  metadata.entities = entities;
2679
3348
  }
2680
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
+ }
2681
3394
 
2682
3395
  const quarantine = isQuarantinedMemory(
2683
3396
  {
@@ -2723,6 +3436,7 @@ const memoryBraidPlugin = {
2723
3436
 
2724
3437
  await withStateLock(runtimeStatePaths.stateLockFile, async () => {
2725
3438
  const dedupe = await readCaptureDedupeState(runtimeStatePaths);
3439
+ const consolidation = await readConsolidationState(runtimeStatePaths);
2726
3440
  const stats = await readStatsState(runtimeStatePaths);
2727
3441
  const lifecycle = cfg.lifecycle.enabled
2728
3442
  ? await readLifecycleState(runtimeStatePaths)
@@ -2773,11 +3487,14 @@ const memoryBraidPlugin = {
2773
3487
  stats.capture.provenanceSkipped += provenanceSkipped;
2774
3488
  stats.capture.transcriptShapeSkipped += transcriptShapeSkipped;
2775
3489
  stats.capture.quarantinedFiltered += remoteQuarantineFiltered;
3490
+ stats.capture.selectionSkipped += selectionSkipped;
2776
3491
  stats.capture.agentLearningAutoCaptured += agentLearningAutoCaptured;
2777
3492
  stats.capture.agentLearningAutoRejected += agentLearningAutoRejected;
2778
3493
  stats.capture.lastRunAt = new Date(now).toISOString();
3494
+ consolidation.newEpisodicSinceLastRun += persisted;
2779
3495
 
2780
3496
  await writeCaptureDedupeState(runtimeStatePaths, dedupe);
3497
+ await writeConsolidationState(runtimeStatePaths, consolidation);
2781
3498
  if (lifecycle) {
2782
3499
  await writeLifecycleState(runtimeStatePaths, lifecycle);
2783
3500
  }
@@ -2807,6 +3524,12 @@ const memoryBraidPlugin = {
2807
3524
  agentLearningAutoRejected,
2808
3525
  }, true);
2809
3526
  });
3527
+
3528
+ await maybeRunOpportunisticConsolidation({
3529
+ scope: persistentScope,
3530
+ runtimeStatePaths,
3531
+ runId,
3532
+ });
2810
3533
  });
2811
3534
 
2812
3535
  api.registerService({
@@ -2848,6 +3571,12 @@ const memoryBraidPlugin = {
2848
3571
  lifecycleCaptureTtlDays: cfg.lifecycle.captureTtlDays,
2849
3572
  lifecycleCleanupIntervalMinutes: cfg.lifecycle.cleanupIntervalMinutes,
2850
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,
2851
3580
  entityExtractionEnabled: cfg.entityExtraction.enabled,
2852
3581
  entityProvider: cfg.entityExtraction.provider,
2853
3582
  entityModel: cfg.entityExtraction.model,
@@ -2906,12 +3635,48 @@ const memoryBraidPlugin = {
2906
3635
  });
2907
3636
  }, intervalMs);
2908
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
+ }
2909
3670
  },
2910
3671
  stop: async () => {
2911
3672
  if (lifecycleTimer) {
2912
3673
  clearInterval(lifecycleTimer);
2913
3674
  lifecycleTimer = null;
2914
3675
  }
3676
+ if (consolidationTimer) {
3677
+ clearInterval(consolidationTimer);
3678
+ consolidationTimer = null;
3679
+ }
2915
3680
  },
2916
3681
  });
2917
3682
  },