@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,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
+ }