@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.
- package/README.md +74 -0
- package/dist/cli-entry.js +49 -0
- package/dist/cli.js +53 -0
- package/dist/commands/backup.js +28 -0
- package/dist/commands/doctor.js +108 -0
- package/dist/commands/help.js +29 -0
- package/dist/commands/index.js +27 -0
- package/dist/commands/init.js +58 -0
- package/dist/commands/migrate.js +25 -0
- package/dist/commands/start.js +29 -0
- package/dist/commands/status.js +19 -0
- package/dist/commands/stop.js +46 -0
- package/dist/commands/version.js +21 -0
- package/dist/core/config.js +161 -0
- package/dist/core/decay.js +50 -0
- package/dist/core/types.js +72 -0
- package/dist/db/database.js +58 -0
- package/dist/db/repositories/access-log-repo.js +59 -0
- package/dist/db/repositories/consolidation-run-repo.js +86 -0
- package/dist/db/repositories/device-repo.js +66 -0
- package/dist/db/repositories/edge-repo.js +104 -0
- package/dist/db/repositories/memory-repo.js +294 -0
- package/dist/db/repositories/observation-repo.js +65 -0
- package/dist/db/repositories/session-repo.js +81 -0
- package/dist/db/repositories/stats-repo.js +92 -0
- package/dist/db/repositories/vector-repo.js +55 -0
- package/dist/db/schema.js +185 -0
- package/dist/injection/bundler.js +39 -0
- package/dist/injection/formatter.js +23 -0
- package/dist/prompts/compression.js +43 -0
- package/dist/prompts/edge-extract.js +21 -0
- package/dist/prompts/value-gate.js +27 -0
- package/dist/providers/embedding/index.js +36 -0
- package/dist/providers/embedding/local-xenova.js +166 -0
- package/dist/providers/embedding/noop.js +40 -0
- package/dist/providers/embedding/openai-compatible.js +46 -0
- package/dist/providers/llm/index.js +12 -0
- package/dist/providers/llm/noop.js +5 -0
- package/dist/providers/llm/openai.js +45 -0
- package/dist/rest/routes/consolidation.js +62 -0
- package/dist/rest/routes/devices.js +47 -0
- package/dist/rest/routes/injection.js +76 -0
- package/dist/rest/routes/memories.js +349 -0
- package/dist/rest/routes/observations.js +29 -0
- package/dist/rest/routes/sessions.js +37 -0
- package/dist/rest/routes/settings.js +25 -0
- package/dist/rest/routes/stats.js +15 -0
- package/dist/retrieval/bm25-search.js +91 -0
- package/dist/retrieval/causal-chain.js +197 -0
- package/dist/retrieval/fusion.js +48 -0
- package/dist/retrieval/graph-traversal.js +144 -0
- package/dist/retrieval/search-engine.js +150 -0
- package/dist/retrieval/vector-search.js +91 -0
- package/dist/server/auth.js +80 -0
- package/dist/server/bootstrap.js +28 -0
- package/dist/server/http.js +77 -0
- package/dist/server/logger.js +36 -0
- package/dist/server/rate-limiter.js +81 -0
- package/dist/server/scheduler.js +99 -0
- package/dist/workers/association.js +41 -0
- package/dist/workers/compressor.js +14 -0
- package/dist/workers/consolidator.js +201 -0
- package/dist/workers/embedder.js +102 -0
- package/dist/workers/graph-worker.js +166 -0
- package/dist/workers/value-gate.js +38 -0
- 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
|
+
}
|