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