@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,45 @@
1
+ import { z } from 'zod';
2
+ const OpenaiConfigSchema = z.object({
3
+ baseUrl: z.string().url().default('https://api.openai.com/v1'),
4
+ apiKey: z.string(),
5
+ model: z.string().default('gpt-4o-mini'),
6
+ temperature: z.number().min(0).max(2).default(0.2),
7
+ maxTokens: z.number().int().positive().default(2048)
8
+ });
9
+ export class OpenaiLlmProvider {
10
+ config;
11
+ constructor(raw) {
12
+ this.config = OpenaiConfigSchema.parse(raw);
13
+ }
14
+ async call(systemPrompt, userPrompt) {
15
+ try {
16
+ const url = `${this.config.baseUrl.replace(/\/+$/, '')}/chat/completions`;
17
+ const res = await fetch(url, {
18
+ method: 'POST',
19
+ headers: {
20
+ 'Content-Type': 'application/json',
21
+ 'Authorization': `Bearer ${this.config.apiKey}`
22
+ },
23
+ body: JSON.stringify({
24
+ model: this.config.model,
25
+ messages: [
26
+ { role: 'system', content: systemPrompt },
27
+ { role: 'user', content: userPrompt }
28
+ ],
29
+ temperature: this.config.temperature,
30
+ max_tokens: this.config.maxTokens
31
+ }),
32
+ signal: AbortSignal.timeout(30000)
33
+ });
34
+ if (!res.ok) {
35
+ const text = await res.text().catch(() => '');
36
+ throw new Error(`LLM API error ${res.status}: ${text.slice(0, 200)}`);
37
+ }
38
+ const json = await res.json();
39
+ return json.choices?.[0]?.message?.content ?? '';
40
+ }
41
+ catch (err) {
42
+ throw new Error('LLM request failed: ' + (err instanceof Error ? err.message : String(err)));
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod';
2
+ import { openDatabase } from '../../db/database.js';
3
+ import { ConsolidationRunRepo } from '../../db/repositories/consolidation-run-repo.js';
4
+ import { runConsolidation } from '../../workers/consolidator.js';
5
+ import { randomUUID } from 'node:crypto';
6
+ const TENANT_DEFAULT = 'tenant_default';
7
+ const ListQuerySchema = z.object({
8
+ limit: z.coerce.number().int().min(1).max(200).default(20)
9
+ });
10
+ const IdParamSchema = z.object({ id: z.string().min(1) });
11
+ const TriggerBodySchema = z.object({
12
+ dryRun: z.boolean().optional()
13
+ }).default({});
14
+ export function registerConsolidationRoute(app, dbPath) {
15
+ const runRepo = new ConsolidationRunRepo(openDatabase(dbPath));
16
+ app.get('/api/v1/consolidate/runs', async (request, reply) => {
17
+ const query = ListQuerySchema.parse(request.query);
18
+ const list = runRepo.listRecent(TENANT_DEFAULT, query.limit);
19
+ return reply.code(200).send({ runs: list, total: list.length });
20
+ });
21
+ app.get('/api/v1/consolidate/runs/:id', async (request, reply) => {
22
+ const { id } = IdParamSchema.parse(request.params);
23
+ const run = runRepo.getById(TENANT_DEFAULT, id);
24
+ if (!run) {
25
+ return reply.code(404).send({
26
+ error: { code: 'CONSOLIDATION_RUN_NOT_FOUND', message: `Run ${id} not found` }
27
+ });
28
+ }
29
+ return reply.code(200).send({ run });
30
+ });
31
+ app.post('/api/v1/consolidate', async (request, reply) => {
32
+ const body = TriggerBodySchema.parse(request.body ?? {});
33
+ const dryRun = body.dryRun ?? false;
34
+ const db = openDatabase(dbPath);
35
+ try {
36
+ const startedAt = Date.now();
37
+ const result = runConsolidation(db, TENANT_DEFAULT, { dryRun });
38
+ const endedAt = Date.now();
39
+ // Persist a run record so the UI can list it. (dryRun runs are also
40
+ // recorded so the UI can show "what would have happened".)
41
+ const id = randomUUID();
42
+ const runRepo2 = new ConsolidationRunRepo(db);
43
+ runRepo2.record({
44
+ tenantId: TENANT_DEFAULT,
45
+ startedAt,
46
+ endedAt,
47
+ promoted: result.promotedIds,
48
+ evicted: result.evictedIds,
49
+ merged: result.mergedPairs,
50
+ edgesCreated: 0,
51
+ contradictionFound: 0,
52
+ dryRun,
53
+ summary: result.summary
54
+ });
55
+ const run = runRepo2.getById(TENANT_DEFAULT, id);
56
+ return reply.code(200).send({ run });
57
+ }
58
+ finally {
59
+ db.close();
60
+ }
61
+ });
62
+ }
@@ -0,0 +1,47 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { z } from 'zod';
3
+ import { openDatabase } from '../../db/database.js';
4
+ import { DeviceRepo } from '../../db/repositories/device-repo.js';
5
+ import { hashApiKey } from '../../server/auth.js';
6
+ const TENANT_DEFAULT = 'tenant_default';
7
+ const CreateBodySchema = z.object({
8
+ name: z.string().min(1).max(80),
9
+ type: z.enum(['opencode', 'cursor', 'claude_code', 'rest'])
10
+ });
11
+ const IdParamSchema = z.object({ id: z.string().min(1) });
12
+ export function registerDevicesRoute(app, dbPath) {
13
+ const deviceRepo = new DeviceRepo(openDatabase(dbPath));
14
+ app.get('/api/v1/devices', async (_request, reply) => {
15
+ const list = deviceRepo.list(TENANT_DEFAULT);
16
+ return reply.code(200).send({ devices: list, total: list.length });
17
+ });
18
+ app.post('/api/v1/devices', async (request, reply) => {
19
+ const body = CreateBodySchema.parse(request.body);
20
+ // Generate a random 32-byte hex key (256 bits). The plain key is
21
+ // returned to the caller EXACTLY ONCE; only the SHA-256 hash is stored.
22
+ const apiKey = randomBytes(32).toString('hex');
23
+ const apiKeyHash = hashApiKey(apiKey, 'sha256');
24
+ const device = deviceRepo.create({
25
+ tenantId: TENANT_DEFAULT,
26
+ name: body.name,
27
+ type: body.type,
28
+ apiKeyHash
29
+ });
30
+ return reply.code(201).send({
31
+ device,
32
+ apiKey, // <-- returned only on creation
33
+ notice: 'This is the only time the API key will be shown. Store it securely.'
34
+ });
35
+ });
36
+ app.delete('/api/v1/devices/:id', async (request, reply) => {
37
+ const { id } = IdParamSchema.parse(request.params);
38
+ const existing = deviceRepo.getById(TENANT_DEFAULT, id);
39
+ if (!existing) {
40
+ return reply.code(404).send({
41
+ error: { code: 'DEVICE_NOT_FOUND', message: `Device ${id} not found` }
42
+ });
43
+ }
44
+ deviceRepo.delete(id);
45
+ return reply.code(200).send({ ok: true, deviceId: id });
46
+ });
47
+ }
@@ -0,0 +1,76 @@
1
+ import { z } from 'zod';
2
+ import { openDatabase } from '../../db/database.js';
3
+ import { searchMemories } from '../../retrieval/search-engine.js';
4
+ import { buildStablePack, buildDeltaPack, createContentHash } from '../../injection/bundler.js';
5
+ import { formatMemoriesAsXml } from '../../injection/formatter.js';
6
+ const TENANT_DEFAULT = 'tenant_default';
7
+ const InjectRequestSchema = z.object({
8
+ sessionId: z.string().min(1),
9
+ phase: z.enum(['session_start', 'prompt_delta', 'file_pack', 'failure_delta']),
10
+ query: z.string().optional(),
11
+ files: z.array(z.string()).optional(),
12
+ alreadyInjected: z.array(z.string()).optional()
13
+ });
14
+ export function registerInjectionRoute(app, dbPath) {
15
+ app.post('/api/v1/inject', async (request, reply) => {
16
+ const input = InjectRequestSchema.parse(request.body);
17
+ const db = openDatabase(dbPath);
18
+ try {
19
+ const alreadyInjected = new Set(input.alreadyInjected ?? []);
20
+ let result;
21
+ let contextMemories = [];
22
+ if (input.phase === 'session_start') {
23
+ // Stable pack: top long/medium memories
24
+ const stableRows = db.prepare(`
25
+ SELECT id, type, tier, title, summary, strength, importance
26
+ FROM memories
27
+ WHERE tenant_id = ? AND deleted_at IS NULL
28
+ AND (tier = 'long' OR (tier = 'medium' AND strength >= 0.4))
29
+ AND access_count >= 1
30
+ ORDER BY tier ASC, strength * importance DESC
31
+ LIMIT 50
32
+ `).all(TENANT_DEFAULT);
33
+ result = buildStablePack(stableRows, { budget: 1200 });
34
+ contextMemories = stableRows.filter(m => result.memoryIds.includes(m.id));
35
+ }
36
+ else {
37
+ // Delta pack: search for relevant memories
38
+ if (!input.query && (!input.files || input.files.length === 0)) {
39
+ // Nothing to search for, return empty bundle
40
+ result = { memoryIds: [], contentHash: createContentHash(input.phase, []), estimatedTokens: 0 };
41
+ }
42
+ else {
43
+ const search = await searchMemories(db, TENANT_DEFAULT, {
44
+ query: input.query ?? input.files?.join(' ') ?? '',
45
+ limit: 10
46
+ });
47
+ const candidates = search.results.map(r => ({
48
+ id: r.candidate.memory.id,
49
+ type: r.candidate.memory.type,
50
+ tier: r.candidate.memory.tier,
51
+ title: r.candidate.memory.title,
52
+ summary: r.candidate.memory.summary,
53
+ strength: r.candidate.memory.strength,
54
+ importance: r.candidate.memory.importance
55
+ }));
56
+ result = buildDeltaPack(candidates, { alreadyInjected, budget: 800 });
57
+ contextMemories = candidates.filter(m => result.memoryIds.includes(m.id));
58
+ }
59
+ }
60
+ const contextXml = formatMemoriesAsXml(input.phase, contextMemories);
61
+ const bundleId = `${input.sessionId}:${input.phase}:${result.contentHash}`;
62
+ const body = {
63
+ bundleId,
64
+ phase: input.phase,
65
+ memoryIds: result.memoryIds,
66
+ contentHash: result.contentHash,
67
+ estimatedTokens: result.estimatedTokens,
68
+ contextXml
69
+ };
70
+ return reply.code(200).send(body);
71
+ }
72
+ finally {
73
+ db.close();
74
+ }
75
+ });
76
+ }
@@ -0,0 +1,349 @@
1
+ import { z } from 'zod';
2
+ import { openDatabase } from '../../db/database.js';
3
+ import { MemoryRepo } from '../../db/repositories/memory-repo.js';
4
+ import { EdgeRepo } from '../../db/repositories/edge-repo.js';
5
+ import { AccessLogRepo } from '../../db/repositories/access-log-repo.js';
6
+ import { searchMemories } from '../../retrieval/search-engine.js';
7
+ import { EdgeTypeSchema } from '../../core/types.js';
8
+ import { RateLimiter } from '../../server/rate-limiter.js';
9
+ const TENANT_DEFAULT = 'tenant_default';
10
+ /**
11
+ * Per-tenant rate limiter for memory_write endpoints. The bucket is keyed
12
+ * by the API key (per device) so a misbehaving client cannot exhaust
13
+ * the bucket for an honest one. Defaults: 30 writes/minute burst, 2/sec
14
+ * sustained. Tuned for the "one memory per conversational turn" pattern;
15
+ * an LLM calling memory_save on every tool use fits comfortably.
16
+ */
17
+ const writeLimiter = new RateLimiter({
18
+ capacity: 30,
19
+ refillPerSecond: 2
20
+ });
21
+ const ListQuerySchema = z.object({
22
+ limit: z.coerce.number().int().min(1).max(100).default(20),
23
+ offset: z.coerce.number().int().min(0).default(0),
24
+ type: z.string().optional(),
25
+ tier: z.string().optional()
26
+ });
27
+ const SearchBodySchema = z.object({
28
+ query: z.string().default(''),
29
+ limit: z.number().int().min(1).max(50).optional(),
30
+ scope: z.object({
31
+ project: z.string().optional(),
32
+ domain: z.string().optional(),
33
+ topic: z.string().optional()
34
+ }).optional(),
35
+ types: z.array(z.string()).optional(),
36
+ mode: z.enum(['compact', 'full']).optional()
37
+ });
38
+ const UpdateBodySchema = z.object({
39
+ title: z.string().min(1).max(120).optional(),
40
+ content: z.string().min(1).optional(),
41
+ summary: z.string().min(1).max(500).optional(),
42
+ importance: z.number().int().min(1).max(10).optional(),
43
+ confidence: z.number().min(0).max(1).optional()
44
+ });
45
+ const GraphQuerySchema = z.object({
46
+ depth: z.coerce.number().int().min(1).max(3).default(1),
47
+ edgeTypes: z.string().optional(),
48
+ direction: z.enum(['in', 'out', 'both']).default('both'),
49
+ limit: z.coerce.number().int().min(1).max(200).default(50)
50
+ });
51
+ const AccessLogsQuerySchema = z.object({
52
+ limit: z.coerce.number().int().min(1).max(200).default(50)
53
+ });
54
+ export function registerMemoriesRoute(app, dbPath) {
55
+ const memoryRepo = new MemoryRepo(openDatabase(dbPath));
56
+ const edgeRepo = new EdgeRepo(openDatabase(dbPath));
57
+ const accessLogRepo = new AccessLogRepo(openDatabase(dbPath));
58
+ // GET /api/v1/memories — list
59
+ app.get('/api/v1/memories', async (request, reply) => {
60
+ const query = ListQuerySchema.parse(request.query);
61
+ const db = openDatabase(dbPath);
62
+ try {
63
+ let sql = 'SELECT * FROM memories WHERE tenant_id = ? AND deleted_at IS NULL';
64
+ const params = [TENANT_DEFAULT];
65
+ if (query.type) {
66
+ sql += ' AND type = ?';
67
+ params.push(query.type);
68
+ }
69
+ if (query.tier) {
70
+ sql += ' AND tier = ?';
71
+ params.push(query.tier);
72
+ }
73
+ const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as cnt');
74
+ const total = db.prepare(countSql).get(...params).cnt;
75
+ sql += ' ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?';
76
+ params.push(query.limit, query.offset);
77
+ const rows = db.prepare(sql).all(...params);
78
+ const memories = rows.map((row) => memoryRepo.getById(TENANT_DEFAULT, row.id));
79
+ return reply.code(200).send({ memories, total, limit: query.limit, offset: query.offset });
80
+ }
81
+ finally {
82
+ db.close();
83
+ }
84
+ });
85
+ // POST /api/v1/memories — create (was in http.ts originally, kept here for cohesion)
86
+ app.post('/api/v1/memories', async (request, reply) => {
87
+ // Rate-limit by the authenticated device. Pre-bucket — even if Zod
88
+ // validation fails, the request counts against the limit, since a
89
+ // flood of invalid writes is still a flood.
90
+ const apiKey = request.headers['x-api-key'] ?? 'anonymous';
91
+ const limitResult = writeLimiter.consume(apiKey);
92
+ if (!limitResult.allowed) {
93
+ return reply
94
+ .code(429)
95
+ .header('Retry-After', String(limitResult.retryAfterSec))
96
+ .send({
97
+ error: {
98
+ code: 'RATE_LIMITED',
99
+ message: `Too many writes. Retry after ${limitResult.retryAfterSec}s.`
100
+ }
101
+ });
102
+ }
103
+ const { CreateMemoryInputSchema } = await import('../../core/types.js');
104
+ const input = CreateMemoryInputSchema.parse({
105
+ ...request.body,
106
+ tenantId: TENANT_DEFAULT
107
+ });
108
+ try {
109
+ const memory = memoryRepo.create(input);
110
+ return reply.code(201).send({
111
+ memoryId: memory.id,
112
+ type: memory.type,
113
+ tier: memory.tier,
114
+ title: memory.title,
115
+ summary: memory.summary,
116
+ createdEdges: []
117
+ });
118
+ }
119
+ catch (err) {
120
+ // UUID v4 collision is astronomically unlikely, but a PRIMARY KEY
121
+ // constraint failure is recoverable: re-throw as a clean 500 with
122
+ // a hint, not the raw SQLite error.
123
+ const msg = err.message ?? '';
124
+ if (msg.includes('UNIQUE') || msg.includes('PRIMARY KEY')) {
125
+ return reply.code(500).send({
126
+ error: {
127
+ code: 'ID_COLLISION',
128
+ message: 'Failed to generate a unique memory id. Please retry.'
129
+ }
130
+ });
131
+ }
132
+ throw err;
133
+ }
134
+ });
135
+ // POST /api/v1/memories/search
136
+ app.post('/api/v1/memories/search', async (request, reply) => {
137
+ const body = SearchBodySchema.parse(request.body);
138
+ const db = openDatabase(dbPath);
139
+ try {
140
+ const search = await searchMemories(db, TENANT_DEFAULT, {
141
+ query: body.query,
142
+ limit: body.limit ?? 8,
143
+ scope: body.scope,
144
+ types: body.types
145
+ });
146
+ const mode = body.mode ?? 'compact';
147
+ const results = search.results.map((r) => {
148
+ const base = {
149
+ memoryId: r.candidate.memory.id,
150
+ type: r.candidate.memory.type,
151
+ tier: r.candidate.memory.tier,
152
+ title: r.candidate.memory.title,
153
+ summary: r.candidate.memory.summary,
154
+ finalScore: r.finalScore,
155
+ sources: Array.from(r.candidate.sources)
156
+ };
157
+ if (mode === 'full') {
158
+ return {
159
+ ...base,
160
+ content: r.candidate.memory.content,
161
+ importance: r.candidate.memory.importance,
162
+ confidence: r.candidate.memory.confidence,
163
+ strength: r.candidate.memory.strength,
164
+ scopes: r.candidate.memory.scopes
165
+ };
166
+ }
167
+ return base;
168
+ });
169
+ return reply.code(200).send({ results, totalCandidates: search.totalCandidates });
170
+ }
171
+ finally {
172
+ db.close();
173
+ }
174
+ });
175
+ // GET /api/v1/memories/:id
176
+ app.get('/api/v1/memories/:id', async (request, reply) => {
177
+ const params = z.object({ id: z.string().min(1) }).parse(request.params);
178
+ const memory = memoryRepo.getById(TENANT_DEFAULT, params.id);
179
+ if (!memory) {
180
+ return reply.code(404).send({
181
+ error: { code: 'MEMORY_NOT_FOUND', message: `Memory ${params.id} not found` }
182
+ });
183
+ }
184
+ return memory;
185
+ });
186
+ // PATCH /api/v1/memories/:id
187
+ app.patch('/api/v1/memories/:id', async (request, reply) => {
188
+ const params = z.object({ id: z.string().min(1) }).parse(request.params);
189
+ const body = UpdateBodySchema.parse(request.body);
190
+ const db = openDatabase(dbPath);
191
+ try {
192
+ const existing = memoryRepo.getById(TENANT_DEFAULT, params.id);
193
+ if (!existing) {
194
+ return reply.code(404).send({
195
+ error: { code: 'MEMORY_NOT_FOUND', message: `Memory ${params.id} not found` }
196
+ });
197
+ }
198
+ const updates = [];
199
+ const values = [];
200
+ if (body.title !== undefined) {
201
+ updates.push('title = ?');
202
+ values.push(body.title);
203
+ }
204
+ if (body.content !== undefined) {
205
+ updates.push('content = ?');
206
+ values.push(body.content);
207
+ }
208
+ if (body.summary !== undefined) {
209
+ updates.push('summary = ?');
210
+ values.push(body.summary);
211
+ }
212
+ if (body.importance !== undefined) {
213
+ updates.push('importance = ?');
214
+ values.push(body.importance);
215
+ }
216
+ if (body.confidence !== undefined) {
217
+ updates.push('confidence = ?');
218
+ values.push(body.confidence);
219
+ }
220
+ if (updates.length === 0) {
221
+ return reply.code(200).send(existing);
222
+ }
223
+ updates.push('updated_at = ?');
224
+ values.push(Date.now());
225
+ values.push(TENANT_DEFAULT, params.id);
226
+ db.prepare(`
227
+ UPDATE memories SET ${updates.join(', ')}
228
+ WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
229
+ `).run(...values);
230
+ const updated = memoryRepo.getById(TENANT_DEFAULT, params.id);
231
+ return reply.code(200).send(updated);
232
+ }
233
+ finally {
234
+ db.close();
235
+ }
236
+ });
237
+ // DELETE /api/v1/memories/:id — soft delete
238
+ app.delete('/api/v1/memories/:id', async (request, reply) => {
239
+ const params = z.object({ id: z.string().min(1) }).parse(request.params);
240
+ const db = openDatabase(dbPath);
241
+ try {
242
+ const existing = memoryRepo.getById(TENANT_DEFAULT, params.id);
243
+ if (!existing) {
244
+ return reply.code(404).send({
245
+ error: { code: 'MEMORY_NOT_FOUND', message: `Memory ${params.id} not found` }
246
+ });
247
+ }
248
+ const now = Date.now();
249
+ db.prepare(`
250
+ UPDATE memories SET deleted_at = ?, eviction_reason = ?
251
+ WHERE tenant_id = ? AND id = ?
252
+ `).run(now, 'manual_delete', TENANT_DEFAULT, params.id);
253
+ return reply.code(200).send({ ok: true, memoryId: params.id, deletedAt: now });
254
+ }
255
+ finally {
256
+ db.close();
257
+ }
258
+ });
259
+ // GET /api/v1/memories/:id/graph
260
+ app.get('/api/v1/memories/:id/graph', async (request, reply) => {
261
+ const params = z.object({ id: z.string().min(1) }).parse(request.params);
262
+ const query = GraphQuerySchema.parse(request.query);
263
+ const memory = memoryRepo.getById(TENANT_DEFAULT, params.id);
264
+ if (!memory) {
265
+ return reply.code(404).send({
266
+ error: { code: 'MEMORY_NOT_FOUND', message: `Memory ${params.id} not found` }
267
+ });
268
+ }
269
+ // Parse and validate edge types against EdgeTypeSchema
270
+ const edgeTypes = query.edgeTypes
271
+ ? query.edgeTypes.split(',').map((s) => s.trim()).filter(Boolean).flatMap((s) => {
272
+ const r = EdgeTypeSchema.safeParse(s);
273
+ return r.success ? [r.data] : [];
274
+ })
275
+ : undefined;
276
+ const db = openDatabase(dbPath);
277
+ try {
278
+ const allNeighbors = [];
279
+ const neighbors = edgeRepo.getNeighbors(TENANT_DEFAULT, params.id, query.direction, edgeTypes);
280
+ allNeighbors.push(...neighbors);
281
+ const seenNeighborIds = new Set([params.id]);
282
+ let frontier = neighbors.map((n) => n.neighborId);
283
+ for (let depth = 1; depth < query.depth && frontier.length > 0; depth++) {
284
+ const nextFrontier = [];
285
+ for (const nid of frontier) {
286
+ if (seenNeighborIds.has(nid))
287
+ continue;
288
+ seenNeighborIds.add(nid);
289
+ const next = edgeRepo.getNeighbors(TENANT_DEFAULT, nid, query.direction, edgeTypes);
290
+ allNeighbors.push(...next);
291
+ for (const n of next) {
292
+ if (!seenNeighborIds.has(n.neighborId))
293
+ nextFrontier.push(n.neighborId);
294
+ }
295
+ }
296
+ frontier = nextFrontier;
297
+ }
298
+ // Build nodes
299
+ const nodeIds = new Set([params.id]);
300
+ for (const n of allNeighbors)
301
+ nodeIds.add(n.neighborId);
302
+ const nodes = Array.from(nodeIds).slice(0, query.limit).map((id) => {
303
+ const m = memoryRepo.getById(TENANT_DEFAULT, id);
304
+ return m ? {
305
+ id: m.id,
306
+ type: m.type,
307
+ tier: m.tier,
308
+ title: m.title,
309
+ summary: m.summary
310
+ } : null;
311
+ }).filter(Boolean);
312
+ // Build edges (dedupe by edgeId)
313
+ const seenEdgeIds = new Set();
314
+ const edges = allNeighbors
315
+ .filter((n) => nodeIds.has(n.neighborId))
316
+ .filter((n) => {
317
+ if (seenEdgeIds.has(n.edgeId))
318
+ return false;
319
+ seenEdgeIds.add(n.edgeId);
320
+ return true;
321
+ })
322
+ .map((n) => ({
323
+ id: n.edgeId,
324
+ fromMemoryId: n.direction === 'out' ? params.id : n.neighborId,
325
+ toMemoryId: n.direction === 'out' ? n.neighborId : params.id,
326
+ type: n.type,
327
+ strength: n.strength,
328
+ reason: n.reason
329
+ }));
330
+ return reply.code(200).send({ nodes, edges });
331
+ }
332
+ finally {
333
+ db.close();
334
+ }
335
+ });
336
+ // GET /api/v1/memories/:id/access-logs
337
+ app.get('/api/v1/memories/:id/access-logs', async (request, reply) => {
338
+ const params = z.object({ id: z.string().min(1) }).parse(request.params);
339
+ const query = AccessLogsQuerySchema.parse(request.query);
340
+ const memory = memoryRepo.getById(TENANT_DEFAULT, params.id);
341
+ if (!memory) {
342
+ return reply.code(404).send({
343
+ error: { code: 'MEMORY_NOT_FOUND', message: `Memory ${params.id} not found` }
344
+ });
345
+ }
346
+ const logs = accessLogRepo.listForMemory(TENANT_DEFAULT, params.id, query.limit);
347
+ return reply.code(200).send({ logs, total: logs.length });
348
+ });
349
+ }
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { openDatabase } from '../../db/database.js';
3
+ import { ObservationRepo } from '../../db/repositories/observation-repo.js';
4
+ const TENANT_DEFAULT = 'tenant_default';
5
+ const ListQuerySchema = z.object({
6
+ limit: z.coerce.number().int().min(1).max(200).default(20),
7
+ unprocessedOnly: z.coerce.boolean().default(false)
8
+ });
9
+ const IdParamSchema = z.object({ id: z.string().min(1) });
10
+ export function registerObservationsRoute(app, dbPath) {
11
+ const obsRepo = new ObservationRepo(openDatabase(dbPath));
12
+ app.get('/api/v1/observations', async (request, reply) => {
13
+ const query = ListQuerySchema.parse(request.query);
14
+ const list = query.unprocessedOnly
15
+ ? obsRepo.listUnprocessed(TENANT_DEFAULT, query.limit)
16
+ : obsRepo.listUnprocessed(TENANT_DEFAULT, query.limit); // v1: same path; v1.1 will add a "list all" method
17
+ return reply.code(200).send({ observations: list, total: list.length });
18
+ });
19
+ app.get('/api/v1/observations/:id', async (request, reply) => {
20
+ const { id } = IdParamSchema.parse(request.params);
21
+ const obs = obsRepo.getById(TENANT_DEFAULT, id);
22
+ if (!obs) {
23
+ return reply.code(404).send({
24
+ error: { code: 'OBSERVATION_NOT_FOUND', message: `Observation ${id} not found` }
25
+ });
26
+ }
27
+ return reply.code(200).send(obs);
28
+ });
29
+ }
@@ -0,0 +1,37 @@
1
+ import { z } from 'zod';
2
+ import { openDatabase } from '../../db/database.js';
3
+ import { SessionRepo } from '../../db/repositories/session-repo.js';
4
+ const TENANT_DEFAULT = 'tenant_default';
5
+ const ListQuerySchema = z.object({
6
+ limit: z.coerce.number().int().min(1).max(200).default(20)
7
+ });
8
+ const IdParamSchema = z.object({ id: z.string().min(1) });
9
+ export function registerSessionsRoute(app, dbPath) {
10
+ const sessionRepo = new SessionRepo(openDatabase(dbPath));
11
+ app.get('/api/v1/sessions', async (request, reply) => {
12
+ const query = ListQuerySchema.parse(request.query);
13
+ const list = sessionRepo.listRecent(TENANT_DEFAULT, query.limit);
14
+ return reply.code(200).send({ sessions: list, total: list.length });
15
+ });
16
+ app.get('/api/v1/sessions/:id', async (request, reply) => {
17
+ const { id } = IdParamSchema.parse(request.params);
18
+ const session = sessionRepo.getById(TENANT_DEFAULT, id);
19
+ if (!session) {
20
+ return reply.code(404).send({
21
+ error: { code: 'SESSION_NOT_FOUND', message: `Session ${id} not found` }
22
+ });
23
+ }
24
+ return reply.code(200).send(session);
25
+ });
26
+ app.get('/api/v1/sessions/:id/memories', async (request, reply) => {
27
+ const { id } = IdParamSchema.parse(request.params);
28
+ const session = sessionRepo.getById(TENANT_DEFAULT, id);
29
+ if (!session) {
30
+ return reply.code(404).send({
31
+ error: { code: 'SESSION_NOT_FOUND', message: `Session ${id} not found` }
32
+ });
33
+ }
34
+ const memories = sessionRepo.listMemories(TENANT_DEFAULT, id);
35
+ return reply.code(200).send({ memories, total: memories.length });
36
+ });
37
+ }
@@ -0,0 +1,25 @@
1
+ import { expandPath, loadConfig } from '../../core/config.js';
2
+ /**
3
+ * GET /api/v1/settings — returns the loaded config with secrets masked.
4
+ * The UI uses this to display "read-only" configuration in the Settings page.
5
+ */
6
+ export function registerSettingsRoute(app, configPath) {
7
+ app.get('/api/v1/settings', async (_request, reply) => {
8
+ const config = loadConfig(configPath);
9
+ // Mask secrets
10
+ const maskIfString = (v) => (v ? '***' : v);
11
+ return reply.code(200).send({
12
+ server: config.server,
13
+ storage: { ...config.storage, path: expandPath(config.storage.path) },
14
+ auth: {
15
+ ...config.auth,
16
+ deviceApiKey: maskIfString(config.auth.deviceApiKey)
17
+ },
18
+ embedding: { ...config.embedding, apiKey: maskIfString(config.embedding.apiKey) },
19
+ llm: { ...config.llm, apiKey: maskIfString(config.llm.apiKey) },
20
+ consolidation: config.consolidation,
21
+ injection: config.injection,
22
+ search: config.search
23
+ });
24
+ });
25
+ }