@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,185 @@
|
|
|
1
|
+
export const SCHEMA_SQL = `
|
|
2
|
+
PRAGMA journal_mode = WAL;
|
|
3
|
+
PRAGMA synchronous = NORMAL;
|
|
4
|
+
PRAGMA foreign_keys = ON;
|
|
5
|
+
PRAGMA busy_timeout = 5000;
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS tenants (
|
|
8
|
+
id TEXT PRIMARY KEY,
|
|
9
|
+
name TEXT NOT NULL,
|
|
10
|
+
api_key_hash TEXT NOT NULL,
|
|
11
|
+
created_at INTEGER NOT NULL,
|
|
12
|
+
settings_json TEXT NOT NULL DEFAULT '{}'
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE IF NOT EXISTS devices (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
tenant_id TEXT NOT NULL,
|
|
18
|
+
name TEXT NOT NULL,
|
|
19
|
+
type TEXT NOT NULL,
|
|
20
|
+
api_key_hash TEXT NOT NULL,
|
|
21
|
+
last_seen_at INTEGER,
|
|
22
|
+
registered_at INTEGER NOT NULL,
|
|
23
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
tenant_id TEXT NOT NULL,
|
|
29
|
+
device_id TEXT,
|
|
30
|
+
source TEXT NOT NULL,
|
|
31
|
+
title TEXT NOT NULL,
|
|
32
|
+
summary TEXT,
|
|
33
|
+
started_at INTEGER NOT NULL,
|
|
34
|
+
ended_at INTEGER,
|
|
35
|
+
observation_count INTEGER NOT NULL DEFAULT 0,
|
|
36
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
|
37
|
+
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE SET NULL
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
tenant_id TEXT NOT NULL,
|
|
43
|
+
tier TEXT NOT NULL,
|
|
44
|
+
type TEXT NOT NULL,
|
|
45
|
+
title TEXT NOT NULL,
|
|
46
|
+
content TEXT NOT NULL,
|
|
47
|
+
summary TEXT NOT NULL,
|
|
48
|
+
concepts_json TEXT NOT NULL DEFAULT '[]',
|
|
49
|
+
concepts_text TEXT NOT NULL DEFAULT '',
|
|
50
|
+
files_json TEXT NOT NULL DEFAULT '[]',
|
|
51
|
+
importance INTEGER NOT NULL,
|
|
52
|
+
confidence REAL NOT NULL,
|
|
53
|
+
strength REAL NOT NULL,
|
|
54
|
+
source TEXT NOT NULL,
|
|
55
|
+
scope_level TEXT NOT NULL,
|
|
56
|
+
source_client TEXT,
|
|
57
|
+
source_device_id TEXT,
|
|
58
|
+
source_session_id TEXT,
|
|
59
|
+
tau REAL NOT NULL,
|
|
60
|
+
access_count INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
last_accessed_at INTEGER,
|
|
62
|
+
last_reinforced_at INTEGER,
|
|
63
|
+
last_decay_at INTEGER,
|
|
64
|
+
reinforcement_score REAL NOT NULL DEFAULT 0,
|
|
65
|
+
promoted_at INTEGER,
|
|
66
|
+
created_at INTEGER NOT NULL,
|
|
67
|
+
updated_at INTEGER NOT NULL,
|
|
68
|
+
deleted_at INTEGER,
|
|
69
|
+
eviction_reason TEXT,
|
|
70
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS memory_scopes (
|
|
74
|
+
memory_id TEXT NOT NULL,
|
|
75
|
+
tenant_id TEXT NOT NULL,
|
|
76
|
+
key TEXT NOT NULL,
|
|
77
|
+
value TEXT NOT NULL,
|
|
78
|
+
created_at INTEGER NOT NULL,
|
|
79
|
+
PRIMARY KEY(memory_id, key, value),
|
|
80
|
+
FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE,
|
|
81
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
85
|
+
id TEXT PRIMARY KEY,
|
|
86
|
+
tenant_id TEXT NOT NULL,
|
|
87
|
+
from_memory_id TEXT NOT NULL,
|
|
88
|
+
to_memory_id TEXT NOT NULL,
|
|
89
|
+
type TEXT NOT NULL,
|
|
90
|
+
strength REAL NOT NULL,
|
|
91
|
+
reason TEXT NOT NULL,
|
|
92
|
+
created_at INTEGER NOT NULL,
|
|
93
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
|
94
|
+
FOREIGN KEY (from_memory_id) REFERENCES memories(id) ON DELETE CASCADE,
|
|
95
|
+
FOREIGN KEY (to_memory_id) REFERENCES memories(id) ON DELETE CASCADE
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS observations (
|
|
99
|
+
id TEXT PRIMARY KEY,
|
|
100
|
+
session_id TEXT NOT NULL,
|
|
101
|
+
tenant_id TEXT NOT NULL,
|
|
102
|
+
hook_type TEXT NOT NULL,
|
|
103
|
+
tool_name TEXT,
|
|
104
|
+
tool_input TEXT,
|
|
105
|
+
tool_output TEXT,
|
|
106
|
+
timestamp INTEGER NOT NULL,
|
|
107
|
+
memory_id TEXT,
|
|
108
|
+
processed INTEGER NOT NULL DEFAULT 0,
|
|
109
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
110
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
|
111
|
+
FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS access_logs (
|
|
115
|
+
id TEXT PRIMARY KEY,
|
|
116
|
+
tenant_id TEXT NOT NULL,
|
|
117
|
+
memory_id TEXT NOT NULL,
|
|
118
|
+
session_id TEXT,
|
|
119
|
+
device_id TEXT,
|
|
120
|
+
source TEXT NOT NULL,
|
|
121
|
+
query TEXT,
|
|
122
|
+
rank INTEGER,
|
|
123
|
+
score REAL,
|
|
124
|
+
used_in_context INTEGER NOT NULL DEFAULT 0,
|
|
125
|
+
accessed_at INTEGER NOT NULL,
|
|
126
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
|
127
|
+
FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
131
|
+
title,
|
|
132
|
+
summary,
|
|
133
|
+
content,
|
|
134
|
+
concepts_text,
|
|
135
|
+
content='memories',
|
|
136
|
+
content_rowid='rowid'
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
|
|
140
|
+
INSERT INTO memory_fts(rowid, title, summary, content, concepts_text)
|
|
141
|
+
VALUES (new.rowid, new.title, new.summary, new.content, new.concepts_text);
|
|
142
|
+
END;
|
|
143
|
+
|
|
144
|
+
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
|
|
145
|
+
INSERT INTO memory_fts(memory_fts, rowid, title, summary, content, concepts_text)
|
|
146
|
+
VALUES ('delete', old.rowid, old.title, old.summary, old.content, old.concepts_text);
|
|
147
|
+
END;
|
|
148
|
+
|
|
149
|
+
CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
|
|
150
|
+
INSERT INTO memory_fts(memory_fts, rowid, title, summary, content, concepts_text)
|
|
151
|
+
VALUES ('delete', old.rowid, old.title, old.summary, old.content, old.concepts_text);
|
|
152
|
+
INSERT INTO memory_fts(rowid, title, summary, content, concepts_text)
|
|
153
|
+
VALUES (new.rowid, new.title, new.summary, new.content, new.concepts_text);
|
|
154
|
+
END;
|
|
155
|
+
|
|
156
|
+
CREATE INDEX IF NOT EXISTS idx_memories_tenant_tier_strength ON memories(tenant_id, tier, strength DESC);
|
|
157
|
+
CREATE INDEX IF NOT EXISTS idx_memories_tenant_type_created ON memories(tenant_id, type, created_at DESC);
|
|
158
|
+
CREATE INDEX IF NOT EXISTS idx_memory_scopes_tenant_key_value ON memory_scopes(tenant_id, key, value);
|
|
159
|
+
CREATE INDEX IF NOT EXISTS idx_memory_scopes_memory_id ON memory_scopes(memory_id);
|
|
160
|
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_memory_id);
|
|
161
|
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_memory_id);
|
|
162
|
+
CREATE INDEX IF NOT EXISTS idx_access_logs_memory_time ON access_logs(memory_id, accessed_at DESC);
|
|
163
|
+
CREATE INDEX IF NOT EXISTS idx_access_logs_tenant_time ON access_logs(tenant_id, accessed_at DESC);
|
|
164
|
+
|
|
165
|
+
CREATE TABLE IF NOT EXISTS consolidation_runs (
|
|
166
|
+
id TEXT PRIMARY KEY,
|
|
167
|
+
tenant_id TEXT NOT NULL,
|
|
168
|
+
started_at INTEGER NOT NULL,
|
|
169
|
+
ended_at INTEGER NOT NULL,
|
|
170
|
+
promoted_count INTEGER NOT NULL DEFAULT 0,
|
|
171
|
+
evicted_count INTEGER NOT NULL DEFAULT 0,
|
|
172
|
+
merged_count INTEGER NOT NULL DEFAULT 0,
|
|
173
|
+
edges_created_count INTEGER NOT NULL DEFAULT 0,
|
|
174
|
+
contradiction_found_count INTEGER NOT NULL DEFAULT 0,
|
|
175
|
+
promoted_ids TEXT NOT NULL DEFAULT '[]',
|
|
176
|
+
evicted_ids TEXT NOT NULL DEFAULT '[]',
|
|
177
|
+
merged_pairs TEXT NOT NULL DEFAULT '[]',
|
|
178
|
+
dry_run INTEGER NOT NULL DEFAULT 0,
|
|
179
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
180
|
+
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
CREATE INDEX IF NOT EXISTS idx_consolidation_runs_tenant_time
|
|
184
|
+
ON consolidation_runs(tenant_id, started_at DESC);
|
|
185
|
+
`;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export function createContentHash(phase, memoryIds) {
|
|
3
|
+
const sorted = [...memoryIds].sort();
|
|
4
|
+
const input = `${phase}:${sorted.join(',')}`;
|
|
5
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
6
|
+
}
|
|
7
|
+
export function buildStablePack(memories, options) {
|
|
8
|
+
const filtered = memories.filter(m => m.tier === 'long' || (m.tier === 'medium' && m.strength >= 0.4));
|
|
9
|
+
const sorted = [...filtered].sort((a, b) => {
|
|
10
|
+
if (a.tier === 'long' && b.tier !== 'long')
|
|
11
|
+
return -1;
|
|
12
|
+
if (a.tier !== 'long' && b.tier === 'long')
|
|
13
|
+
return 1;
|
|
14
|
+
return b.strength * b.importance - a.strength * a.importance;
|
|
15
|
+
});
|
|
16
|
+
return finalizePack(sorted, options);
|
|
17
|
+
}
|
|
18
|
+
export function buildDeltaPack(candidates, options) {
|
|
19
|
+
const filtered = candidates.filter(c => !options.alreadyInjected.has(c.id));
|
|
20
|
+
const sorted = [...filtered].sort((a, b) => b.strength * b.importance - a.strength * a.importance);
|
|
21
|
+
return finalizePack(sorted, options);
|
|
22
|
+
}
|
|
23
|
+
function finalizePack(memories, options) {
|
|
24
|
+
const selected = [];
|
|
25
|
+
let tokens = 0;
|
|
26
|
+
for (const m of memories) {
|
|
27
|
+
const cost = Math.max(20, Math.ceil((m.title.length + m.summary.length) / 3));
|
|
28
|
+
if (tokens + cost > options.budget)
|
|
29
|
+
break;
|
|
30
|
+
selected.push(m);
|
|
31
|
+
tokens += cost;
|
|
32
|
+
}
|
|
33
|
+
const memoryIds = selected.map(m => m.id);
|
|
34
|
+
return {
|
|
35
|
+
memoryIds,
|
|
36
|
+
contentHash: createContentHash('session_start', memoryIds),
|
|
37
|
+
estimatedTokens: tokens
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function formatMemoriesAsXml(phase, memories) {
|
|
2
|
+
const sorted = [...memories].sort((a, b) => {
|
|
3
|
+
const tierOrder = { long: 0, medium: 1, short: 2 };
|
|
4
|
+
const aOrder = tierOrder[a.tier] ?? 2;
|
|
5
|
+
const bOrder = tierOrder[b.tier] ?? 2;
|
|
6
|
+
if (aOrder !== bOrder)
|
|
7
|
+
return aOrder - bOrder;
|
|
8
|
+
return b.strength * b.importance - a.strength * a.importance;
|
|
9
|
+
});
|
|
10
|
+
const header = `<memory-context phase="${escapeAttr(phase)}" count="${sorted.length}">`;
|
|
11
|
+
const items = sorted.map(m => ` <memory id="${escapeAttr(m.id)}" type="${escapeAttr(m.type)}" tier="${escapeAttr(m.tier)}" strength="${m.strength.toFixed(2)}" importance="${m.importance}">\n` +
|
|
12
|
+
` <title>${escapeText(m.title)}</title>\n` +
|
|
13
|
+
` <summary>${escapeText(m.summary)}</summary>\n` +
|
|
14
|
+
` </memory>`);
|
|
15
|
+
const footer = `</memory-context>`;
|
|
16
|
+
return [header, ...items, footer].join('\n');
|
|
17
|
+
}
|
|
18
|
+
function escapeAttr(s) {
|
|
19
|
+
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
20
|
+
}
|
|
21
|
+
function escapeText(s) {
|
|
22
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const COMPRESSION_SYSTEM = `You are a memory compression engine for an AI coding agent. Your job is to extract the essential information from a tool usage observation and compress it into structured data.
|
|
2
|
+
|
|
3
|
+
Output EXACTLY this JSON with no additional text:
|
|
4
|
+
{
|
|
5
|
+
"shouldCreateMemory": true,
|
|
6
|
+
"type": "fact|decision|preference|event|project_context|lesson|code_pattern|bug|workflow",
|
|
7
|
+
"title": "Short descriptive title (max 80 chars)",
|
|
8
|
+
"summary": "One-line summary (max 200 chars)",
|
|
9
|
+
"content": "2-3 sentence narrative of what happened and why it matters",
|
|
10
|
+
"concepts": ["technical concept or pattern"],
|
|
11
|
+
"files": ["path/to/file"],
|
|
12
|
+
"importance": 5,
|
|
13
|
+
"confidence": 0.8,
|
|
14
|
+
"scopeLevel": "project",
|
|
15
|
+
"scopes": [
|
|
16
|
+
{ "key": "project", "value": "project-name" },
|
|
17
|
+
{ "key": "domain", "value": "domain-name" },
|
|
18
|
+
{ "key": "topic", "value": "topic-name" }
|
|
19
|
+
],
|
|
20
|
+
"candidateEdges": [
|
|
21
|
+
{ "targetHint": "related memory title or concept", "type": "related_to", "reason": "why related", "confidence": 0.7 }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
- Be concise but preserve ALL technically relevant details.
|
|
27
|
+
- File paths must be exact.
|
|
28
|
+
- Importance: 1-3 for routine reads, 4-6 for edits/commands, 7-9 for architectural decisions, 10 for breaking changes.
|
|
29
|
+
- Concepts should be reusable search terms.
|
|
30
|
+
- Strip any secrets, tokens, or credentials from the output.
|
|
31
|
+
- If the observation is not worth remembering, set shouldCreateMemory to false.`;
|
|
32
|
+
export function buildCompressionPrompt(observation) {
|
|
33
|
+
const parts = [`Timestamp: ${observation.timestamp}`, `Hook: ${observation.hookType}`];
|
|
34
|
+
if (observation.toolName)
|
|
35
|
+
parts.push(`Tool: ${observation.toolName}`);
|
|
36
|
+
if (observation.toolInput)
|
|
37
|
+
parts.push(`Input:\n${observation.toolInput.slice(0, 4000)}`);
|
|
38
|
+
if (observation.toolOutput)
|
|
39
|
+
parts.push(`Output:\n${observation.toolOutput.slice(0, 8000)}`);
|
|
40
|
+
if (observation.userPrompt)
|
|
41
|
+
parts.push(`User prompt:\n${observation.userPrompt.slice(0, 2000)}`);
|
|
42
|
+
return parts.join('\n\n');
|
|
43
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const EDGE_EXTRACT_SYSTEM = `You are a relationship extraction engine for a memory graph. Given a new memory and a list of existing memories, identify relationships between them.
|
|
2
|
+
|
|
3
|
+
Output EXACTLY this JSON array with no additional text:
|
|
4
|
+
[
|
|
5
|
+
{
|
|
6
|
+
"targetMemoryId": "existing_memory_id",
|
|
7
|
+
"type": "causes|enables|contradicts|supersedes|references|related_to|before|after|duplicates|refines",
|
|
8
|
+
"reason": "Why this relationship exists",
|
|
9
|
+
"confidence": 0.85
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
- Only output relationships with confidence >= 0.6.
|
|
15
|
+
- If no relationship exists, output an empty array [].
|
|
16
|
+
- Be conservative: only create edges when there is a clear, meaningful relationship.`;
|
|
17
|
+
export function buildEdgeExtractPrompt(newMemory, existingMemories) {
|
|
18
|
+
const newSection = `New memory:\nTitle: ${newMemory.title}\nContent: ${newMemory.content}\nConcepts: ${newMemory.concepts.join(', ')}`;
|
|
19
|
+
const existingSection = existingMemories.map(m => `[${m.id}] Title: ${m.title}\nSummary: ${m.summary}\nConcepts: ${m.concepts.join(', ')}`).join('\n\n');
|
|
20
|
+
return `${newSection}\n\nExisting memories:\n${existingSection}`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const VALUE_GATE_SYSTEM = `You are a value gate for an AI coding agent's memory system. Given a raw observation, determine whether it contains information worth remembering.
|
|
2
|
+
|
|
3
|
+
Output EXACTLY this JSON with no additional text:
|
|
4
|
+
{
|
|
5
|
+
"shouldCreateMemory": true,
|
|
6
|
+
"reason": "Why this is worth remembering",
|
|
7
|
+
"suggestedTypes": ["decision"],
|
|
8
|
+
"priority": "high"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Rules:
|
|
12
|
+
- shouldCreateMemory = true for: explicit user requests to remember, architectural decisions, bug root causes, user preferences, project conventions, workflow patterns.
|
|
13
|
+
- shouldCreateMemory = false for: routine file reads, simple grep searches, repeated successful commands with no new information, transient state.
|
|
14
|
+
- priority: "high" for decisions/bugs/preferences, "medium" for project context/lessons, "low" for uncertain cases.
|
|
15
|
+
- suggestedTypes should be the most likely MemoryType(s) from: fact, decision, preference, event, project_context, lesson, code_pattern, bug, workflow.`;
|
|
16
|
+
export function buildValueGatePrompt(observation) {
|
|
17
|
+
const parts = [`Hook: ${observation.hookType}`];
|
|
18
|
+
if (observation.toolName)
|
|
19
|
+
parts.push(`Tool: ${observation.toolName}`);
|
|
20
|
+
if (observation.toolInput)
|
|
21
|
+
parts.push(`Input:\n${observation.toolInput.slice(0, 2000)}`);
|
|
22
|
+
if (observation.toolOutput)
|
|
23
|
+
parts.push(`Output:\n${observation.toolOutput.slice(0, 4000)}`);
|
|
24
|
+
if (observation.userPrompt)
|
|
25
|
+
parts.push(`User prompt:\n${observation.userPrompt.slice(0, 1000)}`);
|
|
26
|
+
return parts.join('\n\n');
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding provider abstraction (matches design spec §9.5).
|
|
3
|
+
*
|
|
4
|
+
* Implementations:
|
|
5
|
+
* - `NoopEmbeddingProvider`: returns a deterministic dummy vector (zeros or hash-derived)
|
|
6
|
+
* - `OpenaiCompatibleEmbeddingProvider`: POSTs to /v1/embeddings
|
|
7
|
+
* - `LocalXenovaEmbeddingProvider`: uses @xenova/transformers (not bundled in v1;
|
|
8
|
+
* this is a stub that explains the install path)
|
|
9
|
+
*/
|
|
10
|
+
import { NoopEmbeddingProvider } from './noop.js';
|
|
11
|
+
import { OpenaiCompatibleEmbeddingProvider } from './openai-compatible.js';
|
|
12
|
+
import { LocalXenovaEmbeddingProvider } from './local-xenova.js';
|
|
13
|
+
export { NoopEmbeddingProvider, OpenaiCompatibleEmbeddingProvider, LocalXenovaEmbeddingProvider };
|
|
14
|
+
export function createEmbeddingProvider(options) {
|
|
15
|
+
switch (options.kind) {
|
|
16
|
+
case 'openai-compatible':
|
|
17
|
+
return new OpenaiCompatibleEmbeddingProvider({
|
|
18
|
+
baseUrl: options.baseUrl ?? 'https://api.openai.com/v1',
|
|
19
|
+
apiKey: options.apiKey ?? '',
|
|
20
|
+
model: options.model ?? 'text-embedding-3-small',
|
|
21
|
+
dimensions: options.dimensions ?? 1536,
|
|
22
|
+
timeoutMs: options.timeoutMs ?? 30000
|
|
23
|
+
});
|
|
24
|
+
case 'local-xenova':
|
|
25
|
+
return new LocalXenovaEmbeddingProvider({
|
|
26
|
+
model: options.model ?? 'Xenova/nomic-embed-text-v1',
|
|
27
|
+
dimensions: options.dimensions ?? 768
|
|
28
|
+
});
|
|
29
|
+
case 'noop':
|
|
30
|
+
default:
|
|
31
|
+
return new NoopEmbeddingProvider({
|
|
32
|
+
dimensions: options.dimensions ?? 768,
|
|
33
|
+
model: options.model ?? 'noop'
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { logger } from '../../server/logger.js';
|
|
3
|
+
/**
|
|
4
|
+
* Local-xenova embedding provider.
|
|
5
|
+
*
|
|
6
|
+
* Uses `@xenova/transformers` (Hugging Face Transformers.js) running in-process.
|
|
7
|
+
* The dependency is **optional** — we dynamically import it on first use. If the
|
|
8
|
+
* package is not installed, this provider falls back to a deterministic noop
|
|
9
|
+
* embedding (with a one-time `console.warn`) so the rest of the system stays
|
|
10
|
+
* functional. The user can opt back in by running:
|
|
11
|
+
*
|
|
12
|
+
* npm install @xenova/transformers
|
|
13
|
+
*
|
|
14
|
+
* Models are loaded lazily on the first `embed()` / `embedBatch()` call and
|
|
15
|
+
* cached for the lifetime of this provider instance. The first call may be slow
|
|
16
|
+
* (~10–60s) while the model weights are fetched from the HF Hub and cached
|
|
17
|
+
* under `node_modules/@xenova/transformers/.cache/`.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalXenovaEmbeddingProvider {
|
|
20
|
+
model;
|
|
21
|
+
dimensions;
|
|
22
|
+
timeoutMs;
|
|
23
|
+
fallbackOnError;
|
|
24
|
+
/**
|
|
25
|
+
* Cached pipeline. `null` until the first embed call. We cache the *promise*
|
|
26
|
+
* (not the resolved value) so concurrent callers share one load.
|
|
27
|
+
*/
|
|
28
|
+
extractorPromise = null;
|
|
29
|
+
/** Set to true after we've warned the user about the missing dep. Avoids spam. */
|
|
30
|
+
warnedMissingDep = false;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.model = options.model;
|
|
33
|
+
this.dimensions = options.dimensions;
|
|
34
|
+
this.timeoutMs = options.timeoutMs ?? 60_000;
|
|
35
|
+
this.fallbackOnError = options.fallbackOnError ?? true;
|
|
36
|
+
}
|
|
37
|
+
async embed(text) {
|
|
38
|
+
const results = await this.embedBatch([text]);
|
|
39
|
+
return results[0];
|
|
40
|
+
}
|
|
41
|
+
async embedBatch(texts) {
|
|
42
|
+
if (texts.length === 0)
|
|
43
|
+
return [];
|
|
44
|
+
let extractor;
|
|
45
|
+
try {
|
|
46
|
+
extractor = await this.getExtractor();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return this.handleError(err, texts);
|
|
50
|
+
}
|
|
51
|
+
// Type-narrowed access to the dynamic module. The library's official types
|
|
52
|
+
// are not always present, so we keep the surface narrow and adapt at runtime.
|
|
53
|
+
const pipeline = extractor;
|
|
54
|
+
try {
|
|
55
|
+
const out = await Promise.race([
|
|
56
|
+
pipeline(texts, { pooling: 'mean', normalize: true }),
|
|
57
|
+
this.timeoutAfter(this.timeoutMs)
|
|
58
|
+
]);
|
|
59
|
+
const flat = this.toNumberArray(out.data);
|
|
60
|
+
// Per-text rows. The xenova pipeline returns [batch, dim] for batches
|
|
61
|
+
// and [dim] for single inputs. Normalize to [text][dim].
|
|
62
|
+
const rows = texts.length === 1
|
|
63
|
+
? [this.coerceLength(flat)]
|
|
64
|
+
: this.splitRows(flat, texts.length);
|
|
65
|
+
return rows.map((row) => this.coerceLength(row));
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return this.handleError(err, texts);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
72
|
+
async getExtractor() {
|
|
73
|
+
if (this.extractorPromise)
|
|
74
|
+
return this.extractorPromise;
|
|
75
|
+
this.extractorPromise = (async () => {
|
|
76
|
+
// Dynamic import: the package is an optional dependency.
|
|
77
|
+
const mod = await import('@xenova/transformers').catch((err) => {
|
|
78
|
+
throw new XenovaMissingError(`@xenova/transformers is not installed. Run \`npm install @xenova/transformers\` to enable local embeddings. (cause: ${err.message})`);
|
|
79
|
+
});
|
|
80
|
+
const pipeline = mod.pipeline;
|
|
81
|
+
if (typeof pipeline !== 'function') {
|
|
82
|
+
throw new XenovaMissingError('@xenova/transformers was loaded but exposes no `pipeline` function — incompatible version?');
|
|
83
|
+
}
|
|
84
|
+
return await pipeline('feature-extraction', this.model);
|
|
85
|
+
})();
|
|
86
|
+
return this.extractorPromise;
|
|
87
|
+
}
|
|
88
|
+
handleError(err, texts) {
|
|
89
|
+
if (err instanceof XenovaMissingError) {
|
|
90
|
+
if (!this.warnedMissingDep) {
|
|
91
|
+
logger.warn({ err: err.message }, 'local-xenova unavailable, falling back to noop embeddings');
|
|
92
|
+
this.warnedMissingDep = true;
|
|
93
|
+
}
|
|
94
|
+
// Reset so a future install can pick it up.
|
|
95
|
+
this.extractorPromise = null;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
logger.warn({ err: err.message }, 'local-xenova embedding failed');
|
|
99
|
+
// Reset so transient errors (network blip, OOM) can recover.
|
|
100
|
+
this.extractorPromise = null;
|
|
101
|
+
}
|
|
102
|
+
if (!this.fallbackOnError)
|
|
103
|
+
throw err;
|
|
104
|
+
return Promise.resolve(texts.map((t) => deriveFallbackVector(t, this.dimensions)));
|
|
105
|
+
}
|
|
106
|
+
toNumberArray(data) {
|
|
107
|
+
const out = new Array(data.length);
|
|
108
|
+
for (let i = 0; i < data.length; i++)
|
|
109
|
+
out[i] = data[i];
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
splitRows(flat, expectedRows) {
|
|
113
|
+
if (flat.length === 0 || expectedRows === 0)
|
|
114
|
+
return [];
|
|
115
|
+
const dim = Math.floor(flat.length / expectedRows);
|
|
116
|
+
const rows = new Array(expectedRows);
|
|
117
|
+
for (let r = 0; r < expectedRows; r++) {
|
|
118
|
+
rows[r] = flat.slice(r * dim, (r + 1) * dim);
|
|
119
|
+
}
|
|
120
|
+
return rows;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Coerce a vector to the configured `dimensions`. If shorter, pad with zeros.
|
|
124
|
+
* If longer, truncate. Matches the graceful-degrade style of vector-search.ts.
|
|
125
|
+
*/
|
|
126
|
+
coerceLength(vec) {
|
|
127
|
+
if (vec.length === this.dimensions)
|
|
128
|
+
return vec;
|
|
129
|
+
if (vec.length > this.dimensions)
|
|
130
|
+
return vec.slice(0, this.dimensions);
|
|
131
|
+
const padded = new Array(this.dimensions).fill(0);
|
|
132
|
+
for (let i = 0; i < vec.length; i++)
|
|
133
|
+
padded[i] = vec[i];
|
|
134
|
+
return padded;
|
|
135
|
+
}
|
|
136
|
+
timeoutAfter(ms) {
|
|
137
|
+
return new Promise((_, reject) => {
|
|
138
|
+
const t = setTimeout(() => reject(new Error(`local-xenova timed out after ${ms}ms`)), ms);
|
|
139
|
+
// Don't keep the event loop alive just for the timeout.
|
|
140
|
+
if (typeof t.unref === 'function')
|
|
141
|
+
t.unref();
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
class XenovaMissingError extends Error {
|
|
146
|
+
name = 'XenovaMissingError';
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Deterministic vector derived from a SHA-256 hash of the input. Used as the
|
|
150
|
+
* fallback when `@xenova/transformers` is unavailable. The algorithm is
|
|
151
|
+
* intentionally identical to `NoopEmbeddingProvider` so the search engine sees
|
|
152
|
+
* a stable embedding space across both code paths.
|
|
153
|
+
*/
|
|
154
|
+
function deriveFallbackVector(text, dimensions) {
|
|
155
|
+
const out = new Array(dimensions);
|
|
156
|
+
let counter = 0;
|
|
157
|
+
let i = 0;
|
|
158
|
+
while (i < dimensions) {
|
|
159
|
+
const hash = createHash('sha256').update(`${text}::${counter++}`).digest();
|
|
160
|
+
for (let b = 0; b < hash.length && i < dimensions; b++, i++) {
|
|
161
|
+
// Map byte to [-1, 1] deterministically.
|
|
162
|
+
out[i] = (hash[b] / 255) * 2 - 1;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic no-op embedding provider. Produces vectors derived from a SHA-256
|
|
4
|
+
* hash of the input text. The output is NOT semantically meaningful — it only
|
|
5
|
+
* exists to make the rest of the pipeline testable when no real embedding
|
|
6
|
+
* service is available. Use the same algorithm for "missing" embeddings so
|
|
7
|
+
* the search engine has something deterministic to compare against.
|
|
8
|
+
*/
|
|
9
|
+
export class NoopEmbeddingProvider {
|
|
10
|
+
dimensions;
|
|
11
|
+
model;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.dimensions = options.dimensions;
|
|
14
|
+
this.model = options.model;
|
|
15
|
+
}
|
|
16
|
+
async embed(text) {
|
|
17
|
+
return deriveDeterministicVector(text, this.dimensions);
|
|
18
|
+
}
|
|
19
|
+
async embedBatch(texts) {
|
|
20
|
+
return texts.map((t) => deriveDeterministicVector(t, this.dimensions));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Deterministically derive a fixed-length vector from a string.
|
|
25
|
+
* Repeats and hashes the text in chunks, then normalizes to [-1, 1].
|
|
26
|
+
* The same input always produces the same vector.
|
|
27
|
+
*/
|
|
28
|
+
function deriveDeterministicVector(text, dimensions) {
|
|
29
|
+
const out = new Array(dimensions);
|
|
30
|
+
let counter = 0;
|
|
31
|
+
let i = 0;
|
|
32
|
+
while (i < dimensions) {
|
|
33
|
+
const hash = createHash('sha256').update(`${text}::${counter++}`).digest();
|
|
34
|
+
for (let b = 0; b < hash.length && i < dimensions; b++, i++) {
|
|
35
|
+
// Map byte to [-1, 1] deterministically.
|
|
36
|
+
out[i] = (hash[b] / 255) * 2 - 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding provider that calls an OpenAI-compatible /v1/embeddings endpoint.
|
|
3
|
+
*/
|
|
4
|
+
export class OpenaiCompatibleEmbeddingProvider {
|
|
5
|
+
model;
|
|
6
|
+
dimensions;
|
|
7
|
+
baseUrl;
|
|
8
|
+
apiKey;
|
|
9
|
+
timeoutMs;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
12
|
+
this.apiKey = options.apiKey;
|
|
13
|
+
this.model = options.model;
|
|
14
|
+
this.dimensions = options.dimensions;
|
|
15
|
+
this.timeoutMs = options.timeoutMs;
|
|
16
|
+
}
|
|
17
|
+
async embed(text) {
|
|
18
|
+
const results = await this.embedBatch([text]);
|
|
19
|
+
return results[0];
|
|
20
|
+
}
|
|
21
|
+
async embedBatch(texts) {
|
|
22
|
+
if (texts.length === 0)
|
|
23
|
+
return [];
|
|
24
|
+
const url = `${this.baseUrl}/embeddings`;
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
'Authorization': `Bearer ${this.apiKey}`
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
model: this.model,
|
|
33
|
+
input: texts
|
|
34
|
+
}),
|
|
35
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const text = await res.text().catch(() => '');
|
|
39
|
+
throw new Error(`Embedding API error ${res.status}: ${text.slice(0, 200)}`);
|
|
40
|
+
}
|
|
41
|
+
const json = await res.json();
|
|
42
|
+
// Order by index to be safe
|
|
43
|
+
const sorted = json.data.slice().sort((a, b) => a.index - b.index);
|
|
44
|
+
return sorted.map((d) => d.embedding);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { OpenaiLlmProvider } from './openai.js';
|
|
2
|
+
import { NoopLlmProvider } from './noop.js';
|
|
3
|
+
export function createLlmProvider(kind, config) {
|
|
4
|
+
switch (kind) {
|
|
5
|
+
case 'openai-compatible':
|
|
6
|
+
return new OpenaiLlmProvider(config);
|
|
7
|
+
case 'noop':
|
|
8
|
+
return new NoopLlmProvider();
|
|
9
|
+
default:
|
|
10
|
+
throw new Error('Unknown LLM provider kind: ' + kind);
|
|
11
|
+
}
|
|
12
|
+
}
|