claude-memory-layer 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/AGENTS.md +60 -0
  2. package/README.md +166 -2
  3. package/bootstrap-kb/decisions/decisions.md +244 -0
  4. package/bootstrap-kb/glossary/glossary.md +46 -0
  5. package/bootstrap-kb/modules/.claude-plugin.md +22 -0
  6. package/bootstrap-kb/modules/agents.md.md +15 -0
  7. package/bootstrap-kb/modules/claude.md.md +15 -0
  8. package/bootstrap-kb/modules/context.md.md +15 -0
  9. package/bootstrap-kb/modules/docs.md +18 -0
  10. package/bootstrap-kb/modules/handoff.md.md +15 -0
  11. package/bootstrap-kb/modules/package-lock.json.md +15 -0
  12. package/bootstrap-kb/modules/package.json.md +15 -0
  13. package/bootstrap-kb/modules/plan.md.md +15 -0
  14. package/bootstrap-kb/modules/readme.md.md +15 -0
  15. package/bootstrap-kb/modules/scripts.md +26 -0
  16. package/bootstrap-kb/modules/spec.md.md +15 -0
  17. package/bootstrap-kb/modules/specs.md +20 -0
  18. package/bootstrap-kb/modules/src.md +51 -0
  19. package/bootstrap-kb/modules/tests.md +42 -0
  20. package/bootstrap-kb/modules/tsconfig.json.md +15 -0
  21. package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
  22. package/bootstrap-kb/overview/overview.md +40 -0
  23. package/bootstrap-kb/sources/manifest.json +950 -0
  24. package/bootstrap-kb/sources/manifest.md +227 -0
  25. package/bootstrap-kb/timeline/timeline.md +57 -0
  26. package/d.sh +3 -0
  27. package/deploy.sh +3 -0
  28. package/dist/cli/index.js +3577 -389
  29. package/dist/cli/index.js.map +4 -4
  30. package/dist/core/index.js +1383 -138
  31. package/dist/core/index.js.map +4 -4
  32. package/dist/hooks/post-tool-use.js +1917 -214
  33. package/dist/hooks/post-tool-use.js.map +4 -4
  34. package/dist/hooks/session-end.js +1813 -231
  35. package/dist/hooks/session-end.js.map +4 -4
  36. package/dist/hooks/session-start.js +1802 -205
  37. package/dist/hooks/session-start.js.map +4 -4
  38. package/dist/hooks/stop.js +1909 -248
  39. package/dist/hooks/stop.js.map +4 -4
  40. package/dist/hooks/user-prompt-submit.js +1861 -206
  41. package/dist/hooks/user-prompt-submit.js.map +4 -4
  42. package/dist/server/api/index.js +2341 -217
  43. package/dist/server/api/index.js.map +4 -4
  44. package/dist/server/index.js +2350 -226
  45. package/dist/server/index.js.map +4 -4
  46. package/dist/services/memory-service.js +1805 -206
  47. package/dist/services/memory-service.js.map +4 -4
  48. package/dist/ui/app.js +1447 -55
  49. package/dist/ui/index.html +318 -147
  50. package/dist/ui/style.css +892 -0
  51. package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
  52. package/docs/MEMU_ADOPTION.md +40 -0
  53. package/docs/OPERATIONS.md +18 -0
  54. package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
  55. package/memory/_index.md +405 -0
  56. package/memory/default/uncategorized/2026-02-25.md +4839 -0
  57. package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
  58. package/memory/specs/citations-system/2026-02-25.md +1121 -0
  59. package/memory/specs/endless-mode/2026-02-25.md +1392 -0
  60. package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
  61. package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
  62. package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
  63. package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
  64. package/memory/specs/private-tags/2026-02-25.md +1057 -0
  65. package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
  66. package/memory/specs/task-entity-system/2026-02-25.md +924 -0
  67. package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
  68. package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
  69. package/package.json +9 -2
  70. package/scripts/build.ts +6 -0
  71. package/scripts/fix-sync-gap.js +32 -0
  72. package/scripts/heartbeat-memory-orchestrator.sh +28 -0
  73. package/scripts/report-sync-gap.js +26 -0
  74. package/scripts/review-queue-auto-resolve.js +21 -0
  75. package/scripts/sync-gap-auto-heal.sh +17 -0
  76. package/specs/20260207-dashboard-upgrade/context.md +38 -0
  77. package/specs/20260207-dashboard-upgrade/spec.md +96 -0
  78. package/src/cli/index.ts +391 -60
  79. package/src/core/consolidated-store.ts +63 -1
  80. package/src/core/consolidation-worker.ts +115 -6
  81. package/src/core/event-store.ts +14 -0
  82. package/src/core/index.ts +1 -0
  83. package/src/core/ingest-interceptor.ts +80 -0
  84. package/src/core/markdown-mirror.ts +70 -0
  85. package/src/core/md-mirror.ts +92 -0
  86. package/src/core/mongo-sync-config.ts +165 -0
  87. package/src/core/mongo-sync-worker.ts +381 -0
  88. package/src/core/retriever.ts +540 -150
  89. package/src/core/sqlite-event-store.ts +794 -7
  90. package/src/core/sqlite-wrapper.ts +8 -0
  91. package/src/core/tag-taxonomy.ts +51 -0
  92. package/src/core/turn-state.ts +159 -0
  93. package/src/core/types.ts +51 -8
  94. package/src/core/vector-store.ts +21 -3
  95. package/src/hooks/post-tool-use.ts +68 -23
  96. package/src/hooks/session-end.ts +8 -3
  97. package/src/hooks/stop.ts +96 -25
  98. package/src/hooks/user-prompt-submit.ts +44 -5
  99. package/src/server/api/chat.ts +244 -0
  100. package/src/server/api/citations.ts +3 -3
  101. package/src/server/api/events.ts +30 -5
  102. package/src/server/api/health.ts +53 -0
  103. package/src/server/api/index.ts +9 -1
  104. package/src/server/api/projects.ts +74 -0
  105. package/src/server/api/search.ts +3 -3
  106. package/src/server/api/sessions.ts +3 -3
  107. package/src/server/api/stats.ts +89 -8
  108. package/src/server/api/turns.ts +143 -0
  109. package/src/server/api/utils.ts +46 -0
  110. package/src/services/bootstrap-organizer.ts +443 -0
  111. package/src/services/codex-session-history-importer.ts +474 -0
  112. package/src/services/memory-service.ts +508 -71
  113. package/src/services/session-history-importer.ts +215 -51
  114. package/src/ui/app.js +1447 -55
  115. package/src/ui/index.html +318 -147
  116. package/src/ui/style.css +892 -0
  117. package/tests/bootstrap-organizer.test.ts +111 -0
  118. package/tests/consolidation-worker.test.ts +75 -0
  119. package/tests/ingest-interceptor.test.ts +38 -0
  120. package/tests/markdown-mirror.test.ts +85 -0
  121. package/tests/md-mirror.test.ts +50 -0
  122. package/tests/retriever-fallback-chain.test.ts +223 -0
  123. package/tests/retriever-strategy-scope.test.ts +97 -0
  124. package/tests/retriever.memu-adoption.test.ts +122 -0
  125. package/tests/sqlite-event-store-replication.test.ts +92 -0
  126. package/.claude/settings.local.json +0 -27
  127. package/.claude-memory/test.sqlite +0 -0
  128. package/.history/package_20260201112328.json +0 -45
  129. package/.history/package_20260201113602.json +0 -45
  130. package/.history/package_20260201113713.json +0 -45
  131. package/.history/package_20260201114110.json +0 -45
  132. package/.history/package_20260201114632.json +0 -46
  133. package/.history/package_20260201133143.json +0 -45
  134. package/.history/package_20260201134319.json +0 -45
  135. package/.history/package_20260201134326.json +0 -45
  136. package/.history/package_20260201134334.json +0 -45
  137. package/.history/package_20260201134912.json +0 -45
  138. package/.history/package_20260201142928.json +0 -46
  139. package/.history/package_20260201192048.json +0 -47
  140. package/.history/package_20260202114053.json +0 -49
  141. package/.history/package_20260202121115.json +0 -49
  142. package/test_access.js +0 -49
@@ -8,7 +8,8 @@ import type {
8
8
  EndlessModeConfig,
9
9
  MemoryEvent,
10
10
  EventGroup,
11
- WorkingSet
11
+ WorkingSet,
12
+ ConsolidationCostQualityReport
12
13
  } from './types.js';
13
14
  import { WorkingSetStore } from './working-set-store.js';
14
15
  import { ConsolidatedStore } from './consolidated-store.js';
@@ -62,7 +63,19 @@ export class ConsolidationWorker {
62
63
  * Force a consolidation run (manual trigger)
63
64
  */
64
65
  async forceRun(): Promise<number> {
65
- return await this.consolidate();
66
+ const out = await this.consolidateWithReport();
67
+ return out.consolidatedCount;
68
+ }
69
+
70
+ /**
71
+ * Force a consolidation run and return metrics report
72
+ */
73
+ async forceRunWithReport(): Promise<{
74
+ consolidatedCount: number;
75
+ promotedRuleCount: number;
76
+ report: ConsolidationCostQualityReport;
77
+ }> {
78
+ return this.consolidateWithReport();
66
79
  }
67
80
 
68
81
  /**
@@ -109,15 +122,29 @@ export class ConsolidationWorker {
109
122
  * Perform consolidation
110
123
  */
111
124
  private async consolidate(): Promise<number> {
125
+ const out = await this.consolidateWithReport();
126
+ return out.consolidatedCount;
127
+ }
128
+
129
+ private async consolidateWithReport(): Promise<{
130
+ consolidatedCount: number;
131
+ promotedRuleCount: number;
132
+ report: ConsolidationCostQualityReport;
133
+ }> {
112
134
  const workingSet = await this.workingSetStore.get();
113
135
 
114
136
  if (workingSet.recentEvents.length < 3) {
115
- return 0; // Not enough events to consolidate
137
+ return {
138
+ consolidatedCount: 0,
139
+ promotedRuleCount: 0,
140
+ report: this.buildCostQualityReport(workingSet.recentEvents, [], 0)
141
+ };
116
142
  }
117
143
 
118
144
  // Group events by topic
119
145
  const groups = this.groupByTopic(workingSet.recentEvents);
120
146
  let consolidatedCount = 0;
147
+ const createdMemoryIds: string[] = [];
121
148
 
122
149
  for (const group of groups) {
123
150
  // Require minimum 3 events per group
@@ -132,16 +159,18 @@ export class ConsolidationWorker {
132
159
  const summary = await this.summarize(group);
133
160
 
134
161
  // Create consolidated memory
135
- await this.consolidatedStore.create({
162
+ const memoryId = await this.consolidatedStore.create({
136
163
  summary,
137
164
  topics: group.topics,
138
165
  sourceEvents: eventIds,
139
166
  confidence: this.calculateConfidence(group)
140
167
  });
141
-
168
+ createdMemoryIds.push(memoryId);
142
169
  consolidatedCount++;
143
170
  }
144
171
 
172
+ const promotedRuleCount = await this.promoteStableSummariesToRules(createdMemoryIds);
173
+
145
174
  // Prune consolidated events from working set
146
175
  if (consolidatedCount > 0) {
147
176
  const consolidatedEventIds = groups
@@ -161,7 +190,87 @@ export class ConsolidationWorker {
161
190
  }
162
191
  }
163
192
 
164
- return consolidatedCount;
193
+ const report = this.buildCostQualityReport(workingSet.recentEvents, groups, consolidatedCount);
194
+ return { consolidatedCount, promotedRuleCount, report };
195
+ }
196
+
197
+ private async promoteStableSummariesToRules(memoryIds: string[]): Promise<number> {
198
+ let promoted = 0;
199
+
200
+ for (const memoryId of memoryIds) {
201
+ const memory = await this.consolidatedStore.get(memoryId);
202
+ if (!memory) continue;
203
+ if (memory.confidence < 0.55) continue;
204
+ if (memory.sourceEvents.length < 4) continue;
205
+
206
+ const exists = await this.consolidatedStore.hasRuleForSourceMemory(memoryId);
207
+ if (exists) continue;
208
+
209
+ const rule = this.buildRuleFromSummary(memory.summary, memory.topics);
210
+ if (!rule) continue;
211
+
212
+ await this.consolidatedStore.createRule({
213
+ rule,
214
+ topics: memory.topics,
215
+ sourceMemoryIds: [memory.memoryId],
216
+ sourceEvents: memory.sourceEvents,
217
+ confidence: Math.min(1, memory.confidence + 0.08)
218
+ });
219
+ promoted++;
220
+ }
221
+
222
+ return promoted;
223
+ }
224
+
225
+ private buildRuleFromSummary(summary: string, topics: string[]): string | null {
226
+ const lines = summary
227
+ .split(/\r?\n/)
228
+ .map((l) => l.trim())
229
+ .filter(Boolean)
230
+ .filter((l) => !l.toLowerCase().startsWith('topics:'));
231
+
232
+ const bullet = lines.find((l) => l.startsWith('- '))?.replace(/^-\s*/, '');
233
+ const seed = bullet || lines[0];
234
+ if (!seed || seed.length < 8) return null;
235
+
236
+ const topicPrefix = topics.length > 0 ? `[${topics.slice(0, 2).join(', ')}] ` : '';
237
+ return `${topicPrefix}${seed}`;
238
+ }
239
+
240
+ private buildCostQualityReport(
241
+ events: MemoryEvent[],
242
+ groups: EventGroup[],
243
+ consolidatedCount: number
244
+ ): ConsolidationCostQualityReport {
245
+ const beforeTokenEstimate = events.reduce((acc, e) => acc + this.estimateTokens(e.content), 0);
246
+
247
+ const afterSummaries = groups
248
+ .filter((g) => g.events.length >= 3)
249
+ .slice(0, Math.max(consolidatedCount, 1));
250
+
251
+ const afterTokenEstimate = afterSummaries.length > 0
252
+ ? afterSummaries.reduce((acc, g) => acc + this.estimateTokens(this.ruleBasedSummary(g)), 0)
253
+ : beforeTokenEstimate;
254
+
255
+ const reductionRatio = beforeTokenEstimate > 0
256
+ ? Math.max(0, (beforeTokenEstimate - afterTokenEstimate) / beforeTokenEstimate)
257
+ : 0;
258
+
259
+ const qualityGuardPassed = consolidatedCount === 0
260
+ ? true
261
+ : groups.filter((g) => g.events.length >= 3).every((g) => this.calculateConfidence(g) >= 0.55);
262
+
263
+ return {
264
+ beforeTokenEstimate,
265
+ afterTokenEstimate,
266
+ reductionRatio,
267
+ qualityGuardPassed,
268
+ details: `groups=${groups.length}, consolidated=${consolidatedCount}`
269
+ };
270
+ }
271
+
272
+ private estimateTokens(text: string): number {
273
+ return Math.ceil((text || '').length / 4);
165
274
  }
166
275
 
167
276
  /**
@@ -280,6 +280,19 @@ export class EventStore {
280
280
  )
281
281
  `);
282
282
 
283
+ // Consolidated Rules table (long-term stable memory)
284
+ await dbRun(this.db, `
285
+ CREATE TABLE IF NOT EXISTS consolidated_rules (
286
+ rule_id VARCHAR PRIMARY KEY,
287
+ rule TEXT NOT NULL,
288
+ topics JSON,
289
+ source_memory_ids JSON,
290
+ source_events JSON,
291
+ confidence FLOAT DEFAULT 0.5,
292
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
293
+ )
294
+ `);
295
+
283
296
  // Endless Mode Config table
284
297
  await dbRun(this.db, `
285
298
  CREATE TABLE IF NOT EXISTS endless_config (
@@ -314,6 +327,7 @@ export class EventStore {
314
327
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_working_set_expires ON working_set(expires_at)`);
315
328
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_working_set_relevance ON working_set(relevance_score DESC)`);
316
329
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_consolidated_confidence ON consolidated_memories(confidence DESC)`);
330
+ await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_consolidated_rules_confidence ON consolidated_rules(confidence DESC)`);
317
331
  await dbRun(this.db, `CREATE INDEX IF NOT EXISTS idx_continuity_created ON continuity_log(created_at)`);
318
332
 
319
333
  this.initialized = true;
package/src/core/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from './event-store.js';
14
14
  export * from './sqlite-wrapper.js';
15
15
  export * from './sqlite-event-store.js';
16
16
  export * from './sync-worker.js';
17
+ export * from './mongo-sync-worker.js';
17
18
  export * from './entity-repo.js';
18
19
  export * from './edge-repo.js';
19
20
 
@@ -0,0 +1,80 @@
1
+ import type { MemoryEventInput } from './types.js';
2
+
3
+ export type IngestStage = 'before' | 'after' | 'error';
4
+
5
+ export interface IngestContext {
6
+ stage: IngestStage;
7
+ operation: 'user_prompt' | 'agent_response' | 'session_summary' | 'tool_observation';
8
+ sessionId: string;
9
+ event: MemoryEventInput;
10
+ error?: Error;
11
+ }
12
+
13
+ export type IngestInterceptor = (context: IngestContext) => Promise<void> | void;
14
+
15
+ export class IngestInterceptorRegistry {
16
+ private before: IngestInterceptor[] = [];
17
+ private after: IngestInterceptor[] = [];
18
+ private onError: IngestInterceptor[] = [];
19
+
20
+ registerBefore(interceptor: IngestInterceptor): () => void {
21
+ this.before.push(interceptor);
22
+ return () => {
23
+ this.before = this.before.filter((i) => i !== interceptor);
24
+ };
25
+ }
26
+
27
+ registerAfter(interceptor: IngestInterceptor): () => void {
28
+ this.after.push(interceptor);
29
+ return () => {
30
+ this.after = this.after.filter((i) => i !== interceptor);
31
+ };
32
+ }
33
+
34
+ registerOnError(interceptor: IngestInterceptor): () => void {
35
+ this.onError.push(interceptor);
36
+ return () => {
37
+ this.onError = this.onError.filter((i) => i !== interceptor);
38
+ };
39
+ }
40
+
41
+ async run(stage: IngestStage, context: Omit<IngestContext, 'stage'>): Promise<void> {
42
+ const interceptors = stage === 'before'
43
+ ? this.before
44
+ : stage === 'after'
45
+ ? this.after
46
+ : this.onError;
47
+
48
+ for (const interceptor of interceptors) {
49
+ await interceptor({ ...context, stage });
50
+ }
51
+ }
52
+ }
53
+
54
+ export function mergeHierarchicalMetadata(
55
+ base: Record<string, unknown> | undefined,
56
+ patch: Record<string, unknown> | undefined
57
+ ): Record<string, unknown> | undefined {
58
+ if (!base && !patch) return undefined;
59
+ if (!base) return patch;
60
+ if (!patch) return base;
61
+
62
+ const result: Record<string, unknown> = { ...base };
63
+
64
+ for (const [key, value] of Object.entries(patch)) {
65
+ const current = result[key];
66
+ if (
67
+ typeof current === 'object' && current !== null && !Array.isArray(current) &&
68
+ typeof value === 'object' && value !== null && !Array.isArray(value)
69
+ ) {
70
+ result[key] = mergeHierarchicalMetadata(
71
+ current as Record<string, unknown>,
72
+ value as Record<string, unknown>
73
+ );
74
+ } else {
75
+ result[key] = value;
76
+ }
77
+ }
78
+
79
+ return result;
80
+ }
@@ -0,0 +1,70 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import type { MemoryEvent } from './types.js';
4
+
5
+ const DEFAULT_NAMESPACE = 'default';
6
+ const DEFAULT_CATEGORY = 'uncategorized';
7
+
8
+ export function sanitizeSegment(input: unknown, fallback: string): string {
9
+ const raw = String(input ?? '').trim().toLowerCase();
10
+ const safe = raw
11
+ .normalize('NFKD')
12
+ .replace(/[^a-z0-9_-]+/g, '-')
13
+ .replace(/^-+|-+$/g, '');
14
+
15
+ if (!safe || safe === '.' || safe === '..') return fallback;
16
+ return safe;
17
+ }
18
+
19
+ function getCategorySegments(metadata: Record<string, unknown> | undefined, eventType: string): string[] {
20
+ const raw = metadata?.categoryPath;
21
+ if (Array.isArray(raw) && raw.length > 0) {
22
+ return raw.map((s) => sanitizeSegment(s, DEFAULT_CATEGORY));
23
+ }
24
+ const single = metadata?.category;
25
+ if (typeof single === 'string' && single.trim()) {
26
+ return [sanitizeSegment(single, DEFAULT_CATEGORY)];
27
+ }
28
+ return [sanitizeSegment(eventType, DEFAULT_CATEGORY)];
29
+ }
30
+
31
+ export function buildMirrorPath(rootDir: string, event: MemoryEvent): string {
32
+ const metadata = event.metadata as Record<string, unknown> | undefined;
33
+ const namespace = sanitizeSegment(metadata?.namespace, DEFAULT_NAMESPACE);
34
+ const categories = getCategorySegments(metadata, event.eventType);
35
+
36
+ const d = event.timestamp;
37
+ const yyyy = d.getFullYear();
38
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
39
+ const dd = String(d.getDate()).padStart(2, '0');
40
+
41
+ return path.join(rootDir, 'memory', namespace, ...categories, `${yyyy}-${mm}-${dd}.md`);
42
+ }
43
+
44
+ export function formatMirrorEntry(event: MemoryEvent): string {
45
+ const category = Array.isArray((event.metadata as any)?.categoryPath)
46
+ ? ((event.metadata as any).categoryPath as unknown[]).join('/')
47
+ : String((event.metadata as any)?.category ?? event.eventType);
48
+
49
+ return [
50
+ '',
51
+ `- ts: ${event.timestamp.toISOString()}`,
52
+ ` id: ${event.id}`,
53
+ ` type: ${event.eventType}`,
54
+ ` session: ${event.sessionId}`,
55
+ ` category: ${category}`,
56
+ ' content: |',
57
+ ...event.content.split('\n').map((line) => ` ${line}`)
58
+ ].join('\n') + '\n';
59
+ }
60
+
61
+ export class MarkdownMirror {
62
+ constructor(private readonly rootDir: string) {}
63
+
64
+ async append(event: MemoryEvent): Promise<string> {
65
+ const outPath = buildMirrorPath(this.rootDir, event);
66
+ await fs.mkdir(path.dirname(outPath), { recursive: true });
67
+ await fs.appendFile(outPath, formatMirrorEntry(event), 'utf8');
68
+ return outPath;
69
+ }
70
+ }
@@ -0,0 +1,92 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { MemoryEventInput } from './types.js';
4
+
5
+ function sanitizeSegment(input: string | undefined, fallback: string): string {
6
+ const v = (input || '').trim().toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
7
+ return v || fallback;
8
+ }
9
+
10
+ function getAtPath(obj: Record<string, unknown> | undefined, dotted: string): unknown {
11
+ if (!obj) return undefined;
12
+ return dotted.split('.').reduce<unknown>((acc, key) => {
13
+ if (!acc || typeof acc !== 'object') return undefined;
14
+ return (acc as Record<string, unknown>)[key];
15
+ }, obj);
16
+ }
17
+
18
+ export function buildMirrorPath(rootDir: string, event: MemoryEventInput): string {
19
+ const meta = event.metadata as Record<string, unknown> | undefined;
20
+
21
+ const namespaceRaw = getAtPath(meta, 'namespace') ?? getAtPath(meta, 'scope.namespace') ?? event.eventType;
22
+ const namespace = sanitizeSegment(typeof namespaceRaw === 'string' ? namespaceRaw : undefined, 'general');
23
+
24
+ const categoryRaw = getAtPath(meta, 'categoryPath') ?? getAtPath(meta, 'scope.categoryPath');
25
+ const categoryPath = Array.isArray(categoryRaw) && categoryRaw.length > 0
26
+ ? categoryRaw.map((x) => sanitizeSegment(typeof x === 'string' ? x : undefined, 'uncategorized'))
27
+ : ['uncategorized'];
28
+
29
+ const d = event.timestamp;
30
+ const yyyy = d.getFullYear();
31
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
32
+ const dd = String(d.getDate()).padStart(2, '0');
33
+
34
+ return path.join(rootDir, 'memory', namespace, ...categoryPath, `${yyyy}-${mm}-${dd}.md`);
35
+ }
36
+
37
+ export class MarkdownMirror {
38
+ constructor(private readonly rootDir: string) {}
39
+
40
+ async append(event: MemoryEventInput, eventId?: string): Promise<void> {
41
+ const out = buildMirrorPath(this.rootDir, event);
42
+ fs.mkdirSync(path.dirname(out), { recursive: true });
43
+
44
+ const lines = [
45
+ '',
46
+ `## ${event.timestamp.toISOString()} | ${eventId ?? 'pending-id'}`,
47
+ `- type: ${event.eventType}`,
48
+ `- session: ${event.sessionId}`,
49
+ event.content,
50
+ ];
51
+
52
+ await fs.promises.appendFile(out, lines.join('\n'), 'utf8');
53
+ await this.refreshIndex();
54
+ }
55
+
56
+ private async refreshIndex(): Promise<void> {
57
+ const memoryRoot = path.join(this.rootDir, 'memory');
58
+ await fs.promises.mkdir(memoryRoot, { recursive: true });
59
+
60
+ const files: string[] = [];
61
+ await this.walk(memoryRoot, files);
62
+
63
+ const mdFiles = files
64
+ .filter((f) => f.endsWith('.md'))
65
+ .map((f) => path.relative(this.rootDir, f))
66
+ .filter((rel) => rel !== path.join('memory', '_index.md'))
67
+ .sort();
68
+
69
+ const index = [
70
+ '# Memory Index',
71
+ '',
72
+ 'Generated automatically by MarkdownMirror.',
73
+ '',
74
+ ...mdFiles.map((rel) => `- ${rel}`),
75
+ '',
76
+ ].join('\n');
77
+
78
+ await fs.promises.writeFile(path.join(memoryRoot, '_index.md'), index, 'utf8');
79
+ }
80
+
81
+ private async walk(dir: string, out: string[]): Promise<void> {
82
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
83
+ for (const e of entries) {
84
+ const full = path.join(dir, e.name);
85
+ if (e.isDirectory()) {
86
+ await this.walk(full, out);
87
+ } else {
88
+ out.push(full);
89
+ }
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Mongo Sync Config
3
+ * Persistent per-project config for enabling auto-sync from hooks (e.g., SessionEnd).
4
+ *
5
+ * Stored as JSON under the project's storagePath next to events.sqlite.
6
+ * Note: This may include credentials in plaintext (MongoDB URI). Treat accordingly.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import * as path from 'path';
11
+
12
+ import type { MongoSyncDirection } from './mongo-sync-worker.js';
13
+
14
+ export type MongoSyncConfig = {
15
+ version: 1;
16
+ enabled: boolean;
17
+ /** MongoDB connection URI (may include credentials). */
18
+ uri: string;
19
+ /** MongoDB database name. */
20
+ dbName: string;
21
+ /** Remote project key (shared across machines). */
22
+ projectKey: string;
23
+ /** push|pull|both */
24
+ direction: MongoSyncDirection;
25
+ /** Batch size for push/pull loops. */
26
+ batchSize: number;
27
+ /** If true, hooks will run a sync at SessionEnd. */
28
+ autoSyncOnSessionEnd: boolean;
29
+ createdAt: string;
30
+ updatedAt: string;
31
+ };
32
+
33
+ function isRecord(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === 'object' && value !== null;
35
+ }
36
+
37
+ function asNonEmptyString(value: unknown): string | null {
38
+ if (typeof value !== 'string') return null;
39
+ const s = value.trim();
40
+ return s.length > 0 ? s : null;
41
+ }
42
+
43
+ function asPositiveInt(value: unknown): number | null {
44
+ if (typeof value === 'number' && Number.isFinite(value)) {
45
+ const n = Math.trunc(value);
46
+ return n > 0 ? n : null;
47
+ }
48
+ if (typeof value === 'string') {
49
+ const n = parseInt(value, 10);
50
+ return Number.isFinite(n) && n > 0 ? n : null;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ function normalizeDirection(value: unknown): MongoSyncDirection | null {
56
+ if (typeof value !== 'string') return null;
57
+ const v = value.toLowerCase();
58
+ if (v === 'push' || v === 'pull' || v === 'both') return v;
59
+ return null;
60
+ }
61
+
62
+ export function getMongoSyncConfigPath(storagePath: string): string {
63
+ return path.join(storagePath, 'mongo-sync.json');
64
+ }
65
+
66
+ export function readMongoSyncConfig(storagePath: string): MongoSyncConfig | null {
67
+ const configPath = getMongoSyncConfigPath(storagePath);
68
+ if (!fs.existsSync(configPath)) return null;
69
+
70
+ try {
71
+ const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as unknown;
72
+ if (!isRecord(raw)) return null;
73
+
74
+ const version = raw.version;
75
+ if (version !== 1) return null;
76
+
77
+ const enabled = raw.enabled;
78
+ if (typeof enabled !== 'boolean') return null;
79
+
80
+ const uri = asNonEmptyString(raw.uri);
81
+ const dbName = asNonEmptyString(raw.dbName);
82
+ const projectKey = asNonEmptyString(raw.projectKey);
83
+ const direction = normalizeDirection(raw.direction);
84
+ const batchSize = asPositiveInt(raw.batchSize) ?? 500;
85
+ const autoSyncOnSessionEnd = typeof raw.autoSyncOnSessionEnd === 'boolean' ? raw.autoSyncOnSessionEnd : true;
86
+ const createdAt = asNonEmptyString(raw.createdAt) ?? new Date(0).toISOString();
87
+ const updatedAt = asNonEmptyString(raw.updatedAt) ?? new Date(0).toISOString();
88
+
89
+ if (!uri || !dbName || !projectKey || !direction) return null;
90
+
91
+ return {
92
+ version: 1,
93
+ enabled,
94
+ uri,
95
+ dbName,
96
+ projectKey,
97
+ direction,
98
+ batchSize,
99
+ autoSyncOnSessionEnd,
100
+ createdAt,
101
+ updatedAt
102
+ };
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function writeMongoSyncConfig(storagePath: string, config: Omit<MongoSyncConfig, 'version' | 'createdAt' | 'updatedAt'> & {
109
+ createdAt?: string;
110
+ updatedAt?: string;
111
+ }): MongoSyncConfig {
112
+ if (!fs.existsSync(storagePath)) {
113
+ fs.mkdirSync(storagePath, { recursive: true });
114
+ }
115
+
116
+ const now = new Date().toISOString();
117
+ const existing = readMongoSyncConfig(storagePath);
118
+
119
+ const normalized: MongoSyncConfig = {
120
+ version: 1,
121
+ enabled: config.enabled,
122
+ uri: config.uri,
123
+ dbName: config.dbName,
124
+ projectKey: config.projectKey,
125
+ direction: config.direction,
126
+ batchSize: config.batchSize,
127
+ autoSyncOnSessionEnd: config.autoSyncOnSessionEnd,
128
+ createdAt: config.createdAt ?? existing?.createdAt ?? now,
129
+ updatedAt: config.updatedAt ?? now
130
+ };
131
+
132
+ const configPath = getMongoSyncConfigPath(storagePath);
133
+ const tmpPath = `${configPath}.tmp`;
134
+
135
+ // 0600: contains secrets (Mongo URI may embed credentials)
136
+ fs.writeFileSync(tmpPath, JSON.stringify(normalized, null, 2), { mode: 0o600 });
137
+ fs.renameSync(tmpPath, configPath);
138
+
139
+ return normalized;
140
+ }
141
+
142
+ export function removeMongoSyncConfig(storagePath: string): boolean {
143
+ const configPath = getMongoSyncConfigPath(storagePath);
144
+ if (!fs.existsSync(configPath)) return false;
145
+ fs.unlinkSync(configPath);
146
+ return true;
147
+ }
148
+
149
+ export function redactMongoUri(uri: string): string {
150
+ // mongodb://user:pass@host:port/ -> mongodb://user:***@host:port/
151
+ // mongodb+srv://user:pass@host/ -> mongodb+srv://user:***@host/
152
+ const schemeIdx = uri.indexOf('://');
153
+ if (schemeIdx === -1) return uri;
154
+ const atIdx = uri.indexOf('@', schemeIdx + 3);
155
+ if (atIdx === -1) return uri;
156
+
157
+ const creds = uri.slice(schemeIdx + 3, atIdx); // user:pass
158
+ const colonIdx = creds.indexOf(':');
159
+ if (colonIdx === -1) return uri;
160
+
161
+ const prefix = uri.slice(0, schemeIdx + 3 + colonIdx + 1);
162
+ const suffix = uri.slice(atIdx);
163
+ return `${prefix}***${suffix}`;
164
+ }
165
+