@mem-weave/server 0.2.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.
Files changed (66) hide show
  1. package/README.md +74 -0
  2. package/dist/cli-entry.js +49 -0
  3. package/dist/cli.js +53 -0
  4. package/dist/commands/backup.js +28 -0
  5. package/dist/commands/doctor.js +108 -0
  6. package/dist/commands/help.js +29 -0
  7. package/dist/commands/index.js +27 -0
  8. package/dist/commands/init.js +58 -0
  9. package/dist/commands/migrate.js +25 -0
  10. package/dist/commands/start.js +29 -0
  11. package/dist/commands/status.js +19 -0
  12. package/dist/commands/stop.js +46 -0
  13. package/dist/commands/version.js +21 -0
  14. package/dist/core/config.js +161 -0
  15. package/dist/core/decay.js +50 -0
  16. package/dist/core/types.js +72 -0
  17. package/dist/db/database.js +58 -0
  18. package/dist/db/repositories/access-log-repo.js +59 -0
  19. package/dist/db/repositories/consolidation-run-repo.js +86 -0
  20. package/dist/db/repositories/device-repo.js +66 -0
  21. package/dist/db/repositories/edge-repo.js +104 -0
  22. package/dist/db/repositories/memory-repo.js +294 -0
  23. package/dist/db/repositories/observation-repo.js +65 -0
  24. package/dist/db/repositories/session-repo.js +81 -0
  25. package/dist/db/repositories/stats-repo.js +92 -0
  26. package/dist/db/repositories/vector-repo.js +55 -0
  27. package/dist/db/schema.js +185 -0
  28. package/dist/injection/bundler.js +39 -0
  29. package/dist/injection/formatter.js +23 -0
  30. package/dist/prompts/compression.js +43 -0
  31. package/dist/prompts/edge-extract.js +21 -0
  32. package/dist/prompts/value-gate.js +27 -0
  33. package/dist/providers/embedding/index.js +36 -0
  34. package/dist/providers/embedding/local-xenova.js +166 -0
  35. package/dist/providers/embedding/noop.js +40 -0
  36. package/dist/providers/embedding/openai-compatible.js +46 -0
  37. package/dist/providers/llm/index.js +12 -0
  38. package/dist/providers/llm/noop.js +5 -0
  39. package/dist/providers/llm/openai.js +45 -0
  40. package/dist/rest/routes/consolidation.js +62 -0
  41. package/dist/rest/routes/devices.js +47 -0
  42. package/dist/rest/routes/injection.js +76 -0
  43. package/dist/rest/routes/memories.js +349 -0
  44. package/dist/rest/routes/observations.js +29 -0
  45. package/dist/rest/routes/sessions.js +37 -0
  46. package/dist/rest/routes/settings.js +25 -0
  47. package/dist/rest/routes/stats.js +15 -0
  48. package/dist/retrieval/bm25-search.js +91 -0
  49. package/dist/retrieval/causal-chain.js +197 -0
  50. package/dist/retrieval/fusion.js +48 -0
  51. package/dist/retrieval/graph-traversal.js +144 -0
  52. package/dist/retrieval/search-engine.js +150 -0
  53. package/dist/retrieval/vector-search.js +91 -0
  54. package/dist/server/auth.js +80 -0
  55. package/dist/server/bootstrap.js +28 -0
  56. package/dist/server/http.js +77 -0
  57. package/dist/server/logger.js +36 -0
  58. package/dist/server/rate-limiter.js +81 -0
  59. package/dist/server/scheduler.js +99 -0
  60. package/dist/workers/association.js +41 -0
  61. package/dist/workers/compressor.js +14 -0
  62. package/dist/workers/consolidator.js +201 -0
  63. package/dist/workers/embedder.js +102 -0
  64. package/dist/workers/graph-worker.js +166 -0
  65. package/dist/workers/value-gate.js +38 -0
  66. package/package.json +40 -0
@@ -0,0 +1,161 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { resolve } from 'node:path';
4
+ import { parse, printParseErrorCode } from 'jsonc-parser';
5
+ import { z } from 'zod';
6
+ /**
7
+ * MemWeave config schema, organized into sections matching the design spec (§9.9).
8
+ *
9
+ * Each section is optional and has sensible defaults so a missing config file
10
+ * (or partial override) still works for local development.
11
+ */
12
+ // --- Section: server ---
13
+ const ServerConfigSchema = z.object({
14
+ host: z.string().default('127.0.0.1'),
15
+ port: z.number().int().min(1).max(65535).default(3131)
16
+ });
17
+ // --- Section: storage ---
18
+ const StorageConfigSchema = z.object({
19
+ path: z.string().default('~/.memweave/data/memweave.db')
20
+ });
21
+ // --- Section: auth ---
22
+ const AuthConfigSchema = z.object({
23
+ defaultTenantName: z.string().default('default'),
24
+ deviceApiKey: z.string().default('dev-local-key'),
25
+ /** When true, the Bearer-token middleware is enforced on /api/v1/* (except /health). */
26
+ requireAuth: z.boolean().default(false)
27
+ });
28
+ // --- Section: embedding ---
29
+ const EmbeddingConfigSchema = z.object({
30
+ provider: z.enum(['local-xenova', 'openai-compatible', 'noop']).default('noop'),
31
+ model: z.string().default('Xenova/nomic-embed-text-v1'),
32
+ dimensions: z.number().int().positive().default(768),
33
+ baseUrl: z.string().optional(),
34
+ apiKey: z.string().optional(),
35
+ /** Max texts to embed per background-worker batch. */
36
+ batchSize: z.number().int().positive().default(16)
37
+ });
38
+ // --- Section: llm ---
39
+ const LlmConfigSchema = z.object({
40
+ provider: z.enum(['openai-compatible', 'noop']).default('noop'),
41
+ baseUrl: z.string().optional(),
42
+ apiKey: z.string().optional(),
43
+ model: z.string().default('gpt-4o-mini'),
44
+ temperature: z.number().min(0).max(2).default(0.2),
45
+ maxTokens: z.number().int().positive().default(2048)
46
+ });
47
+ // --- Section: consolidation ---
48
+ const ConsolidationConfigSchema = z.object({
49
+ enabled: z.boolean().default(true),
50
+ intervalHours: z.number().positive().default(6),
51
+ accessLogRetentionDays: z.number().int().positive().default(90)
52
+ });
53
+ // --- Section: injection ---
54
+ const InjectionConfigSchema = z.object({
55
+ sessionStartBudget: z.number().int().positive().default(1200),
56
+ promptDeltaBudget: z.number().int().positive().default(800),
57
+ filePackBudget: z.number().int().positive().default(1000),
58
+ failureDeltaBudget: z.number().int().positive().default(1500)
59
+ });
60
+ // --- Section: search (RRF + layers) ---
61
+ const SearchConfigSchema = z.object({
62
+ rrfK: z.number().positive().default(60),
63
+ /** Per-layer recall limits before fusion. */
64
+ bm25Limit: z.number().int().positive().default(50),
65
+ vectorLimit: z.number().int().positive().default(50),
66
+ graphLimit: z.number().int().positive().default(30),
67
+ causalLimit: z.number().int().positive().default(30),
68
+ /** Minimum cosine similarity for vector results. */
69
+ vectorMinSimilarity: z.number().min(-1).max(1).default(0.55),
70
+ /** When true, vector/graph/causal layers are skipped (BM25-only). */
71
+ bm25Only: z.boolean().default(false)
72
+ });
73
+ // Note: we keep the top-level schema's sub-schemas marked `.optional()` because
74
+ // Zod v4 in this project does not auto-fill sub-object defaults when the parent
75
+ // receives `undefined`. We compensate in `loadConfig` by always parsing each
76
+ // sub-section with `obj.section ?? {}`. The exported `MemWeaveConfig` type is
77
+ // the *fully populated* shape (all sections always present).
78
+ const ConfigSchema = z.object({
79
+ server: ServerConfigSchema.optional(),
80
+ storage: StorageConfigSchema.optional(),
81
+ auth: AuthConfigSchema.optional(),
82
+ embedding: EmbeddingConfigSchema.optional(),
83
+ llm: LlmConfigSchema.optional(),
84
+ consolidation: ConsolidationConfigSchema.optional(),
85
+ injection: InjectionConfigSchema.optional(),
86
+ search: SearchConfigSchema.optional()
87
+ });
88
+ export function expandPath(value) {
89
+ if (value.startsWith('~/'))
90
+ return resolve(homedir(), value.slice(2));
91
+ return resolve(value);
92
+ }
93
+ export function expandEnv(value) {
94
+ if (!value.startsWith('env://'))
95
+ return value;
96
+ const name = value.slice('env://'.length);
97
+ const resolved = process.env[name];
98
+ if (!resolved)
99
+ throw new Error(`Missing environment variable ${name}`);
100
+ return resolved;
101
+ }
102
+ /**
103
+ * Resolve any `env://NAME` placeholders in string fields. Returns a new object.
104
+ * Leaves non-string values untouched.
105
+ */
106
+ export function resolveEnvPlaceholders(cfg) {
107
+ const walk = (v) => {
108
+ if (typeof v === 'string')
109
+ return expandEnv(v);
110
+ if (Array.isArray(v))
111
+ return v.map(walk);
112
+ if (v !== null && typeof v === 'object') {
113
+ const out = {};
114
+ for (const [k, val] of Object.entries(v))
115
+ out[k] = walk(val);
116
+ return out;
117
+ }
118
+ return v;
119
+ };
120
+ return walk(cfg);
121
+ }
122
+ /** Default config (no file given). */
123
+ export function defaultConfig() {
124
+ return resolveEnvPlaceholders({
125
+ server: ServerConfigSchema.parse({}),
126
+ storage: StorageConfigSchema.parse({}),
127
+ auth: AuthConfigSchema.parse({}),
128
+ embedding: EmbeddingConfigSchema.parse({}),
129
+ llm: LlmConfigSchema.parse({}),
130
+ consolidation: ConsolidationConfigSchema.parse({}),
131
+ injection: InjectionConfigSchema.parse({}),
132
+ search: SearchConfigSchema.parse({})
133
+ });
134
+ }
135
+ export function loadConfig(path) {
136
+ let parsed = {};
137
+ if (path) {
138
+ const raw = readFileSync(path, 'utf8');
139
+ const errors = [];
140
+ parsed = parse(raw, errors);
141
+ if (errors.length > 0) {
142
+ const details = errors
143
+ .map((e) => `${printParseErrorCode(e.error)} at offset ${e.offset}`)
144
+ .join('; ');
145
+ throw new Error(`Invalid config file "${path}": ${details}`);
146
+ }
147
+ }
148
+ // Merge parsed file with defaults, section by section.
149
+ const obj = (parsed ?? {});
150
+ const cfg = {
151
+ server: ServerConfigSchema.parse(obj.server ?? {}),
152
+ storage: StorageConfigSchema.parse(obj.storage ?? {}),
153
+ auth: AuthConfigSchema.parse(obj.auth ?? {}),
154
+ embedding: EmbeddingConfigSchema.parse(obj.embedding ?? {}),
155
+ llm: LlmConfigSchema.parse(obj.llm ?? {}),
156
+ consolidation: ConsolidationConfigSchema.parse(obj.consolidation ?? {}),
157
+ injection: InjectionConfigSchema.parse(obj.injection ?? {}),
158
+ search: SearchConfigSchema.parse(obj.search ?? {})
159
+ };
160
+ return resolveEnvPlaceholders(cfg);
161
+ }
@@ -0,0 +1,50 @@
1
+ const TAU_TABLE = {
2
+ short: [
3
+ { min: 1, max: 3, tau: 1 },
4
+ { min: 4, max: 6, tau: 2 },
5
+ { min: 7, max: 9, tau: 7 },
6
+ { min: 10, max: 10, tau: 30 }
7
+ ],
8
+ medium: [
9
+ { min: 1, max: 3, tau: 5 },
10
+ { min: 4, max: 6, tau: 14 },
11
+ { min: 7, max: 9, tau: 30 },
12
+ { min: 10, max: 10, tau: 60 }
13
+ ],
14
+ long: [
15
+ { min: 1, max: 3, tau: 60 },
16
+ { min: 4, max: 6, tau: 180 },
17
+ { min: 7, max: 10, tau: Number.POSITIVE_INFINITY }
18
+ ]
19
+ };
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+ export function initialStrengthFromImportance(importance) {
22
+ const bounded = Math.max(1, Math.min(10, Math.round(importance)));
23
+ return bounded / 10;
24
+ }
25
+ export function tauFor(tier, importance) {
26
+ const bounded = Math.max(1, Math.min(10, Math.round(importance)));
27
+ const row = TAU_TABLE[tier].find((entry) => bounded >= entry.min && bounded <= entry.max);
28
+ if (!row)
29
+ throw new Error(`No tau mapping for tier=${tier} importance=${bounded}`);
30
+ return row.tau;
31
+ }
32
+ export function applyDecay(input) {
33
+ const current = Math.max(0, Math.min(1, input.strength));
34
+ if (input.lastDecayAt === null)
35
+ return { strength: current, lastDecayAt: input.now };
36
+ if (!Number.isFinite(input.tau))
37
+ return { strength: current, lastDecayAt: input.now };
38
+ const elapsedDays = Math.max(0, (input.now - input.lastDecayAt) / DAY_MS);
39
+ const decayFactor = Math.exp(-elapsedDays / input.tau);
40
+ return { strength: Math.max(0, current * decayFactor), lastDecayAt: input.now };
41
+ }
42
+ export function reinforcementBoost(input) {
43
+ if (input.userConfirmed)
44
+ return 0.3;
45
+ if (input.explicitReference)
46
+ return 0.15;
47
+ if (input.usedInContext)
48
+ return 0.1;
49
+ return 0.02;
50
+ }
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ export const MemoryTierSchema = z.enum(['short', 'medium', 'long']);
3
+ export const MemoryTypeSchema = z.enum([
4
+ 'fact',
5
+ 'decision',
6
+ 'preference',
7
+ 'event',
8
+ 'project_context',
9
+ 'lesson',
10
+ 'code_pattern',
11
+ 'bug',
12
+ 'workflow'
13
+ ]);
14
+ export const EdgeTypeSchema = z.enum([
15
+ 'causes',
16
+ 'enables',
17
+ 'contradicts',
18
+ 'supersedes',
19
+ 'references',
20
+ 'related_to',
21
+ 'before',
22
+ 'after',
23
+ 'duplicates',
24
+ 'refines'
25
+ ]);
26
+ export const ScopeKeySchema = z.enum(['project', 'domain', 'topic']);
27
+ export const ScopeLevelSchema = z.enum(['global', 'project']);
28
+ export const MemorySourceSchema = z.enum(['user_explicit', 'agent_capture', 'system_inferred']);
29
+ export const SourceClientSchema = z.enum(['opencode', 'cursor', 'claude_code', 'rest_api']);
30
+ export const ScopeTagSchema = z.object({
31
+ key: ScopeKeySchema,
32
+ value: z.string().min(1)
33
+ });
34
+ /**
35
+ * Hard limits on user-controllable memory fields. These exist to keep a
36
+ * buggy or malicious LLM from inserting 10MB of text, 10k concepts, etc.,
37
+ * which would balloon the FTS5 index and slow down every search.
38
+ */
39
+ export const MEMORY_LIMITS = {
40
+ /** Max body length in chars. ~30k tokens; well above any single memory. */
41
+ CONTENT_MAX: 100_000,
42
+ /** Max concept count per memory. Real memories have 3-10. */
43
+ CONCEPTS_MAX: 50,
44
+ /** Max file associations per memory. */
45
+ FILES_MAX: 50
46
+ };
47
+ export const CreateMemoryInputSchema = z.object({
48
+ tenantId: z.string().min(1),
49
+ type: MemoryTypeSchema,
50
+ title: z.string().min(1).max(120),
51
+ content: z.string().min(1).max(MEMORY_LIMITS.CONTENT_MAX),
52
+ summary: z.string().min(1).max(500),
53
+ concepts: z.array(z.string().min(1).max(100)).max(MEMORY_LIMITS.CONCEPTS_MAX).default([]),
54
+ files: z.array(z.string().min(1).max(500)).max(MEMORY_LIMITS.FILES_MAX).default([]),
55
+ importance: z.number().int().min(1).max(10),
56
+ confidence: z.number().min(0).max(1),
57
+ source: MemorySourceSchema,
58
+ scopeLevel: ScopeLevelSchema,
59
+ scopes: z.array(ScopeTagSchema).default([]),
60
+ sourceClient: SourceClientSchema.nullable().default(null),
61
+ sourceDeviceId: z.string().nullable().default(null),
62
+ sourceSessionId: z.string().nullable().default(null)
63
+ });
64
+ export const AccessSourceSchema = z.enum([
65
+ 'recall',
66
+ 'smart_search',
67
+ 'context_inject',
68
+ 'file_history',
69
+ 'graph_query',
70
+ 'manual_view',
71
+ 'dedup_reinforce'
72
+ ]);
@@ -0,0 +1,58 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import Database from 'better-sqlite3';
4
+ import { SCHEMA_SQL } from './schema.js';
5
+ import { logger } from '../server/logger.js';
6
+ /**
7
+ * Default dimensions for the memory_vectors vec0 table. The actual configured
8
+ * dimensions come from the EmbeddingConfig; the table is created lazily by
9
+ * `openDatabase` and recreated when the configured dimensions change.
10
+ */
11
+ export const VECTOR_DEFAULT_DIMENSIONS = 768;
12
+ function vecTableName(dimensions) {
13
+ return `memory_vectors_${dimensions}`;
14
+ }
15
+ function ensureVecTable(db, dimensions) {
16
+ const tableName = vecTableName(dimensions);
17
+ const exists = db
18
+ .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`)
19
+ .get(tableName);
20
+ if (!exists) {
21
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS ${tableName} USING vec0(
22
+ memory_id TEXT PRIMARY KEY,
23
+ tenant_id TEXT,
24
+ embedding float[${dimensions}]
25
+ )`);
26
+ }
27
+ }
28
+ export function openDatabase(path, options = {}) {
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ const db = new Database(path);
31
+ db.exec(SCHEMA_SQL);
32
+ if (!options.skipVectorExtension) {
33
+ try {
34
+ // Load sqlite-vec (a no-op if the package isn't installed, but we install it
35
+ // as a regular dependency so the binary is always present in production).
36
+ // Use a dynamic import to avoid hard-failing test environments that
37
+ // exercise only non-vector code paths.
38
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
39
+ const sqliteVec = require('sqlite-vec');
40
+ sqliteVec.load(db);
41
+ const dims = options.vectorDimensions ?? VECTOR_DEFAULT_DIMENSIONS;
42
+ ensureVecTable(db, dims);
43
+ }
44
+ catch (err) {
45
+ // sqlite-vec unavailable — vector search will be a no-op
46
+ // (search engine handles missing vector layer gracefully).
47
+ // We intentionally do not throw — rest of system must work without it.
48
+ logger.warn({ err: err.message }, 'sqlite-vec not available, vector search disabled');
49
+ }
50
+ }
51
+ return db;
52
+ }
53
+ export function getVecTableName(dimensions) {
54
+ return vecTableName(dimensions);
55
+ }
56
+ export function transaction(db, fn) {
57
+ return db.transaction(fn)();
58
+ }
@@ -0,0 +1,59 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class AccessLogRepo {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ record(input) {
8
+ const id = randomUUID();
9
+ const now = Date.now();
10
+ this.db.prepare(`
11
+ INSERT INTO access_logs (id, tenant_id, memory_id, session_id, device_id, source, query, rank, score, used_in_context, accessed_at)
12
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
13
+ `).run(id, input.tenantId, input.memoryId, input.sessionId, input.deviceId, input.source, input.query, input.rank, input.score, input.usedInContext ? 1 : 0, now);
14
+ return id;
15
+ }
16
+ listForMemory(tenantId, memoryId, limit) {
17
+ if (limit <= 0)
18
+ return [];
19
+ const rows = this.db.prepare(`
20
+ SELECT * FROM access_logs
21
+ WHERE tenant_id = ? AND memory_id = ?
22
+ ORDER BY accessed_at DESC, rowid DESC
23
+ LIMIT ?
24
+ `).all(tenantId, memoryId, limit);
25
+ return rows.map((r) => this.mapRow(r));
26
+ }
27
+ listSince(tenantId, sinceMs, limit) {
28
+ if (limit <= 0)
29
+ return [];
30
+ const rows = this.db.prepare(`
31
+ SELECT * FROM access_logs
32
+ WHERE tenant_id = ? AND accessed_at >= ?
33
+ ORDER BY accessed_at DESC, rowid DESC
34
+ LIMIT ?
35
+ `).all(tenantId, sinceMs, limit);
36
+ return rows.map((r) => this.mapRow(r));
37
+ }
38
+ purgeOlderThan(cutoffMs) {
39
+ const result = this.db.prepare(`
40
+ DELETE FROM access_logs WHERE accessed_at < ?
41
+ `).run(cutoffMs);
42
+ return Number(result.changes);
43
+ }
44
+ mapRow(row) {
45
+ return {
46
+ id: row.id,
47
+ tenantId: row.tenant_id,
48
+ memoryId: row.memory_id,
49
+ sessionId: row.session_id,
50
+ deviceId: row.device_id,
51
+ source: row.source,
52
+ query: row.query,
53
+ rank: row.rank,
54
+ score: row.score,
55
+ usedInContext: row.used_in_context === 1,
56
+ accessedAt: row.accessed_at
57
+ };
58
+ }
59
+ }
@@ -0,0 +1,86 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class ConsolidationRunRepo {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ record(input) {
8
+ const id = randomUUID();
9
+ this.db.prepare(`
10
+ INSERT INTO consolidation_runs (
11
+ id, tenant_id, started_at, ended_at,
12
+ promoted_count, evicted_count, merged_count,
13
+ edges_created_count, contradiction_found_count,
14
+ promoted_ids, evicted_ids, merged_pairs,
15
+ dry_run, summary
16
+ ) VALUES (
17
+ @id, @tenantId, @startedAt, @endedAt,
18
+ @promotedCount, @evictedCount, @mergedCount,
19
+ @edgesCreated, @contradictionFound,
20
+ @promotedIds, @evictedIds, @mergedPairs,
21
+ @dryRun, @summary
22
+ )
23
+ `).run({
24
+ id,
25
+ tenantId: input.tenantId,
26
+ startedAt: input.startedAt,
27
+ endedAt: input.endedAt,
28
+ promotedCount: input.promoted.length,
29
+ evictedCount: input.evicted.length,
30
+ mergedCount: input.merged.length,
31
+ edgesCreated: input.edgesCreated,
32
+ contradictionFound: input.contradictionFound,
33
+ promotedIds: JSON.stringify(input.promoted),
34
+ evictedIds: JSON.stringify(input.evicted),
35
+ mergedPairs: JSON.stringify(input.merged),
36
+ dryRun: input.dryRun ? 1 : 0,
37
+ summary: input.summary
38
+ });
39
+ return id;
40
+ }
41
+ getById(tenantId, id) {
42
+ const row = this.db.prepare(`
43
+ SELECT * FROM consolidation_runs WHERE tenant_id = ? AND id = ?
44
+ `).get(tenantId, id);
45
+ if (!row)
46
+ return null;
47
+ return this.mapRow(row);
48
+ }
49
+ listRecent(tenantId, limit) {
50
+ if (limit <= 0)
51
+ return [];
52
+ const rows = this.db.prepare(`
53
+ SELECT * FROM consolidation_runs
54
+ WHERE tenant_id = ?
55
+ ORDER BY started_at DESC, rowid DESC
56
+ LIMIT ?
57
+ `).all(tenantId, limit);
58
+ return rows.map((r) => this.mapRow(r));
59
+ }
60
+ latestForTenant(tenantId) {
61
+ const row = this.db.prepare(`
62
+ SELECT * FROM consolidation_runs
63
+ WHERE tenant_id = ?
64
+ ORDER BY started_at DESC, rowid DESC
65
+ LIMIT 1
66
+ `).get(tenantId);
67
+ if (!row)
68
+ return null;
69
+ return this.mapRow(row);
70
+ }
71
+ mapRow(row) {
72
+ return {
73
+ id: row.id,
74
+ tenantId: row.tenant_id,
75
+ startedAt: row.started_at,
76
+ endedAt: row.ended_at,
77
+ promoted: JSON.parse(row.promoted_ids),
78
+ evicted: JSON.parse(row.evicted_ids),
79
+ merged: JSON.parse(row.merged_pairs),
80
+ edgesCreated: row.edges_created_count,
81
+ contradictionFound: row.contradiction_found_count,
82
+ dryRun: row.dry_run === 1,
83
+ summary: row.summary
84
+ };
85
+ }
86
+ }
@@ -0,0 +1,66 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class DeviceRepo {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ create(input) {
8
+ const id = randomUUID();
9
+ const now = Date.now();
10
+ this.db.prepare(`
11
+ INSERT INTO devices (id, tenant_id, name, type, api_key_hash, last_seen_at, registered_at)
12
+ VALUES (?, ?, ?, ?, ?, NULL, ?)
13
+ `).run(id, input.tenantId, input.name, input.type, input.apiKeyHash, now);
14
+ return {
15
+ id,
16
+ tenantId: input.tenantId,
17
+ name: input.name,
18
+ type: input.type,
19
+ apiKeyHash: input.apiKeyHash,
20
+ lastSeenAt: null,
21
+ registeredAt: now
22
+ };
23
+ }
24
+ getById(tenantId, id) {
25
+ const row = this.db.prepare(`
26
+ SELECT * FROM devices WHERE tenant_id = ? AND id = ?
27
+ `).get(tenantId, id);
28
+ if (!row)
29
+ return null;
30
+ return this.mapRow(row);
31
+ }
32
+ findByKeyHash(apiKeyHash) {
33
+ const row = this.db.prepare(`
34
+ SELECT * FROM devices WHERE api_key_hash = ? LIMIT 1
35
+ `).get(apiKeyHash);
36
+ if (!row)
37
+ return null;
38
+ return this.mapRow(row);
39
+ }
40
+ list(tenantId) {
41
+ const rows = this.db.prepare(`
42
+ SELECT * FROM devices WHERE tenant_id = ?
43
+ ORDER BY registered_at DESC, rowid DESC
44
+ `).all(tenantId);
45
+ return rows.map((r) => this.mapRow(r));
46
+ }
47
+ touch(id) {
48
+ this.db.prepare(`
49
+ UPDATE devices SET last_seen_at = ? WHERE id = ?
50
+ `).run(Date.now(), id);
51
+ }
52
+ delete(id) {
53
+ this.db.prepare(`DELETE FROM devices WHERE id = ?`).run(id);
54
+ }
55
+ mapRow(row) {
56
+ return {
57
+ id: row.id,
58
+ tenantId: row.tenant_id,
59
+ name: row.name,
60
+ type: row.type,
61
+ apiKeyHash: row.api_key_hash,
62
+ lastSeenAt: row.last_seen_at,
63
+ registeredAt: row.registered_at
64
+ };
65
+ }
66
+ }
@@ -0,0 +1,104 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ export class EdgeRepo {
3
+ db;
4
+ constructor(db) {
5
+ this.db = db;
6
+ }
7
+ create(input) {
8
+ const id = randomUUID();
9
+ const now = Date.now();
10
+ this.db.prepare(`
11
+ INSERT INTO edges (id, tenant_id, from_memory_id, to_memory_id, type, strength, reason, created_at)
12
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
13
+ `).run(id, input.tenantId, input.fromMemoryId, input.toMemoryId, input.type, input.strength, input.reason, now);
14
+ return {
15
+ id,
16
+ tenantId: input.tenantId,
17
+ fromMemoryId: input.fromMemoryId,
18
+ toMemoryId: input.toMemoryId,
19
+ type: input.type,
20
+ strength: input.strength,
21
+ reason: input.reason,
22
+ createdAt: now
23
+ };
24
+ }
25
+ getOutgoing(tenantId, memoryId) {
26
+ const rows = this.db.prepare(`
27
+ SELECT * FROM edges
28
+ WHERE tenant_id = ? AND from_memory_id = ?
29
+ ORDER BY created_at DESC
30
+ `).all(tenantId, memoryId);
31
+ return rows.map(this.mapRow);
32
+ }
33
+ getIncoming(tenantId, memoryId) {
34
+ const rows = this.db.prepare(`
35
+ SELECT * FROM edges
36
+ WHERE tenant_id = ? AND to_memory_id = ?
37
+ ORDER BY created_at DESC
38
+ `).all(tenantId, memoryId);
39
+ return rows.map(this.mapRow);
40
+ }
41
+ getNeighbors(tenantId, memoryId, direction = 'both', edgeTypes) {
42
+ const typeFilter = edgeTypes && edgeTypes.length > 0
43
+ ? `AND type IN (${edgeTypes.map(() => '?').join(',')})`
44
+ : '';
45
+ const params = [tenantId, memoryId];
46
+ if (edgeTypes && edgeTypes.length > 0)
47
+ params.push(...edgeTypes);
48
+ const results = [];
49
+ if (direction === 'out' || direction === 'both') {
50
+ const outRows = this.db.prepare(`
51
+ SELECT id, type, strength, reason, to_memory_id, created_at
52
+ FROM edges
53
+ WHERE tenant_id = ? AND from_memory_id = ? ${typeFilter}
54
+ ORDER BY created_at DESC
55
+ `).all(...params);
56
+ for (const r of outRows) {
57
+ results.push({
58
+ edgeId: r.id,
59
+ type: r.type,
60
+ strength: r.strength,
61
+ reason: r.reason,
62
+ direction: 'out',
63
+ neighborId: r.to_memory_id,
64
+ createdAt: r.created_at
65
+ });
66
+ }
67
+ }
68
+ if (direction === 'in' || direction === 'both') {
69
+ const inRows = this.db.prepare(`
70
+ SELECT id, type, strength, reason, from_memory_id, created_at
71
+ FROM edges
72
+ WHERE tenant_id = ? AND to_memory_id = ? ${typeFilter}
73
+ ORDER BY created_at DESC
74
+ `).all(...params);
75
+ for (const r of inRows) {
76
+ results.push({
77
+ edgeId: r.id,
78
+ type: r.type,
79
+ strength: r.strength,
80
+ reason: r.reason,
81
+ direction: 'in',
82
+ neighborId: r.from_memory_id,
83
+ createdAt: r.created_at
84
+ });
85
+ }
86
+ }
87
+ return results;
88
+ }
89
+ delete(id) {
90
+ this.db.prepare('DELETE FROM edges WHERE id = ?').run(id);
91
+ }
92
+ mapRow(row) {
93
+ return {
94
+ id: row.id,
95
+ tenantId: row.tenant_id,
96
+ fromMemoryId: row.from_memory_id,
97
+ toMemoryId: row.to_memory_id,
98
+ type: row.type,
99
+ strength: row.strength,
100
+ reason: row.reason,
101
+ createdAt: row.created_at
102
+ };
103
+ }
104
+ }