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.
- package/AGENTS.md +60 -0
- package/README.md +166 -2
- package/bootstrap-kb/decisions/decisions.md +244 -0
- package/bootstrap-kb/glossary/glossary.md +46 -0
- package/bootstrap-kb/modules/.claude-plugin.md +22 -0
- package/bootstrap-kb/modules/agents.md.md +15 -0
- package/bootstrap-kb/modules/claude.md.md +15 -0
- package/bootstrap-kb/modules/context.md.md +15 -0
- package/bootstrap-kb/modules/docs.md +18 -0
- package/bootstrap-kb/modules/handoff.md.md +15 -0
- package/bootstrap-kb/modules/package-lock.json.md +15 -0
- package/bootstrap-kb/modules/package.json.md +15 -0
- package/bootstrap-kb/modules/plan.md.md +15 -0
- package/bootstrap-kb/modules/readme.md.md +15 -0
- package/bootstrap-kb/modules/scripts.md +26 -0
- package/bootstrap-kb/modules/spec.md.md +15 -0
- package/bootstrap-kb/modules/specs.md +20 -0
- package/bootstrap-kb/modules/src.md +51 -0
- package/bootstrap-kb/modules/tests.md +42 -0
- package/bootstrap-kb/modules/tsconfig.json.md +15 -0
- package/bootstrap-kb/modules/vitest.config.ts.md +15 -0
- package/bootstrap-kb/overview/overview.md +40 -0
- package/bootstrap-kb/sources/manifest.json +950 -0
- package/bootstrap-kb/sources/manifest.md +227 -0
- package/bootstrap-kb/timeline/timeline.md +57 -0
- package/d.sh +3 -0
- package/deploy.sh +3 -0
- package/dist/cli/index.js +3577 -389
- package/dist/cli/index.js.map +4 -4
- package/dist/core/index.js +1383 -138
- package/dist/core/index.js.map +4 -4
- package/dist/hooks/post-tool-use.js +1917 -214
- package/dist/hooks/post-tool-use.js.map +4 -4
- package/dist/hooks/session-end.js +1813 -231
- package/dist/hooks/session-end.js.map +4 -4
- package/dist/hooks/session-start.js +1802 -205
- package/dist/hooks/session-start.js.map +4 -4
- package/dist/hooks/stop.js +1909 -248
- package/dist/hooks/stop.js.map +4 -4
- package/dist/hooks/user-prompt-submit.js +1861 -206
- package/dist/hooks/user-prompt-submit.js.map +4 -4
- package/dist/server/api/index.js +2341 -217
- package/dist/server/api/index.js.map +4 -4
- package/dist/server/index.js +2350 -226
- package/dist/server/index.js.map +4 -4
- package/dist/services/memory-service.js +1805 -206
- package/dist/services/memory-service.js.map +4 -4
- package/dist/ui/app.js +1447 -55
- package/dist/ui/index.html +318 -147
- package/dist/ui/style.css +892 -0
- package/docs/MCP_MEMORY_SERVICE_COMPARATIVE_REVIEW.md +271 -0
- package/docs/MEMU_ADOPTION.md +40 -0
- package/docs/OPERATIONS.md +18 -0
- package/memory/.claude-plugin/commands/2026-02-25.md +263 -0
- package/memory/_index.md +405 -0
- package/memory/default/uncategorized/2026-02-25.md +4839 -0
- package/memory/specs/20260207-dashboard-upgrade/2026-02-25.md +142 -0
- package/memory/specs/citations-system/2026-02-25.md +1121 -0
- package/memory/specs/endless-mode/2026-02-25.md +1392 -0
- package/memory/specs/entity-edge-model/2026-02-25.md +1263 -0
- package/memory/specs/evidence-aligner-v2/2026-02-25.md +1028 -0
- package/memory/specs/mcp-desktop-integration/2026-02-25.md +1334 -0
- package/memory/specs/post-tool-use-hook/2026-02-25.md +1164 -0
- package/memory/specs/private-tags/2026-02-25.md +1057 -0
- package/memory/specs/progressive-disclosure/2026-02-25.md +1436 -0
- package/memory/specs/task-entity-system/2026-02-25.md +924 -0
- package/memory/specs/vector-outbox-v2/2026-02-25.md +1510 -0
- package/memory/specs/web-viewer-ui/2026-02-25.md +1709 -0
- package/package.json +9 -2
- package/scripts/build.ts +6 -0
- package/scripts/fix-sync-gap.js +32 -0
- package/scripts/heartbeat-memory-orchestrator.sh +28 -0
- package/scripts/report-sync-gap.js +26 -0
- package/scripts/review-queue-auto-resolve.js +21 -0
- package/scripts/sync-gap-auto-heal.sh +17 -0
- package/specs/20260207-dashboard-upgrade/context.md +38 -0
- package/specs/20260207-dashboard-upgrade/spec.md +96 -0
- package/src/cli/index.ts +391 -60
- package/src/core/consolidated-store.ts +63 -1
- package/src/core/consolidation-worker.ts +115 -6
- package/src/core/event-store.ts +14 -0
- package/src/core/index.ts +1 -0
- package/src/core/ingest-interceptor.ts +80 -0
- package/src/core/markdown-mirror.ts +70 -0
- package/src/core/md-mirror.ts +92 -0
- package/src/core/mongo-sync-config.ts +165 -0
- package/src/core/mongo-sync-worker.ts +381 -0
- package/src/core/retriever.ts +540 -150
- package/src/core/sqlite-event-store.ts +794 -7
- package/src/core/sqlite-wrapper.ts +8 -0
- package/src/core/tag-taxonomy.ts +51 -0
- package/src/core/turn-state.ts +159 -0
- package/src/core/types.ts +51 -8
- package/src/core/vector-store.ts +21 -3
- package/src/hooks/post-tool-use.ts +68 -23
- package/src/hooks/session-end.ts +8 -3
- package/src/hooks/stop.ts +96 -25
- package/src/hooks/user-prompt-submit.ts +44 -5
- package/src/server/api/chat.ts +244 -0
- package/src/server/api/citations.ts +3 -3
- package/src/server/api/events.ts +30 -5
- package/src/server/api/health.ts +53 -0
- package/src/server/api/index.ts +9 -1
- package/src/server/api/projects.ts +74 -0
- package/src/server/api/search.ts +3 -3
- package/src/server/api/sessions.ts +3 -3
- package/src/server/api/stats.ts +89 -8
- package/src/server/api/turns.ts +143 -0
- package/src/server/api/utils.ts +46 -0
- package/src/services/bootstrap-organizer.ts +443 -0
- package/src/services/codex-session-history-importer.ts +474 -0
- package/src/services/memory-service.ts +508 -71
- package/src/services/session-history-importer.ts +215 -51
- package/src/ui/app.js +1447 -55
- package/src/ui/index.html +318 -147
- package/src/ui/style.css +892 -0
- package/tests/bootstrap-organizer.test.ts +111 -0
- package/tests/consolidation-worker.test.ts +75 -0
- package/tests/ingest-interceptor.test.ts +38 -0
- package/tests/markdown-mirror.test.ts +85 -0
- package/tests/md-mirror.test.ts +50 -0
- package/tests/retriever-fallback-chain.test.ts +223 -0
- package/tests/retriever-strategy-scope.test.ts +97 -0
- package/tests/retriever.memu-adoption.test.ts +122 -0
- package/tests/sqlite-event-store-replication.test.ts +92 -0
- package/.claude/settings.local.json +0 -27
- package/.claude-memory/test.sqlite +0 -0
- package/.history/package_20260201112328.json +0 -45
- package/.history/package_20260201113602.json +0 -45
- package/.history/package_20260201113713.json +0 -45
- package/.history/package_20260201114110.json +0 -45
- package/.history/package_20260201114632.json +0 -46
- package/.history/package_20260201133143.json +0 -45
- package/.history/package_20260201134319.json +0 -45
- package/.history/package_20260201134326.json +0 -45
- package/.history/package_20260201134334.json +0 -45
- package/.history/package_20260201134912.json +0 -45
- package/.history/package_20260201142928.json +0 -46
- package/.history/package_20260201192048.json +0 -47
- package/.history/package_20260202114053.json +0 -49
- package/.history/package_20260202121115.json +0 -49
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
/**
|
package/src/core/event-store.ts
CHANGED
|
@@ -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
|
+
|