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.
@@ -0,0 +1,336 @@
1
+ import { normalizeWhitespace } from "./chunking.js";
2
+ import type { ExtractedEntity } from "./entities.js";
3
+ import type {
4
+ MemoryBraidResult,
5
+ MemoryKind,
6
+ MemoryLayer,
7
+ MemoryOwner,
8
+ TaxonomyBuckets,
9
+ } from "./types.js";
10
+
11
+ const TOPIC_STOPWORDS = new Set([
12
+ "about",
13
+ "after",
14
+ "agent",
15
+ "always",
16
+ "before",
17
+ "from",
18
+ "have",
19
+ "into",
20
+ "just",
21
+ "keep",
22
+ "like",
23
+ "memory",
24
+ "never",
25
+ "note",
26
+ "only",
27
+ "remember",
28
+ "that",
29
+ "their",
30
+ "them",
31
+ "they",
32
+ "this",
33
+ "turn",
34
+ "user",
35
+ "using",
36
+ "what",
37
+ "when",
38
+ "will",
39
+ "with",
40
+ ]);
41
+
42
+ export function asRecord(value: unknown): Record<string, unknown> {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
44
+ return {};
45
+ }
46
+ return value as Record<string, unknown>;
47
+ }
48
+
49
+ export function asString(value: unknown): string | undefined {
50
+ if (typeof value !== "string") {
51
+ return undefined;
52
+ }
53
+ const trimmed = value.trim();
54
+ return trimmed || undefined;
55
+ }
56
+
57
+ export function normalizeMemoryKind(raw: unknown): MemoryKind | undefined {
58
+ return raw === "fact" ||
59
+ raw === "preference" ||
60
+ raw === "decision" ||
61
+ raw === "task" ||
62
+ raw === "heuristic" ||
63
+ raw === "lesson" ||
64
+ raw === "strategy" ||
65
+ raw === "other"
66
+ ? raw
67
+ : undefined;
68
+ }
69
+
70
+ export function normalizeMemoryOwner(raw: unknown): MemoryOwner | undefined {
71
+ return raw === "user" || raw === "agent" ? raw : undefined;
72
+ }
73
+
74
+ export function normalizeMemoryLayer(raw: unknown): MemoryLayer | undefined {
75
+ return raw === "episodic" || raw === "semantic" || raw === "procedural" ? raw : undefined;
76
+ }
77
+
78
+ export function emptyTaxonomy(): TaxonomyBuckets {
79
+ return {
80
+ people: [],
81
+ places: [],
82
+ organizations: [],
83
+ projects: [],
84
+ tools: [],
85
+ topics: [],
86
+ };
87
+ }
88
+
89
+ function slugify(value: string): string {
90
+ return value
91
+ .normalize("NFKD")
92
+ .replace(/\p{M}+/gu, "")
93
+ .toLowerCase()
94
+ .replace(/[^a-z0-9]+/g, "-")
95
+ .replace(/^-+|-+$/g, "");
96
+ }
97
+
98
+ function pushBucket(target: string[], value: string): void {
99
+ const cleaned = normalizeWhitespace(value);
100
+ if (!cleaned) {
101
+ return;
102
+ }
103
+ const existing = new Set(target.map((entry) => slugify(entry)));
104
+ const key = slugify(cleaned);
105
+ if (!key || existing.has(key)) {
106
+ return;
107
+ }
108
+ target.push(cleaned);
109
+ }
110
+
111
+ function firstWords(text: string, count: number): string {
112
+ return text
113
+ .split(/\s+/)
114
+ .filter(Boolean)
115
+ .slice(0, count)
116
+ .join(" ");
117
+ }
118
+
119
+ function normalizeEntityRows(raw: unknown): ExtractedEntity[] {
120
+ if (!Array.isArray(raw)) {
121
+ return [];
122
+ }
123
+ const out: ExtractedEntity[] = [];
124
+ for (const value of raw) {
125
+ const row = asRecord(value);
126
+ const text = asString(row.text);
127
+ const type = asString(row.type);
128
+ const canonicalUri = asString(row.canonicalUri);
129
+ if (!text || !type || !canonicalUri) {
130
+ continue;
131
+ }
132
+ if (type !== "person" && type !== "organization" && type !== "location" && type !== "misc") {
133
+ continue;
134
+ }
135
+ out.push({
136
+ text,
137
+ type,
138
+ score:
139
+ typeof row.score === "number" && Number.isFinite(row.score)
140
+ ? Math.max(0, Math.min(1, row.score))
141
+ : 0,
142
+ canonicalUri,
143
+ });
144
+ }
145
+ return out;
146
+ }
147
+
148
+ function deriveToolCandidates(text: string): string[] {
149
+ const matches = [
150
+ ...text.matchAll(/`([^`]{2,40})`/g),
151
+ ...text.matchAll(/\b(?:use|using|with|tool|library|framework)\s+([A-Z][A-Za-z0-9._-]{1,40})/g),
152
+ ];
153
+ return matches.map((match) => normalizeWhitespace(match[1] ?? "")).filter(Boolean);
154
+ }
155
+
156
+ function deriveProjectCandidates(text: string): string[] {
157
+ const matches = [
158
+ ...text.matchAll(/\bproject\s+([A-Z][A-Za-z0-9._-]{1,50})/gi),
159
+ ...text.matchAll(/\b(?:repo|workspace)\s+([A-Z][A-Za-z0-9._-]{1,50})/gi),
160
+ ];
161
+ return matches.map((match) => normalizeWhitespace(match[1] ?? "")).filter(Boolean);
162
+ }
163
+
164
+ function deriveTopicCandidates(text: string): string[] {
165
+ const tokens = text.match(/[\p{L}\p{N}][\p{L}\p{N}-]{2,}/gu) ?? [];
166
+ const seen = new Set<string>();
167
+ const out: string[] = [];
168
+ for (const token of tokens) {
169
+ const normalized = slugify(token);
170
+ if (!normalized || TOPIC_STOPWORDS.has(normalized) || seen.has(normalized)) {
171
+ continue;
172
+ }
173
+ seen.add(normalized);
174
+ out.push(token);
175
+ if (out.length >= 3) {
176
+ break;
177
+ }
178
+ }
179
+ return out;
180
+ }
181
+
182
+ export function buildTaxonomy(params: {
183
+ text: string;
184
+ entities?: unknown;
185
+ existingTaxonomy?: unknown;
186
+ }): TaxonomyBuckets {
187
+ const taxonomy = normalizeTaxonomy(params.existingTaxonomy);
188
+ for (const entity of normalizeEntityRows(params.entities)) {
189
+ if (entity.type === "person") {
190
+ pushBucket(taxonomy.people, entity.text);
191
+ } else if (entity.type === "organization") {
192
+ pushBucket(taxonomy.organizations, entity.text);
193
+ } else if (entity.type === "location") {
194
+ pushBucket(taxonomy.places, entity.text);
195
+ }
196
+ }
197
+
198
+ for (const candidate of deriveToolCandidates(params.text)) {
199
+ pushBucket(taxonomy.tools, candidate);
200
+ }
201
+ for (const candidate of deriveProjectCandidates(params.text)) {
202
+ pushBucket(taxonomy.projects, candidate);
203
+ }
204
+ for (const candidate of deriveTopicCandidates(params.text)) {
205
+ pushBucket(taxonomy.topics, candidate);
206
+ }
207
+
208
+ return taxonomy;
209
+ }
210
+
211
+ export function normalizeTaxonomy(raw: unknown): TaxonomyBuckets {
212
+ const source = asRecord(raw);
213
+ const out = emptyTaxonomy();
214
+ const keys = Object.keys(out) as Array<keyof TaxonomyBuckets>;
215
+ for (const key of keys) {
216
+ const values = Array.isArray(source[key]) ? source[key] : [];
217
+ for (const value of values) {
218
+ if (typeof value === "string") {
219
+ pushBucket(out[key], value);
220
+ }
221
+ }
222
+ }
223
+ return out;
224
+ }
225
+
226
+ export function taxonomyTerms(taxonomy: TaxonomyBuckets): string[] {
227
+ return [
228
+ ...taxonomy.people,
229
+ ...taxonomy.places,
230
+ ...taxonomy.organizations,
231
+ ...taxonomy.projects,
232
+ ...taxonomy.tools,
233
+ ...taxonomy.topics,
234
+ ];
235
+ }
236
+
237
+ export function taxonomyOverlap(left: TaxonomyBuckets, right: TaxonomyBuckets): number {
238
+ const leftTerms = new Set(taxonomyTerms(left).map(slugify));
239
+ const rightTerms = new Set(taxonomyTerms(right).map(slugify));
240
+ if (leftTerms.size === 0 || rightTerms.size === 0) {
241
+ return 0;
242
+ }
243
+ let shared = 0;
244
+ for (const term of leftTerms) {
245
+ if (rightTerms.has(term)) {
246
+ shared += 1;
247
+ }
248
+ }
249
+ return shared / Math.max(leftTerms.size, rightTerms.size);
250
+ }
251
+
252
+ export function primaryTaxonomyAnchor(taxonomy: TaxonomyBuckets): string | undefined {
253
+ return (
254
+ taxonomy.people[0] ??
255
+ taxonomy.organizations[0] ??
256
+ taxonomy.projects[0] ??
257
+ taxonomy.tools[0] ??
258
+ taxonomy.topics[0] ??
259
+ taxonomy.places[0]
260
+ );
261
+ }
262
+
263
+ export function formatTaxonomySummary(taxonomy: TaxonomyBuckets): string {
264
+ const lines: string[] = [];
265
+ const ordered: Array<keyof TaxonomyBuckets> = [
266
+ "people",
267
+ "places",
268
+ "organizations",
269
+ "projects",
270
+ "tools",
271
+ "topics",
272
+ ];
273
+ for (const key of ordered) {
274
+ if (taxonomy[key].length > 0) {
275
+ lines.push(`${key}=${taxonomy[key].join(", ")}`);
276
+ }
277
+ }
278
+ return lines.join(" | ");
279
+ }
280
+
281
+ export function inferMemoryLayer(result: MemoryBraidResult): MemoryLayer {
282
+ const metadata = asRecord(result.metadata);
283
+ const explicit = normalizeMemoryLayer(metadata.memoryLayer);
284
+ if (explicit) {
285
+ return explicit;
286
+ }
287
+ const sourceType = asString(metadata.sourceType);
288
+ if (sourceType === "capture") {
289
+ return "episodic";
290
+ }
291
+ if (sourceType === "agent_learning") {
292
+ return "procedural";
293
+ }
294
+ if (sourceType === "compendium") {
295
+ return "semantic";
296
+ }
297
+ const owner = normalizeMemoryOwner(metadata.memoryOwner);
298
+ if (owner === "agent") {
299
+ return "procedural";
300
+ }
301
+ return "episodic";
302
+ }
303
+
304
+ export function summarizeSnippet(text: string, maxChars = 140): string {
305
+ const normalized = normalizeWhitespace(text);
306
+ if (normalized.length <= maxChars) {
307
+ return normalized;
308
+ }
309
+ return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
310
+ }
311
+
312
+ export function stripCapturePreamble(text: string): string {
313
+ const normalized = normalizeWhitespace(text);
314
+ return normalized.replace(/^(?:remember that|note that|we discussed that)\s+/i, "");
315
+ }
316
+
317
+ export function summarizeClusterText(texts: string[], kind?: MemoryKind): string {
318
+ const latest = stripCapturePreamble(texts[texts.length - 1] ?? "");
319
+ const base = latest || stripCapturePreamble(texts[0] ?? "");
320
+ if (!base) {
321
+ return "";
322
+ }
323
+ if (kind === "preference") {
324
+ return `Preference: ${firstWords(base, 24)}`;
325
+ }
326
+ if (kind === "decision") {
327
+ return `Decision: ${firstWords(base, 24)}`;
328
+ }
329
+ if (kind === "fact") {
330
+ return `Fact: ${firstWords(base, 24)}`;
331
+ }
332
+ if (kind === "task") {
333
+ return `Recurring task context: ${firstWords(base, 24)}`;
334
+ }
335
+ return firstWords(base, 28);
336
+ }
@@ -0,0 +1,257 @@
1
+ import { normalizeWhitespace } from "./chunking.js";
2
+ import {
3
+ isLikelyTranscriptLikeText,
4
+ isLikelyTurnRecap,
5
+ } from "./capture.js";
6
+ import {
7
+ primaryTaxonomyAnchor,
8
+ taxonomyTerms,
9
+ } from "./memory-model.js";
10
+ import type {
11
+ MemoryBraidConfig,
12
+ } from "./config.js";
13
+ import type {
14
+ MemoryKind,
15
+ MemorySelectionDecision,
16
+ TaxonomyBuckets,
17
+ } from "./types.js";
18
+
19
+ type SelectionResult = {
20
+ decision: MemorySelectionDecision;
21
+ score: number;
22
+ reasons: string[];
23
+ };
24
+
25
+ function clampScore(value: number): number {
26
+ return Math.max(0, Math.min(1, value));
27
+ }
28
+
29
+ function pushReason(reasons: string[], condition: boolean, reason: string): void {
30
+ if (condition) {
31
+ reasons.push(reason);
32
+ }
33
+ }
34
+
35
+ function stableSignal(text: string): boolean {
36
+ return /\b(?:prefer|timezone|name is|works at|work at|team|organization|project|repo|workspace|we decided|decision|we will|we use|deploy|stack|tooling)\b/i.test(
37
+ text,
38
+ );
39
+ }
40
+
41
+ function explicitRememberSignal(text: string): boolean {
42
+ return /^(?:remember|note)\b/i.test(text);
43
+ }
44
+
45
+ function volatileSignal(text: string): boolean {
46
+ return /\b(?:today|tomorrow|yesterday|later today|this afternoon|tonight|this week|next week|this session|this chat|just now|one-off)\b/i.test(
47
+ text,
48
+ );
49
+ }
50
+
51
+ function recurringTaskSignal(text: string): boolean {
52
+ return /\b(?:every|weekly|monthly|each|recurring|routine|regularly)\b/i.test(text);
53
+ }
54
+
55
+ function firstPersonOwnershipSignal(text: string): boolean {
56
+ return /\b(?:my|i prefer|i like|i use|we decided|our|we use)\b/i.test(text);
57
+ }
58
+
59
+ function thresholdForKind(cfg: MemoryBraidConfig, kind: MemoryKind): number {
60
+ if (kind === "preference" || kind === "decision") {
61
+ return cfg.capture.selection.minPreferenceDecisionScore;
62
+ }
63
+ if (kind === "fact") {
64
+ return cfg.capture.selection.minFactScore;
65
+ }
66
+ if (kind === "task") {
67
+ return cfg.capture.selection.minTaskScore;
68
+ }
69
+ return cfg.capture.selection.minOtherScore;
70
+ }
71
+
72
+ export function scoreObservedMemory(params: {
73
+ text: string;
74
+ kind: MemoryKind;
75
+ extractionScore: number;
76
+ taxonomy: TaxonomyBuckets;
77
+ source: "heuristic" | "ml";
78
+ cfg: MemoryBraidConfig;
79
+ }): SelectionResult {
80
+ const text = normalizeWhitespace(params.text);
81
+ const reasons: string[] = [];
82
+ if (!text || isLikelyTranscriptLikeText(text) || isLikelyTurnRecap(text)) {
83
+ return {
84
+ decision: "ignore",
85
+ score: 0,
86
+ reasons: ["invalid_or_recap"],
87
+ };
88
+ }
89
+
90
+ let score = clampScore(params.extractionScore) * 0.45;
91
+ const taxonomyCount = taxonomyTerms(params.taxonomy).length;
92
+ const hasAnchor = Boolean(primaryTaxonomyAnchor(params.taxonomy));
93
+
94
+ if (params.kind === "preference") {
95
+ score += 0.22;
96
+ reasons.push("kind:preference");
97
+ } else if (params.kind === "decision") {
98
+ score += 0.2;
99
+ reasons.push("kind:decision");
100
+ } else if (params.kind === "fact") {
101
+ score += 0.14;
102
+ reasons.push("kind:fact");
103
+ } else if (params.kind === "task") {
104
+ score += 0.04;
105
+ reasons.push("kind:task");
106
+ }
107
+
108
+ pushReason(reasons, explicitRememberSignal(text), "explicit_remember");
109
+ if (explicitRememberSignal(text)) {
110
+ score += 0.06;
111
+ }
112
+ pushReason(reasons, stableSignal(text), "stable_signal");
113
+ if (stableSignal(text)) {
114
+ score += 0.12;
115
+ }
116
+ pushReason(reasons, firstPersonOwnershipSignal(text), "first_person");
117
+ if (firstPersonOwnershipSignal(text)) {
118
+ score += 0.08;
119
+ }
120
+ pushReason(reasons, hasAnchor, "taxonomy_anchor");
121
+ if (hasAnchor) {
122
+ score += 0.08;
123
+ }
124
+ if (taxonomyCount >= 2) {
125
+ score += 0.04;
126
+ reasons.push("taxonomy_rich");
127
+ }
128
+ if (params.source === "ml") {
129
+ reasons.push("ml_extracted");
130
+ }
131
+ pushReason(reasons, volatileSignal(text), "volatile_signal");
132
+ if (volatileSignal(text)) {
133
+ score -= 0.35;
134
+ }
135
+ if (params.kind === "task" && !recurringTaskSignal(text)) {
136
+ score -= 0.2;
137
+ reasons.push("one_off_task_penalty");
138
+ }
139
+ if (params.kind === "other") {
140
+ score -= 0.18;
141
+ reasons.push("kind:other_penalty");
142
+ }
143
+
144
+ const finalScore = clampScore(score);
145
+ return {
146
+ decision: finalScore >= thresholdForKind(params.cfg, params.kind) ? "episodic" : "ignore",
147
+ score: finalScore,
148
+ reasons,
149
+ };
150
+ }
151
+
152
+ export function scoreProceduralMemory(params: {
153
+ text: string;
154
+ confidence?: number;
155
+ captureIntent: "explicit_tool" | "self_reflection";
156
+ cfg: MemoryBraidConfig;
157
+ }): SelectionResult {
158
+ const text = normalizeWhitespace(params.text);
159
+ const reasons: string[] = [];
160
+ if (!text || isLikelyTranscriptLikeText(text) || isLikelyTurnRecap(text)) {
161
+ return {
162
+ decision: "ignore",
163
+ score: 0,
164
+ reasons: ["invalid_or_recap"],
165
+ };
166
+ }
167
+
168
+ let score = clampScore(params.confidence ?? 0.65) * 0.4;
169
+ if (/\b(?:always|never|prefer|avoid|use|keep|store|limit|filter|dedupe|search|persist|only|when|if|strategy|approach|plan)\b/i.test(text)) {
170
+ score += 0.3;
171
+ reasons.push("reusable_procedure");
172
+ }
173
+ if (params.captureIntent === "explicit_tool") {
174
+ score += 0.12;
175
+ reasons.push("explicit_tool");
176
+ } else {
177
+ score += 0.05;
178
+ reasons.push("self_reflection");
179
+ }
180
+ if (text.length >= 32 && text.length <= 220) {
181
+ score += 0.08;
182
+ reasons.push("compact_atomic");
183
+ }
184
+ if (volatileSignal(text)) {
185
+ score -= 0.35;
186
+ reasons.push("volatile_signal");
187
+ }
188
+
189
+ const finalScore = clampScore(score);
190
+ return {
191
+ decision: finalScore >= params.cfg.capture.selection.minProceduralScore ? "procedural" : "ignore",
192
+ score: finalScore,
193
+ reasons,
194
+ };
195
+ }
196
+
197
+ export function scoreSemanticPromotion(params: {
198
+ kind: MemoryKind;
199
+ supportCount: number;
200
+ recallSupport: number;
201
+ taxonomy: TaxonomyBuckets;
202
+ firstSeenAt: number;
203
+ lastSeenAt: number;
204
+ sessionKeys: Set<string>;
205
+ text: string;
206
+ cfg: MemoryBraidConfig;
207
+ }): SelectionResult {
208
+ const reasons: string[] = [];
209
+ let score = 0;
210
+ score += Math.min(0.4, Math.max(0, params.supportCount - 1) * 0.18);
211
+ if (params.supportCount > 1) {
212
+ reasons.push("repeated_support");
213
+ }
214
+ score += Math.min(0.18, params.recallSupport * 0.06);
215
+ if (params.recallSupport > 0) {
216
+ reasons.push("recall_reinforced");
217
+ }
218
+ if (params.sessionKeys.size > 1) {
219
+ score += 0.14;
220
+ reasons.push("cross_session");
221
+ }
222
+ const ageDays = Math.max(0, (params.lastSeenAt - params.firstSeenAt) / (24 * 60 * 60 * 1000));
223
+ if (ageDays >= 1) {
224
+ score += Math.min(0.12, ageDays / 14);
225
+ reasons.push("survived_over_time");
226
+ }
227
+ if (params.kind === "preference" || params.kind === "decision" || params.kind === "fact") {
228
+ score += 0.1;
229
+ reasons.push(`kind:${params.kind}`);
230
+ } else if (params.kind === "task" || params.kind === "other") {
231
+ score -= 0.12;
232
+ reasons.push(`kind:${params.kind}_penalty`);
233
+ }
234
+ if (primaryTaxonomyAnchor(params.taxonomy)) {
235
+ score += 0.08;
236
+ reasons.push("taxonomy_anchor");
237
+ }
238
+ if (taxonomyTerms(params.taxonomy).length >= 2) {
239
+ score += 0.04;
240
+ reasons.push("taxonomy_rich");
241
+ }
242
+ if (volatileSignal(params.text) && params.kind !== "preference" && params.kind !== "decision") {
243
+ score -= 0.18;
244
+ reasons.push("volatile_signal");
245
+ }
246
+
247
+ const finalScore = clampScore(score);
248
+ return {
249
+ decision: finalScore >= params.cfg.consolidation.minSelectionScore ? "semantic" : "ignore",
250
+ score: finalScore,
251
+ reasons,
252
+ };
253
+ }
254
+
255
+ export function summarizeSelection(result: SelectionResult): string {
256
+ return `${result.decision} score=${result.score.toFixed(2)} reasons=${result.reasons.join(",") || "n/a"}`;
257
+ }
package/src/state.ts CHANGED
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type {
4
4
  CaptureDedupeState,
5
+ ConsolidationState,
5
6
  LifecycleState,
6
7
  PluginStatsState,
7
8
  RemediationState,
@@ -47,6 +48,16 @@ const DEFAULT_STATS: PluginStatsState = {
47
48
  agentLearningAutoRejected: 0,
48
49
  agentLearningInjected: 0,
49
50
  agentLearningRecallHits: 0,
51
+ selectionSkipped: 0,
52
+ agentLearningRejectedSelection: 0,
53
+ consolidationRuns: 0,
54
+ consolidationCandidates: 0,
55
+ clustersFormed: 0,
56
+ semanticCreated: 0,
57
+ semanticUpdated: 0,
58
+ episodicMarkedConsolidated: 0,
59
+ contradictionsDetected: 0,
60
+ supersededMarked: 0,
50
61
  },
51
62
  };
52
63
 
@@ -55,12 +66,19 @@ const DEFAULT_REMEDIATION: RemediationState = {
55
66
  quarantined: {},
56
67
  };
57
68
 
69
+ const DEFAULT_CONSOLIDATION: ConsolidationState = {
70
+ version: 1,
71
+ newEpisodicSinceLastRun: 0,
72
+ semanticByCompendiumKey: {},
73
+ };
74
+
58
75
  export type StatePaths = {
59
76
  rootDir: string;
60
77
  captureDedupeFile: string;
61
78
  lifecycleFile: string;
62
79
  statsFile: string;
63
80
  remediationFile: string;
81
+ consolidationFile: string;
64
82
  stateLockFile: string;
65
83
  };
66
84
 
@@ -72,6 +90,7 @@ export function createStatePaths(stateDir: string): StatePaths {
72
90
  lifecycleFile: path.join(rootDir, "lifecycle.v1.json"),
73
91
  statsFile: path.join(rootDir, "stats.v1.json"),
74
92
  remediationFile: path.join(rootDir, "remediation.v1.json"),
93
+ consolidationFile: path.join(rootDir, "consolidation.v1.json"),
75
94
  stateLockFile: path.join(rootDir, "state.v1.lock"),
76
95
  };
77
96
  }
@@ -160,6 +179,27 @@ export async function writeRemediationState(
160
179
  await writeJsonFile(paths.remediationFile, state);
161
180
  }
162
181
 
182
+ export async function readConsolidationState(paths: StatePaths): Promise<ConsolidationState> {
183
+ const value = await readJsonFile(paths.consolidationFile, DEFAULT_CONSOLIDATION);
184
+ return {
185
+ version: 1,
186
+ lastConsolidationAt: value.lastConsolidationAt,
187
+ lastConsolidationReason: value.lastConsolidationReason,
188
+ newEpisodicSinceLastRun:
189
+ typeof value.newEpisodicSinceLastRun === "number" && Number.isFinite(value.newEpisodicSinceLastRun)
190
+ ? Math.max(0, Math.round(value.newEpisodicSinceLastRun))
191
+ : 0,
192
+ semanticByCompendiumKey: { ...(value.semanticByCompendiumKey ?? {}) },
193
+ };
194
+ }
195
+
196
+ export async function writeConsolidationState(
197
+ paths: StatePaths,
198
+ state: ConsolidationState,
199
+ ): Promise<void> {
200
+ await writeJsonFile(paths.consolidationFile, state);
201
+ }
202
+
163
203
  export async function withStateLock<T>(
164
204
  lockFilePath: string,
165
205
  fn: () => Promise<T>,