@jhizzard/termdeck 0.2.0 → 0.2.2
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 +191 -169
- package/config/config.example.yaml +10 -10
- package/package.json +7 -6
- package/packages/cli/src/index.js +44 -1
- package/packages/cli/src/init-engram.js +344 -0
- package/packages/cli/src/init-rumen.js +425 -0
- package/packages/client/public/index.html +10 -10
- package/packages/server/src/config.js +8 -8
- package/packages/server/src/index.js +10 -10
- package/packages/server/src/{engram-bridge → mnestra-bridge}/index.js +18 -18
- package/packages/server/src/rag.js +6 -6
- package/packages/server/src/setup/dotenv-io.js +116 -0
- package/packages/server/src/setup/engram-migrations/001_engram_tables.sql +116 -0
- package/packages/server/src/setup/engram-migrations/002_engram_search_function.sql +141 -0
- package/packages/server/src/setup/engram-migrations/003_engram_event_webhook.sql +28 -0
- package/packages/server/src/setup/engram-migrations/004_engram_match_count_cap_and_explain.sql +176 -0
- package/packages/server/src/setup/engram-migrations/005_v0_1_to_v0_2_upgrade.sql +23 -0
- package/packages/server/src/setup/engram-migrations/006_memory_status_rpc.sql +58 -0
- package/packages/server/src/setup/index.js +14 -0
- package/packages/server/src/setup/migrations.js +80 -0
- package/packages/server/src/setup/pg-runner.js +113 -0
- package/packages/server/src/setup/prompts.js +177 -0
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +85 -0
- package/packages/server/src/setup/rumen/functions/rumen-tick/tsconfig.json +14 -0
- package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +91 -0
- package/packages/server/src/setup/rumen/migrations/002_pg_cron_schedule.sql +40 -0
- package/packages/server/src/setup/supabase-url.js +114 -0
- package/packages/server/src/setup/yaml-io.js +99 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Mnestra bridge — routes TermDeck memory queries through one of three backends:
|
|
2
2
|
// - direct: talk to Supabase + OpenAI from the server (pre-bridge behavior)
|
|
3
|
-
// - webhook: POST to
|
|
4
|
-
// - mcp: spawn the @jhizzard/
|
|
3
|
+
// - webhook: POST to Mnestra's HTTP webhook server (T3.1) at rag.mnestraWebhookUrl
|
|
4
|
+
// - mcp: spawn the @jhizzard/mnestra binary and talk JSON-RPC over stdio
|
|
5
5
|
//
|
|
6
6
|
// All three modes return the same shape:
|
|
7
7
|
// { memories: Array<{ content, source_type, project, similarity, created_at }>, total }
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
const { spawn } = require('child_process');
|
|
12
12
|
|
|
13
13
|
function createBridge(config) {
|
|
14
|
-
const mode = config.rag?.
|
|
14
|
+
const mode = config.rag?.mnestraMode || 'direct';
|
|
15
15
|
const state = { mcpChild: null, mcpQueue: [], mcpNextId: 1, mcpBuffer: '' };
|
|
16
16
|
|
|
17
17
|
async function queryDirect({ question, project, searchAll }) {
|
|
@@ -40,7 +40,7 @@ function createBridge(config) {
|
|
|
40
40
|
});
|
|
41
41
|
if (!embeddingRes.ok) {
|
|
42
42
|
const err = await embeddingRes.text();
|
|
43
|
-
console.error('[
|
|
43
|
+
console.error('[mnestra-bridge:direct] embedding failed:', err);
|
|
44
44
|
throw new Error('Embedding generation failed');
|
|
45
45
|
}
|
|
46
46
|
const embeddingData = await embeddingRes.json();
|
|
@@ -68,7 +68,7 @@ function createBridge(config) {
|
|
|
68
68
|
});
|
|
69
69
|
if (!searchRes.ok) {
|
|
70
70
|
const err = await searchRes.text();
|
|
71
|
-
console.error('[
|
|
71
|
+
console.error('[mnestra-bridge:direct] supabase search failed:', err);
|
|
72
72
|
throw new Error('Memory search failed');
|
|
73
73
|
}
|
|
74
74
|
const rows = await searchRes.json();
|
|
@@ -85,7 +85,7 @@ function createBridge(config) {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
async function queryWebhook({ question, project, searchAll }) {
|
|
88
|
-
const url = config.rag?.
|
|
88
|
+
const url = config.rag?.mnestraWebhookUrl || 'http://localhost:37778/mnestra';
|
|
89
89
|
const res = await fetch(url, {
|
|
90
90
|
method: 'POST',
|
|
91
91
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -98,8 +98,8 @@ function createBridge(config) {
|
|
|
98
98
|
});
|
|
99
99
|
if (!res.ok) {
|
|
100
100
|
const err = await res.text();
|
|
101
|
-
console.error('[
|
|
102
|
-
throw new Error(`
|
|
101
|
+
console.error('[mnestra-bridge:webhook] request failed:', err);
|
|
102
|
+
throw new Error(`Mnestra webhook returned ${res.status}`);
|
|
103
103
|
}
|
|
104
104
|
const data = await res.json();
|
|
105
105
|
const rows = data.memories || [];
|
|
@@ -118,7 +118,7 @@ function createBridge(config) {
|
|
|
118
118
|
function ensureMcpChild() {
|
|
119
119
|
if (state.mcpChild && !state.mcpChild.killed) return state.mcpChild;
|
|
120
120
|
|
|
121
|
-
const bin = config.rag?.
|
|
121
|
+
const bin = config.rag?.mnestraBinary || 'mnestra';
|
|
122
122
|
const child = spawn(bin, ['serve', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
123
123
|
state.mcpChild = child;
|
|
124
124
|
state.mcpBuffer = '';
|
|
@@ -135,24 +135,24 @@ function createBridge(config) {
|
|
|
135
135
|
const pending = state.mcpQueue.find((p) => p.id === msg.id);
|
|
136
136
|
if (pending) {
|
|
137
137
|
state.mcpQueue = state.mcpQueue.filter((p) => p !== pending);
|
|
138
|
-
if (msg.error) pending.reject(new Error(msg.error.message || '
|
|
138
|
+
if (msg.error) pending.reject(new Error(msg.error.message || 'Mnestra MCP error'));
|
|
139
139
|
else pending.resolve(msg.result);
|
|
140
140
|
}
|
|
141
141
|
} catch (err) {
|
|
142
|
-
console.error('[
|
|
142
|
+
console.error('[mnestra-bridge:mcp] parse error:', err.message, line);
|
|
143
143
|
}
|
|
144
144
|
}
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
child.stderr.on('data', (chunk) => {
|
|
148
|
-
console.error('[
|
|
148
|
+
console.error('[mnestra-bridge:mcp]', chunk.toString('utf-8').trim());
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
child.on('exit', (code, signal) => {
|
|
152
|
-
console.warn(`[
|
|
152
|
+
console.warn(`[mnestra-bridge:mcp] child exited (code=${code}, signal=${signal}); will respawn on next call`);
|
|
153
153
|
state.mcpChild = null;
|
|
154
154
|
for (const pending of state.mcpQueue) {
|
|
155
|
-
pending.reject(new Error('
|
|
155
|
+
pending.reject(new Error('Mnestra MCP child exited'));
|
|
156
156
|
}
|
|
157
157
|
state.mcpQueue = [];
|
|
158
158
|
});
|
|
@@ -177,7 +177,7 @@ function createBridge(config) {
|
|
|
177
177
|
const pending = state.mcpQueue.find((p) => p.id === id);
|
|
178
178
|
if (pending) {
|
|
179
179
|
state.mcpQueue = state.mcpQueue.filter((p) => p !== pending);
|
|
180
|
-
pending.reject(new Error('
|
|
180
|
+
pending.reject(new Error('Mnestra MCP call timed out'));
|
|
181
181
|
}
|
|
182
182
|
}, 15000);
|
|
183
183
|
});
|
|
@@ -214,7 +214,7 @@ function createBridge(config) {
|
|
|
214
214
|
}
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
async function
|
|
217
|
+
async function queryMnestra({ question, project, searchAll }) {
|
|
218
218
|
switch (mode) {
|
|
219
219
|
case 'webhook':
|
|
220
220
|
return queryWebhook({ question, project, searchAll });
|
|
@@ -226,7 +226,7 @@ function createBridge(config) {
|
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
-
return { mode,
|
|
229
|
+
return { mode, queryMnestra };
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
module.exports = { createBridge };
|
|
@@ -16,10 +16,10 @@ class RAGIntegration {
|
|
|
16
16
|
|
|
17
17
|
// Table configuration matching Josh's multi-layer schema
|
|
18
18
|
this.tables = {
|
|
19
|
-
sessionMemory: config.rag?.tables?.session || '
|
|
20
|
-
projectMemory: config.rag?.tables?.project || '
|
|
21
|
-
developerMemory: config.rag?.tables?.developer || '
|
|
22
|
-
commandLog: config.rag?.tables?.commands || '
|
|
19
|
+
sessionMemory: config.rag?.tables?.session || 'mnestra_session_memory',
|
|
20
|
+
projectMemory: config.rag?.tables?.project || 'mnestra_project_memory',
|
|
21
|
+
developerMemory: config.rag?.tables?.developer || 'mnestra_developer_memory',
|
|
22
|
+
commandLog: config.rag?.tables?.commands || 'mnestra_commands'
|
|
23
23
|
};
|
|
24
24
|
|
|
25
25
|
if (this.enabled) {
|
|
@@ -120,7 +120,7 @@ class RAGIntegration {
|
|
|
120
120
|
}
|
|
121
121
|
} catch (err) {
|
|
122
122
|
// Will be retried by sync loop
|
|
123
|
-
console.error('[
|
|
123
|
+
console.error('[mnestra] Push failed:', err.message);
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
@@ -163,7 +163,7 @@ class RAGIntegration {
|
|
|
163
163
|
markRagEventsSynced(this.db, synced);
|
|
164
164
|
}
|
|
165
165
|
} catch (err) {
|
|
166
|
-
console.error('[
|
|
166
|
+
console.error('[mnestra] Sync cycle error:', err.message);
|
|
167
167
|
}
|
|
168
168
|
}, this.syncInterval);
|
|
169
169
|
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Merge-aware reader/writer for ~/.termdeck/secrets.env.
|
|
2
|
+
//
|
|
3
|
+
// The existing config.js module already parses the file at load time, but for
|
|
4
|
+
// the `init` wizards we need to UPDATE the file without clobbering values the
|
|
5
|
+
// user has already set. This helper preserves unknown keys, preserves order
|
|
6
|
+
// of existing keys, and appends new keys at the bottom.
|
|
7
|
+
//
|
|
8
|
+
// File format (same subset as config.js parseDotenv):
|
|
9
|
+
// KEY=value
|
|
10
|
+
// KEY="quoted"
|
|
11
|
+
// KEY='single'
|
|
12
|
+
// # comments are preserved
|
|
13
|
+
// blank lines are preserved
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const SECRETS_PATH = path.join(os.homedir(), '.termdeck', 'secrets.env');
|
|
20
|
+
|
|
21
|
+
function readSecretsRaw(filepath = SECRETS_PATH) {
|
|
22
|
+
if (!fs.existsSync(filepath)) return { exists: false, lines: [], keys: {} };
|
|
23
|
+
const raw = fs.readFileSync(filepath, 'utf-8');
|
|
24
|
+
const lines = raw.split(/\r?\n/);
|
|
25
|
+
const keys = {};
|
|
26
|
+
lines.forEach((line, idx) => {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed || trimmed.startsWith('#')) return;
|
|
29
|
+
const eq = trimmed.indexOf('=');
|
|
30
|
+
if (eq === -1) return;
|
|
31
|
+
const key = trimmed.slice(0, eq).trim();
|
|
32
|
+
let val = trimmed.slice(eq + 1).trim();
|
|
33
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
34
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
35
|
+
val = val.slice(1, -1);
|
|
36
|
+
}
|
|
37
|
+
keys[key] = { value: val, lineIndex: idx };
|
|
38
|
+
});
|
|
39
|
+
return { exists: true, lines, keys };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Escape a value for safe re-serialization. Wraps in double quotes if the
|
|
43
|
+
// value contains whitespace, `#`, or `"`. Always safe to wrap — we wrap when
|
|
44
|
+
// in doubt to avoid ambiguity with the dotenv parser.
|
|
45
|
+
function formatValue(value) {
|
|
46
|
+
if (value == null) return '';
|
|
47
|
+
const str = String(value);
|
|
48
|
+
if (str === '') return '';
|
|
49
|
+
const needsQuoting = /[\s#"'=]/.test(str);
|
|
50
|
+
if (!needsQuoting) return str;
|
|
51
|
+
const escaped = str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
52
|
+
return `"${escaped}"`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Write a merged secrets.env. `updates` is an object of key→value pairs. Pass
|
|
56
|
+
// null/undefined value to delete a key. Existing lines are preserved for keys
|
|
57
|
+
// not listed in `updates`. New keys get appended to the bottom with a header.
|
|
58
|
+
function writeSecrets(updates, filepath = SECRETS_PATH) {
|
|
59
|
+
const dir = path.dirname(filepath);
|
|
60
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
61
|
+
|
|
62
|
+
const { exists, lines, keys } = readSecretsRaw(filepath);
|
|
63
|
+
const originalLines = exists ? lines.slice() : [
|
|
64
|
+
'# TermDeck secrets — loaded on server startup.',
|
|
65
|
+
'# Never commit this file.',
|
|
66
|
+
''
|
|
67
|
+
];
|
|
68
|
+
const workingLines = originalLines.slice();
|
|
69
|
+
const toAppend = [];
|
|
70
|
+
|
|
71
|
+
for (const [key, rawVal] of Object.entries(updates)) {
|
|
72
|
+
if (rawVal == null || rawVal === '') {
|
|
73
|
+
// Delete existing line (by overwriting with blank). Safer to mark for
|
|
74
|
+
// deletion than splice (splice shifts indices of subsequent keys).
|
|
75
|
+
if (keys[key]) workingLines[keys[key].lineIndex] = '';
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const formatted = `${key}=${formatValue(rawVal)}`;
|
|
79
|
+
if (keys[key]) {
|
|
80
|
+
workingLines[keys[key].lineIndex] = formatted;
|
|
81
|
+
} else {
|
|
82
|
+
toAppend.push(formatted);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let out = workingLines.join('\n');
|
|
87
|
+
if (toAppend.length > 0) {
|
|
88
|
+
if (out.length > 0 && !out.endsWith('\n')) out += '\n';
|
|
89
|
+
// Add a header for the first append-pass on an empty file.
|
|
90
|
+
if (!exists) out = originalLines.join('\n') + toAppend.join('\n') + '\n';
|
|
91
|
+
else out = out.replace(/\n+$/, '') + '\n' + toAppend.join('\n') + '\n';
|
|
92
|
+
} else if (!out.endsWith('\n')) {
|
|
93
|
+
out += '\n';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// chmod 600 for secrets — owner read/write only.
|
|
97
|
+
fs.writeFileSync(filepath, out, { encoding: 'utf-8', mode: 0o600 });
|
|
98
|
+
try { fs.chmodSync(filepath, 0o600); } catch (_err) { /* best-effort */ }
|
|
99
|
+
|
|
100
|
+
return { path: filepath, wrote: Object.keys(updates).length, appended: toAppend.length };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Convenience read — returns `{ SUPABASE_URL, ... }` object, no metadata.
|
|
104
|
+
function readSecrets(filepath = SECRETS_PATH) {
|
|
105
|
+
const { keys } = readSecretsRaw(filepath);
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [k, v] of Object.entries(keys)) out[k] = v.value;
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
SECRETS_PATH,
|
|
113
|
+
readSecrets,
|
|
114
|
+
writeSecrets,
|
|
115
|
+
_formatValue: formatValue
|
|
116
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
-- Engram v0.1 — core tables
|
|
2
|
+
--
|
|
3
|
+
-- Run against a Postgres 15+ database that has pgvector installed
|
|
4
|
+
-- (Supabase already ships with it). Apply in order:
|
|
5
|
+
-- 001_engram_tables.sql
|
|
6
|
+
-- 002_engram_search_function.sql
|
|
7
|
+
-- 003_engram_event_webhook.sql
|
|
8
|
+
|
|
9
|
+
create extension if not exists "vector";
|
|
10
|
+
create extension if not exists "pg_trgm";
|
|
11
|
+
create extension if not exists "pgcrypto";
|
|
12
|
+
|
|
13
|
+
-- ── memory_items ──────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
create table if not exists memory_items (
|
|
16
|
+
id uuid primary key default gen_random_uuid(),
|
|
17
|
+
content text not null,
|
|
18
|
+
embedding vector(1536),
|
|
19
|
+
source_type text not null default 'fact',
|
|
20
|
+
category text,
|
|
21
|
+
project text not null default 'global',
|
|
22
|
+
metadata jsonb not null default '{}'::jsonb,
|
|
23
|
+
is_active boolean not null default true,
|
|
24
|
+
archived boolean not null default false,
|
|
25
|
+
superseded_by uuid references memory_items(id) on delete set null,
|
|
26
|
+
created_at timestamptz not null default now(),
|
|
27
|
+
updated_at timestamptz not null default now()
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
create index if not exists memory_items_project_idx
|
|
31
|
+
on memory_items(project) where is_active = true and archived = false;
|
|
32
|
+
|
|
33
|
+
create index if not exists memory_items_source_type_idx
|
|
34
|
+
on memory_items(source_type) where is_active = true and archived = false;
|
|
35
|
+
|
|
36
|
+
create index if not exists memory_items_created_at_idx
|
|
37
|
+
on memory_items(created_at desc);
|
|
38
|
+
|
|
39
|
+
create index if not exists memory_items_content_trgm_idx
|
|
40
|
+
on memory_items using gin (content gin_trgm_ops);
|
|
41
|
+
|
|
42
|
+
-- HNSW vector index. If your Postgres/pgvector is too old for HNSW,
|
|
43
|
+
-- swap to ivfflat:
|
|
44
|
+
-- create index memory_items_embedding_idx on memory_items
|
|
45
|
+
-- using ivfflat (embedding vector_cosine_ops) with (lists = 100);
|
|
46
|
+
create index if not exists memory_items_embedding_hnsw_idx
|
|
47
|
+
on memory_items using hnsw (embedding vector_cosine_ops)
|
|
48
|
+
with (m = 16, ef_construction = 64);
|
|
49
|
+
|
|
50
|
+
-- ── memory_sessions ───────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
create table if not exists memory_sessions (
|
|
53
|
+
id uuid primary key default gen_random_uuid(),
|
|
54
|
+
project text not null default 'global',
|
|
55
|
+
summary text,
|
|
56
|
+
metadata jsonb not null default '{}'::jsonb,
|
|
57
|
+
created_at timestamptz not null default now()
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
create index if not exists memory_sessions_project_idx on memory_sessions(project);
|
|
61
|
+
create index if not exists memory_sessions_created_at_idx on memory_sessions(created_at desc);
|
|
62
|
+
|
|
63
|
+
-- ── memory_relationships ──────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
create table if not exists memory_relationships (
|
|
66
|
+
id uuid primary key default gen_random_uuid(),
|
|
67
|
+
source_id uuid not null references memory_items(id) on delete cascade,
|
|
68
|
+
target_id uuid not null references memory_items(id) on delete cascade,
|
|
69
|
+
relationship_type text not null,
|
|
70
|
+
created_at timestamptz not null default now(),
|
|
71
|
+
unique (source_id, target_id, relationship_type),
|
|
72
|
+
check (source_id <> target_id),
|
|
73
|
+
check (relationship_type in ('supersedes','relates_to','contradicts','elaborates','caused_by'))
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
create index if not exists memory_relationships_source_idx on memory_relationships(source_id);
|
|
77
|
+
create index if not exists memory_relationships_target_idx on memory_relationships(target_id);
|
|
78
|
+
|
|
79
|
+
-- ── match_memories helper RPC ─────────────────────────────────────────────
|
|
80
|
+
-- Used by remember.ts (dedup) and consolidate.ts (cluster seeding).
|
|
81
|
+
|
|
82
|
+
create or replace function match_memories (
|
|
83
|
+
query_embedding vector(1536),
|
|
84
|
+
match_threshold float,
|
|
85
|
+
match_count int,
|
|
86
|
+
filter_project text default null
|
|
87
|
+
)
|
|
88
|
+
returns table (
|
|
89
|
+
id uuid,
|
|
90
|
+
content text,
|
|
91
|
+
source_type text,
|
|
92
|
+
category text,
|
|
93
|
+
project text,
|
|
94
|
+
metadata jsonb,
|
|
95
|
+
similarity float
|
|
96
|
+
)
|
|
97
|
+
language sql stable
|
|
98
|
+
as $$
|
|
99
|
+
select
|
|
100
|
+
m.id,
|
|
101
|
+
m.content,
|
|
102
|
+
m.source_type,
|
|
103
|
+
m.category,
|
|
104
|
+
m.project,
|
|
105
|
+
m.metadata,
|
|
106
|
+
1 - (m.embedding <=> query_embedding) as similarity
|
|
107
|
+
from memory_items m
|
|
108
|
+
where m.is_active = true
|
|
109
|
+
and m.archived = false
|
|
110
|
+
and m.superseded_by is null
|
|
111
|
+
and m.embedding is not null
|
|
112
|
+
and (filter_project is null or m.project = filter_project)
|
|
113
|
+
and 1 - (m.embedding <=> query_embedding) >= match_threshold
|
|
114
|
+
order by m.embedding <=> query_embedding asc
|
|
115
|
+
limit match_count;
|
|
116
|
+
$$;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
-- Engram v0.1 — memory_hybrid_search
|
|
2
|
+
--
|
|
3
|
+
-- Reciprocal rank fusion over full-text and semantic search, with the
|
|
4
|
+
-- three SQL-side fixes from RAG-MEMORY-IMPROVEMENTS-AND-TERMDECK-STRATEGY.md:
|
|
5
|
+
--
|
|
6
|
+
-- Fix 1 — Tiered recency decay by source_type. Architectural decisions
|
|
7
|
+
-- decay on a one-year half-life; bug fixes on a 30-day half-life;
|
|
8
|
+
-- session summaries and document chunks on a 14-day half-life.
|
|
9
|
+
--
|
|
10
|
+
-- Fix 3 — Source_type weighting. Decisions and architecture outrank
|
|
11
|
+
-- raw document chunks in the final fused score.
|
|
12
|
+
--
|
|
13
|
+
-- Fix 5 — Project affinity scoring. Exact project match multiplies the
|
|
14
|
+
-- score by 1.5x; mismatches are penalised 0.7x.
|
|
15
|
+
|
|
16
|
+
create or replace function memory_hybrid_search (
|
|
17
|
+
query_text text,
|
|
18
|
+
query_embedding vector(1536),
|
|
19
|
+
match_count int default 20,
|
|
20
|
+
full_text_weight float default 1.0,
|
|
21
|
+
semantic_weight float default 1.0,
|
|
22
|
+
rrf_k int default 60,
|
|
23
|
+
filter_project text default null,
|
|
24
|
+
filter_source_type text default null
|
|
25
|
+
)
|
|
26
|
+
returns table (
|
|
27
|
+
id uuid,
|
|
28
|
+
content text,
|
|
29
|
+
source_type text,
|
|
30
|
+
category text,
|
|
31
|
+
project text,
|
|
32
|
+
metadata jsonb,
|
|
33
|
+
score float,
|
|
34
|
+
created_at timestamptz
|
|
35
|
+
)
|
|
36
|
+
language sql stable
|
|
37
|
+
as $$
|
|
38
|
+
with candidates as (
|
|
39
|
+
select
|
|
40
|
+
m.id,
|
|
41
|
+
m.content,
|
|
42
|
+
m.source_type,
|
|
43
|
+
m.category,
|
|
44
|
+
m.project,
|
|
45
|
+
m.metadata,
|
|
46
|
+
m.created_at,
|
|
47
|
+
m.embedding,
|
|
48
|
+
ts_rank_cd(to_tsvector('english', m.content), plainto_tsquery('english', query_text))
|
|
49
|
+
as ft_rank,
|
|
50
|
+
1 - (m.embedding <=> query_embedding) as sem_rank,
|
|
51
|
+
extract(epoch from (now() - m.created_at))::float as age_seconds
|
|
52
|
+
from memory_items m
|
|
53
|
+
where m.is_active = true
|
|
54
|
+
and m.archived = false
|
|
55
|
+
and m.superseded_by is null
|
|
56
|
+
and m.embedding is not null
|
|
57
|
+
and (filter_project is null or m.project = filter_project)
|
|
58
|
+
and (filter_source_type is null or m.source_type = filter_source_type)
|
|
59
|
+
),
|
|
60
|
+
ft_ranked as (
|
|
61
|
+
select id, row_number() over (order by ft_rank desc nulls last) as rank
|
|
62
|
+
from candidates where ft_rank > 0
|
|
63
|
+
),
|
|
64
|
+
sem_ranked as (
|
|
65
|
+
select id, row_number() over (order by sem_rank desc nulls last) as rank
|
|
66
|
+
from candidates
|
|
67
|
+
),
|
|
68
|
+
fused as (
|
|
69
|
+
select
|
|
70
|
+
c.id,
|
|
71
|
+
c.content,
|
|
72
|
+
c.source_type,
|
|
73
|
+
c.category,
|
|
74
|
+
c.project,
|
|
75
|
+
c.metadata,
|
|
76
|
+
c.created_at,
|
|
77
|
+
c.age_seconds,
|
|
78
|
+
-- RRF base score
|
|
79
|
+
coalesce(full_text_weight / (rrf_k + ft.rank), 0.0) +
|
|
80
|
+
coalesce(semantic_weight / (rrf_k + sr.rank), 0.0) as base_score
|
|
81
|
+
from candidates c
|
|
82
|
+
left join ft_ranked ft on ft.id = c.id
|
|
83
|
+
left join sem_ranked sr on sr.id = c.id
|
|
84
|
+
),
|
|
85
|
+
scored as (
|
|
86
|
+
select
|
|
87
|
+
f.id,
|
|
88
|
+
f.content,
|
|
89
|
+
f.source_type,
|
|
90
|
+
f.category,
|
|
91
|
+
f.project,
|
|
92
|
+
f.metadata,
|
|
93
|
+
f.created_at,
|
|
94
|
+
f.base_score
|
|
95
|
+
-- Fix 1: tiered recency decay by source_type
|
|
96
|
+
* case f.source_type
|
|
97
|
+
when 'decision' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
|
|
98
|
+
when 'architecture' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
|
|
99
|
+
when 'preference' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
|
|
100
|
+
when 'fact' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
|
|
101
|
+
when 'convention' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
|
|
102
|
+
when 'bug_fix' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
|
|
103
|
+
when 'debugging' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
|
|
104
|
+
when 'session_summary' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
|
|
105
|
+
when 'document_chunk' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
|
|
106
|
+
when 'code_context' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
|
|
107
|
+
else 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
|
|
108
|
+
end
|
|
109
|
+
-- Fix 3: source_type weighting
|
|
110
|
+
* case f.source_type
|
|
111
|
+
when 'decision' then 1.5
|
|
112
|
+
when 'architecture' then 1.4
|
|
113
|
+
when 'bug_fix' then 1.3
|
|
114
|
+
when 'preference' then 1.2
|
|
115
|
+
when 'fact' then 1.0
|
|
116
|
+
when 'document_chunk' then 0.6
|
|
117
|
+
else 1.0
|
|
118
|
+
end
|
|
119
|
+
-- Fix 5: project affinity scoring
|
|
120
|
+
* case
|
|
121
|
+
when filter_project is null then 1.0
|
|
122
|
+
when f.project = filter_project then 1.5
|
|
123
|
+
when f.project = 'global' then 1.0
|
|
124
|
+
else 0.7
|
|
125
|
+
end
|
|
126
|
+
as score
|
|
127
|
+
from fused f
|
|
128
|
+
)
|
|
129
|
+
select
|
|
130
|
+
s.id,
|
|
131
|
+
s.content,
|
|
132
|
+
s.source_type,
|
|
133
|
+
s.category,
|
|
134
|
+
s.project,
|
|
135
|
+
s.metadata,
|
|
136
|
+
s.score,
|
|
137
|
+
s.created_at
|
|
138
|
+
from scored s
|
|
139
|
+
order by s.score desc
|
|
140
|
+
limit match_count;
|
|
141
|
+
$$;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
-- Engram v0.1 — real-time event webhook (Fix 6)
|
|
2
|
+
--
|
|
3
|
+
-- Fix 6 from RAG-MEMORY-IMPROVEMENTS-AND-TERMDECK-STRATEGY.md is a
|
|
4
|
+
-- real-time event intake path so TermDeck (or any other client) can
|
|
5
|
+
-- POST terminal events — "server started on :8080", "tests failing",
|
|
6
|
+
-- "error detected" — and have them land in memory immediately.
|
|
7
|
+
--
|
|
8
|
+
-- That intake is implemented as an HTTP endpoint inside the Engram MCP
|
|
9
|
+
-- server process, not as a SQL trigger. This file exists as a placeholder
|
|
10
|
+
-- so the migration history is explicit and future database-side changes
|
|
11
|
+
-- (e.g. an events queue table for async ingestion) have a home.
|
|
12
|
+
--
|
|
13
|
+
-- If you want to add a durable event queue later, add it below this line.
|
|
14
|
+
|
|
15
|
+
-- Example future shape (commented out — not applied):
|
|
16
|
+
--
|
|
17
|
+
-- create table if not exists memory_events (
|
|
18
|
+
-- id uuid primary key default gen_random_uuid(),
|
|
19
|
+
-- project text not null,
|
|
20
|
+
-- source text not null,
|
|
21
|
+
-- event_type text not null,
|
|
22
|
+
-- payload jsonb not null default '{}'::jsonb,
|
|
23
|
+
-- processed boolean not null default false,
|
|
24
|
+
-- created_at timestamptz not null default now()
|
|
25
|
+
-- );
|
|
26
|
+
--
|
|
27
|
+
-- create index if not exists memory_events_unprocessed_idx
|
|
28
|
+
-- on memory_events(created_at) where processed = false;
|