@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,91 @@
|
|
|
1
|
+
import { getVecTableName, VECTOR_DEFAULT_DIMENSIONS } from '../db/database.js';
|
|
2
|
+
/**
|
|
3
|
+
* Vector similarity search using sqlite-vec.
|
|
4
|
+
*
|
|
5
|
+
* Returns the top-`limit` memories in the tenant whose stored embedding is
|
|
6
|
+
* closest to `queryVector` (L2 distance, then converted to similarity
|
|
7
|
+
* = 1 / (1 + distance) for downstream fusion).
|
|
8
|
+
*/
|
|
9
|
+
export function vectorSearch(db, tenantId, queryVector, limit, dimensions = VECTOR_DEFAULT_DIMENSIONS) {
|
|
10
|
+
if (limit <= 0 || queryVector.length === 0)
|
|
11
|
+
return [];
|
|
12
|
+
if (queryVector.length !== dimensions) {
|
|
13
|
+
// Mismatched dimensions — degrade gracefully.
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const tableName = getVecTableName(dimensions);
|
|
17
|
+
// Verify the vec table exists; if not, vector search returns empty.
|
|
18
|
+
const exists = db
|
|
19
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`)
|
|
20
|
+
.get(tableName);
|
|
21
|
+
if (!exists)
|
|
22
|
+
return [];
|
|
23
|
+
const float32 = new Float32Array(queryVector);
|
|
24
|
+
// Use a subquery: sqlite-vec's `MATCH` operator only works on the
|
|
25
|
+
// outermost virtual table in a query. Wrapping the vec search as a
|
|
26
|
+
// subquery lets us join the result against `memories` for scope/type filters.
|
|
27
|
+
const rows = db.prepare(`
|
|
28
|
+
SELECT
|
|
29
|
+
m.id, m.tenant_id, m.tier, m.type, m.title, m.content, m.summary,
|
|
30
|
+
m.concepts_json, m.files_json, m.importance, m.confidence, m.strength,
|
|
31
|
+
m.source, m.scope_level, m.source_client, m.source_device_id,
|
|
32
|
+
m.source_session_id, m.tau, m.access_count, m.last_accessed_at,
|
|
33
|
+
m.last_reinforced_at, m.last_decay_at, m.reinforcement_score,
|
|
34
|
+
m.promoted_at, m.created_at, m.updated_at, m.deleted_at, m.eviction_reason,
|
|
35
|
+
v.distance AS vec_distance
|
|
36
|
+
FROM (
|
|
37
|
+
SELECT memory_id, tenant_id, distance
|
|
38
|
+
FROM ${tableName}
|
|
39
|
+
WHERE embedding MATCH ? AND k = ?
|
|
40
|
+
) v
|
|
41
|
+
JOIN memories m ON m.id = v.memory_id
|
|
42
|
+
WHERE v.tenant_id = ? AND m.deleted_at IS NULL
|
|
43
|
+
ORDER BY v.distance
|
|
44
|
+
LIMIT ?
|
|
45
|
+
`).all(float32, limit, tenantId, limit);
|
|
46
|
+
return rows.map((row) => {
|
|
47
|
+
const distance = Number(row.vec_distance);
|
|
48
|
+
return {
|
|
49
|
+
memory: rowToMemory(row),
|
|
50
|
+
distance,
|
|
51
|
+
similarity: similarityFromL2(distance)
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function rowToMemory(row) {
|
|
56
|
+
return {
|
|
57
|
+
id: row.id,
|
|
58
|
+
tenantId: row.tenant_id,
|
|
59
|
+
tier: row.tier,
|
|
60
|
+
type: row.type,
|
|
61
|
+
title: row.title,
|
|
62
|
+
content: row.content,
|
|
63
|
+
summary: row.summary,
|
|
64
|
+
concepts: JSON.parse(row.concepts_json),
|
|
65
|
+
files: JSON.parse(row.files_json),
|
|
66
|
+
importance: row.importance,
|
|
67
|
+
confidence: row.confidence,
|
|
68
|
+
strength: row.strength,
|
|
69
|
+
source: row.source,
|
|
70
|
+
scopeLevel: row.scope_level,
|
|
71
|
+
scopes: [],
|
|
72
|
+
sourceClient: row.source_client,
|
|
73
|
+
sourceDeviceId: row.source_device_id,
|
|
74
|
+
sourceSessionId: row.source_session_id,
|
|
75
|
+
tau: row.tau,
|
|
76
|
+
accessCount: row.access_count,
|
|
77
|
+
lastAccessedAt: row.last_accessed_at,
|
|
78
|
+
lastReinforcedAt: row.last_reinforced_at,
|
|
79
|
+
lastDecayAt: row.last_decay_at,
|
|
80
|
+
reinforcementScore: row.reinforcement_score,
|
|
81
|
+
promotedAt: row.promoted_at,
|
|
82
|
+
createdAt: row.created_at,
|
|
83
|
+
updatedAt: row.updated_at,
|
|
84
|
+
deletedAt: row.deleted_at,
|
|
85
|
+
evictionReason: row.eviction_reason
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** L2 distance → [0, 1] similarity. */
|
|
89
|
+
export function similarityFromL2(distance) {
|
|
90
|
+
return 1 / (1 + distance);
|
|
91
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { openDatabase } from '../db/database.js';
|
|
3
|
+
import { DeviceRepo } from '../db/repositories/device-repo.js';
|
|
4
|
+
/**
|
|
5
|
+
* Hash an API key for storage/comparison. v1 supports two strategies:
|
|
6
|
+
* - 'plain': identity (no hash) — convenient for local dev / tests
|
|
7
|
+
* - 'sha256': SHA-256 of the key — safer for production
|
|
8
|
+
*/
|
|
9
|
+
export function hashApiKey(key, strategy = 'plain') {
|
|
10
|
+
if (strategy === 'sha256')
|
|
11
|
+
return createHash('sha256').update(key).digest('hex');
|
|
12
|
+
return key;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Extract a Bearer token from the `Authorization` header.
|
|
16
|
+
* Returns `null` if missing or malformed.
|
|
17
|
+
*/
|
|
18
|
+
export function extractBearerToken(authorization) {
|
|
19
|
+
if (!authorization)
|
|
20
|
+
return null;
|
|
21
|
+
const m = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
22
|
+
return m ? m[1].trim() : null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Registers an `onRequest` hook that enforces Bearer-token authentication on
|
|
26
|
+
* `/api/v1/*` routes (except `/api/v1/health`).
|
|
27
|
+
*
|
|
28
|
+
* Behavior:
|
|
29
|
+
* - `config.requireAuth === false`: skip auth, leave `request.authDevice` undefined.
|
|
30
|
+
* This is the v1 default for developer convenience.
|
|
31
|
+
* - `config.requireAuth === true`: require a valid Bearer token; otherwise 401.
|
|
32
|
+
* Also calls `device.touch(id)` to update `lastSeenAt`.
|
|
33
|
+
*
|
|
34
|
+
* On success, `request.authDevice` is populated and downstream handlers can
|
|
35
|
+
* use `request.tenantId` and `request.deviceId` for tenant isolation.
|
|
36
|
+
*/
|
|
37
|
+
export function registerAuthMiddleware(app, options) {
|
|
38
|
+
const deviceRepo = new DeviceRepo(openDatabase(options.dbPath));
|
|
39
|
+
const hashStrategy = options.config.requireAuth ? 'sha256' : 'plain';
|
|
40
|
+
// Build a "default device" record on the fly when requireAuth is disabled,
|
|
41
|
+
// so the rest of the system can still treat the request as authenticated.
|
|
42
|
+
const defaultTenantId = 'tenant_default';
|
|
43
|
+
app.addHook('onRequest', async (request, reply) => {
|
|
44
|
+
const url = request.routeOptions?.url ?? request.url;
|
|
45
|
+
if (!url.startsWith('/api/v1/'))
|
|
46
|
+
return;
|
|
47
|
+
if (url === '/api/v1/health')
|
|
48
|
+
return;
|
|
49
|
+
if (!options.config.requireAuth) {
|
|
50
|
+
// Dev mode: synthesize a default device identity.
|
|
51
|
+
request.authDevice = {
|
|
52
|
+
deviceId: 'dev-device',
|
|
53
|
+
tenantId: defaultTenantId,
|
|
54
|
+
type: 'rest',
|
|
55
|
+
name: 'dev-local'
|
|
56
|
+
};
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const token = extractBearerToken(request.headers.authorization);
|
|
60
|
+
if (!token) {
|
|
61
|
+
return reply.code(401).send({
|
|
62
|
+
error: { code: 'UNAUTHORIZED', message: 'Missing Bearer token' }
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const hash = hashApiKey(token, hashStrategy);
|
|
66
|
+
const device = deviceRepo.findByKeyHash(hash);
|
|
67
|
+
if (!device) {
|
|
68
|
+
return reply.code(401).send({
|
|
69
|
+
error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
deviceRepo.touch(device.id);
|
|
73
|
+
request.authDevice = {
|
|
74
|
+
deviceId: device.id,
|
|
75
|
+
tenantId: device.tenantId,
|
|
76
|
+
type: device.type,
|
|
77
|
+
name: device.name
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expandPath, loadConfig } from '../core/config.js';
|
|
2
|
+
import { createHttpServer } from './http.js';
|
|
3
|
+
import { startConsolidationScheduler } from './scheduler.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
const configPath = process.env.MEMWEAVE_CONFIG;
|
|
6
|
+
const config = loadConfig(configPath);
|
|
7
|
+
const dbPath = expandPath(config.storage.path);
|
|
8
|
+
const app = await createHttpServer({ dbPath, configPath });
|
|
9
|
+
// Background consolidation: every 6 hours, also run once on startup so any
|
|
10
|
+
// pending promotions/evictions are applied. Disable by setting MEMWEAVE_NO_SCHEDULER=1.
|
|
11
|
+
if (process.env.MEMWEAVE_NO_SCHEDULER !== '1') {
|
|
12
|
+
const scheduler = startConsolidationScheduler({
|
|
13
|
+
dbPath,
|
|
14
|
+
intervalMs: 6 * 60 * 60 * 1000,
|
|
15
|
+
runOnStart: true,
|
|
16
|
+
onRun: (r) => {
|
|
17
|
+
logger.info({ event: 'consolidation', ...r }, r.summary);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
// Stop the scheduler gracefully on process exit
|
|
21
|
+
const shutdown = () => {
|
|
22
|
+
scheduler.stop();
|
|
23
|
+
};
|
|
24
|
+
process.once('SIGINT', shutdown);
|
|
25
|
+
process.once('SIGTERM', shutdown);
|
|
26
|
+
}
|
|
27
|
+
await app.listen({ host: config.server.host, port: config.server.port });
|
|
28
|
+
logger.info({ host: config.server.host, port: config.server.port }, 'memweave server listening');
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import Fastify from 'fastify';
|
|
2
|
+
import fastifyStatic from '@fastify/static';
|
|
3
|
+
import { ZodError } from 'zod';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { openDatabase } from '../db/database.js';
|
|
8
|
+
import { registerInjectionRoute } from '../rest/routes/injection.js';
|
|
9
|
+
import { registerMemoriesRoute } from '../rest/routes/memories.js';
|
|
10
|
+
import { registerStatsRoute } from '../rest/routes/stats.js';
|
|
11
|
+
import { registerSessionsRoute } from '../rest/routes/sessions.js';
|
|
12
|
+
import { registerObservationsRoute } from '../rest/routes/observations.js';
|
|
13
|
+
import { registerConsolidationRoute } from '../rest/routes/consolidation.js';
|
|
14
|
+
import { registerDevicesRoute } from '../rest/routes/devices.js';
|
|
15
|
+
import { registerSettingsRoute } from '../rest/routes/settings.js';
|
|
16
|
+
export async function createHttpServer(options) {
|
|
17
|
+
const app = Fastify({ logger: false });
|
|
18
|
+
const db = openDatabase(options.dbPath);
|
|
19
|
+
db.prepare('INSERT OR IGNORE INTO tenants (id, name, api_key_hash, created_at) VALUES (?, ?, ?, ?)')
|
|
20
|
+
.run('tenant_default', 'default', 'dev-local-key', Date.now());
|
|
21
|
+
app.addHook('onClose', async () => db.close());
|
|
22
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
23
|
+
if (error instanceof ZodError) {
|
|
24
|
+
return reply.code(400).send({
|
|
25
|
+
error: {
|
|
26
|
+
code: 'VALIDATION_ERROR',
|
|
27
|
+
message: 'Request validation failed',
|
|
28
|
+
details: error.issues
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return reply.code(500).send({
|
|
33
|
+
error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
app.get('/api/v1/health', async () => ({ ok: true, service: 'memweave-server' }));
|
|
37
|
+
registerMemoriesRoute(app, options.dbPath);
|
|
38
|
+
registerInjectionRoute(app, options.dbPath);
|
|
39
|
+
registerStatsRoute(app, options.dbPath);
|
|
40
|
+
registerSessionsRoute(app, options.dbPath);
|
|
41
|
+
registerObservationsRoute(app, options.dbPath);
|
|
42
|
+
registerConsolidationRoute(app, options.dbPath);
|
|
43
|
+
registerDevicesRoute(app, options.dbPath);
|
|
44
|
+
registerSettingsRoute(app, options.configPath);
|
|
45
|
+
// Serve the Web UI (SPA) at /ui/*
|
|
46
|
+
// web/ builds to ../dist/web/ relative to the repo root.
|
|
47
|
+
// We resolve from this file's location so the path is stable regardless of cwd.
|
|
48
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
49
|
+
// src/server → ../../ → repo root → dist/web
|
|
50
|
+
const webDist = resolve(here, '../../dist/web');
|
|
51
|
+
if (existsSync(webDist)) {
|
|
52
|
+
await app.register(fastifyStatic, {
|
|
53
|
+
root: webDist,
|
|
54
|
+
prefix: '/ui/',
|
|
55
|
+
decorateReply: true
|
|
56
|
+
});
|
|
57
|
+
// SPA fallback: any GET under /ui/* that didn't match a file → index.html
|
|
58
|
+
app.setNotFoundHandler((request, reply) => {
|
|
59
|
+
if (request.url.startsWith('/ui/') && request.method === 'GET') {
|
|
60
|
+
return reply.sendFile('index.html');
|
|
61
|
+
}
|
|
62
|
+
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'No route' } });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
// Dev mode: tell the user how to build the SPA.
|
|
67
|
+
app.get('/ui/*', async (_req, reply) => {
|
|
68
|
+
return reply.code(503).send({
|
|
69
|
+
error: {
|
|
70
|
+
code: 'UI_NOT_BUILT',
|
|
71
|
+
message: 'Run `npm run web:build` (or `npm run web:dev` on :5173) to serve the SPA.'
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return app;
|
|
77
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared structured logger. Built on pino (already a dependency). The
|
|
3
|
+
* Fastify server has its own pino instance internally (`logger: false`
|
|
4
|
+
* in `http.ts` disables it for now; can be enabled later). This module
|
|
5
|
+
* is for *operational* logging from CLI, scheduler, workers, and the
|
|
6
|
+
* OpenCode plugin.
|
|
7
|
+
*
|
|
8
|
+
* Why pino and not console.*?
|
|
9
|
+
* - Structured (JSON) output — operators can pipe to jq / log aggregators
|
|
10
|
+
* - Level filtering via LOG_LEVEL env (default: info)
|
|
11
|
+
* - Child loggers for per-tenant / per-run context
|
|
12
|
+
* - Benchmarks ~5x faster than console.* under load
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* import { logger } from './logger.js';
|
|
16
|
+
* logger.info({ memoryId, action: 'reinforced' }, 'memory reinforced');
|
|
17
|
+
* logger.warn({ err }, 'consolidation failed');
|
|
18
|
+
*/
|
|
19
|
+
import pino from 'pino';
|
|
20
|
+
const LOG_LEVEL = process.env.LOG_LEVEL ?? 'info';
|
|
21
|
+
const options = {
|
|
22
|
+
level: LOG_LEVEL,
|
|
23
|
+
// Pretty-print only in dev; in production, leave as JSON for log shippers.
|
|
24
|
+
// We avoid pino-pretty as a hard dep to keep install lean.
|
|
25
|
+
base: { service: 'memweave' },
|
|
26
|
+
timestamp: pino.stdTimeFunctions.isoTime
|
|
27
|
+
};
|
|
28
|
+
export const logger = pino(options);
|
|
29
|
+
/**
|
|
30
|
+
* Create a child logger with a fixed context (e.g., tenantId, runId).
|
|
31
|
+
* Use this for sub-operations that should share context across multiple
|
|
32
|
+
* log lines.
|
|
33
|
+
*/
|
|
34
|
+
export function childLogger(bindings) {
|
|
35
|
+
return logger.child(bindings);
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process token-bucket rate limiter. One bucket per (key, window) pair.
|
|
3
|
+
* No external dependency; suitable for single-node MemWeave deployments.
|
|
4
|
+
*
|
|
5
|
+
* If you ever scale to multi-node, swap this for a Redis-backed limiter.
|
|
6
|
+
* The interface (`RateLimiter.consume(key)` returning boolean) is the
|
|
7
|
+
* only thing callers depend on.
|
|
8
|
+
*/
|
|
9
|
+
export class RateLimiter {
|
|
10
|
+
buckets = new Map();
|
|
11
|
+
capacity;
|
|
12
|
+
refillPerMs;
|
|
13
|
+
sweepIntervalMs;
|
|
14
|
+
sweepTimer = null;
|
|
15
|
+
constructor(config) {
|
|
16
|
+
this.capacity = config.capacity;
|
|
17
|
+
this.refillPerMs = config.refillPerSecond / 1000;
|
|
18
|
+
this.sweepIntervalMs = config.sweepIntervalMs ?? 60_000;
|
|
19
|
+
// Periodic GC of buckets that have been idle longer than the time to
|
|
20
|
+
// fully refill. Prevents the map from growing unbounded over the
|
|
21
|
+
// process lifetime in long-running deployments.
|
|
22
|
+
this.sweepTimer = setInterval(() => this.sweep(), this.sweepIntervalMs);
|
|
23
|
+
if (typeof this.sweepTimer.unref === 'function')
|
|
24
|
+
this.sweepTimer.unref();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Attempt to consume one token. Returns whether the call is allowed.
|
|
28
|
+
*/
|
|
29
|
+
consume(key, now = Date.now()) {
|
|
30
|
+
let bucket = this.buckets.get(key);
|
|
31
|
+
if (!bucket) {
|
|
32
|
+
bucket = { tokens: this.capacity, lastRefillMs: now, lastSeenMs: now };
|
|
33
|
+
this.buckets.set(key, bucket);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Refill: add (elapsed * refillPerMs), capped at capacity.
|
|
37
|
+
const elapsed = Math.max(0, now - bucket.lastRefillMs);
|
|
38
|
+
const refilled = elapsed * this.refillPerMs;
|
|
39
|
+
if (refilled > 0) {
|
|
40
|
+
bucket.tokens = Math.min(this.capacity, bucket.tokens + refilled);
|
|
41
|
+
bucket.lastRefillMs = now;
|
|
42
|
+
}
|
|
43
|
+
bucket.lastSeenMs = now;
|
|
44
|
+
}
|
|
45
|
+
if (bucket.tokens >= 1) {
|
|
46
|
+
bucket.tokens -= 1;
|
|
47
|
+
return { allowed: true, remaining: Math.floor(bucket.tokens), retryAfterSec: 0 };
|
|
48
|
+
}
|
|
49
|
+
// Compute how long until we have >= 1 token.
|
|
50
|
+
const deficit = 1 - bucket.tokens;
|
|
51
|
+
const retryAfterSec = Math.max(1, Math.ceil(deficit / this.refillPerMs / 1000));
|
|
52
|
+
return { allowed: false, remaining: 0, retryAfterSec };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* For tests / observability. Returns the number of live buckets.
|
|
56
|
+
*/
|
|
57
|
+
size() {
|
|
58
|
+
return this.buckets.size;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Stop the sweep timer. Call before shutdown to allow the process to exit
|
|
62
|
+
* cleanly.
|
|
63
|
+
*/
|
|
64
|
+
dispose() {
|
|
65
|
+
if (this.sweepTimer) {
|
|
66
|
+
clearInterval(this.sweepTimer);
|
|
67
|
+
this.sweepTimer = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
sweep() {
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
// A bucket that hasn't been touched for the time-to-full-refill is
|
|
73
|
+
// indistinguishable from a fresh one — drop it.
|
|
74
|
+
const fullRefillMs = this.capacity / this.refillPerMs;
|
|
75
|
+
for (const [key, bucket] of this.buckets) {
|
|
76
|
+
if (now - bucket.lastSeenMs > fullRefillMs * 2) {
|
|
77
|
+
this.buckets.delete(key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { openDatabase } from '../db/database.js';
|
|
2
|
+
import { runConsolidation } from '../workers/consolidator.js';
|
|
3
|
+
import { ConsolidationRunRepo } from '../db/repositories/consolidation-run-repo.js';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Starts a background consolidation loop. Returns a handle with stop() and runNow().
|
|
7
|
+
*
|
|
8
|
+
* Default interval: 6 hours. Default: does NOT run on start (set `runOnStart: true` to run once immediately).
|
|
9
|
+
*
|
|
10
|
+
* Each run is persisted to the `consolidation_runs` table so the Web UI's
|
|
11
|
+
* Sleep page can render a history.
|
|
12
|
+
*/
|
|
13
|
+
export function startConsolidationScheduler(options) {
|
|
14
|
+
const interval = options.intervalMs ?? 6 * 60 * 60 * 1000;
|
|
15
|
+
const tenantId = options.tenantId ?? 'tenant_default';
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let timer = null;
|
|
18
|
+
async function runOnce() {
|
|
19
|
+
const startedAt = Date.now();
|
|
20
|
+
const db = openDatabase(options.dbPath);
|
|
21
|
+
try {
|
|
22
|
+
const result = runConsolidation(db, tenantId);
|
|
23
|
+
const endedAt = Date.now();
|
|
24
|
+
// Persist the run for the Sleep UI page. Swallow errors so the scheduler
|
|
25
|
+
// loop stays alive even if the runs table is somehow missing.
|
|
26
|
+
try {
|
|
27
|
+
const runRepo = new ConsolidationRunRepo(db);
|
|
28
|
+
runRepo.record({
|
|
29
|
+
tenantId,
|
|
30
|
+
startedAt,
|
|
31
|
+
endedAt,
|
|
32
|
+
promoted: result.promotedIds,
|
|
33
|
+
evicted: result.evictedIds,
|
|
34
|
+
merged: result.mergedPairs,
|
|
35
|
+
edgesCreated: 0,
|
|
36
|
+
contradictionFound: 0,
|
|
37
|
+
dryRun: false,
|
|
38
|
+
summary: result.summary
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
logger.warn({ err: err.message }, 'failed to record consolidation run');
|
|
43
|
+
}
|
|
44
|
+
const payload = {
|
|
45
|
+
promoted: result.promoted,
|
|
46
|
+
evicted: result.evicted,
|
|
47
|
+
summary: result.summary,
|
|
48
|
+
timestamp: endedAt
|
|
49
|
+
};
|
|
50
|
+
options.onRun?.(payload);
|
|
51
|
+
return payload;
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
db.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const runNow = runOnce;
|
|
58
|
+
function schedule() {
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
timer = setTimeout(async () => {
|
|
62
|
+
try {
|
|
63
|
+
await runOnce();
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Swallow errors in background; the next interval will try again.
|
|
67
|
+
// We intentionally do not throw here to keep the scheduler alive.
|
|
68
|
+
logger.error({ err }, 'consolidation run failed');
|
|
69
|
+
}
|
|
70
|
+
schedule();
|
|
71
|
+
}, interval);
|
|
72
|
+
}
|
|
73
|
+
// Listen for abort
|
|
74
|
+
if (options.signal) {
|
|
75
|
+
if (options.signal.aborted) {
|
|
76
|
+
stopped = true;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
options.signal.addEventListener('abort', () => {
|
|
80
|
+
stopped = true;
|
|
81
|
+
if (timer)
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (options.runOnStart) {
|
|
87
|
+
void runOnce();
|
|
88
|
+
}
|
|
89
|
+
schedule();
|
|
90
|
+
const handle = {
|
|
91
|
+
stop() {
|
|
92
|
+
stopped = true;
|
|
93
|
+
if (timer)
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
},
|
|
96
|
+
runNow
|
|
97
|
+
};
|
|
98
|
+
return handle;
|
|
99
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { EDGE_EXTRACT_SYSTEM, buildEdgeExtractPrompt } from '../prompts/edge-extract.js';
|
|
2
|
+
import { logger } from '../server/logger.js';
|
|
3
|
+
function isValidEdge(candidate) {
|
|
4
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
5
|
+
return false;
|
|
6
|
+
const c = candidate;
|
|
7
|
+
return (typeof c.targetMemoryId === 'string' &&
|
|
8
|
+
typeof c.type === 'string' &&
|
|
9
|
+
typeof c.reason === 'string' &&
|
|
10
|
+
typeof c.confidence === 'number');
|
|
11
|
+
}
|
|
12
|
+
export async function extractEdges(provider, newMemory, existingMemories) {
|
|
13
|
+
if (existingMemories.length === 0)
|
|
14
|
+
return [];
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
const prompt = buildEdgeExtractPrompt(newMemory, existingMemories);
|
|
18
|
+
raw = await provider.call(EDGE_EXTRACT_SYSTEM, prompt);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
logger.warn({ err }, 'provider call failed');
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
if (!raw.trim())
|
|
25
|
+
return [];
|
|
26
|
+
let parsed;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(raw);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
logger.warn('failed to parse JSON response');
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(parsed))
|
|
35
|
+
return [];
|
|
36
|
+
const existingIds = new Set(existingMemories.map(m => m.id));
|
|
37
|
+
return parsed
|
|
38
|
+
.filter(isValidEdge)
|
|
39
|
+
.filter(e => e.confidence >= 0.6)
|
|
40
|
+
.filter(e => existingIds.has(e.targetMemoryId));
|
|
41
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { COMPRESSION_SYSTEM, buildCompressionPrompt } from '../prompts/compression.js';
|
|
2
|
+
export async function compressObservation(provider, input) {
|
|
3
|
+
const prompt = buildCompressionPrompt(input);
|
|
4
|
+
const raw = await provider.call(COMPRESSION_SYSTEM, prompt);
|
|
5
|
+
if (!raw.trim())
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(raw);
|
|
9
|
+
return parsed;
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|