@abarcenas/ai-workflow-template 1.23.0 → 1.26.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/.agents/instructions/agent-skill.instructions.md +18 -20
- package/.agents/instructions/agent.instructions.md +111 -570
- package/.agents/instructions/ai-prompt-engineering-safety.instructions.md +3 -3
- package/.agents/instructions/instructions.instructions.md +6 -7
- package/.agents/instructions/learned-knowledge.instructions.md +17 -41
- package/.agents/instructions/memory-bank.instructions.md +125 -17
- package/.agents/instructions/memory-schema.instructions.md +75 -0
- package/.agents/instructions/prompt.instructions.md +25 -30
- package/.agents/instructions/task-implementation.instructions.md +12 -5
- package/.agents/skills/architecture-blueprint-generator/SKILL.md +63 -18
- package/.agents/skills/context-map/SKILL.md +27 -4
- package/.agents/skills/conventional-commit/SKILL.md +2 -2
- package/.agents/skills/github-issues/SKILL.md +2 -2
- package/.agents/skills/graphify-framework-aware/SKILL.md +133 -0
- package/.agents/skills/graphify-framework-aware/references/framework-registry.md +95 -0
- package/.agents/skills/graphify-framework-aware/scripts/fw-exclude.py +425 -0
- package/.agents/skills/memory-search/SKILL.md +69 -0
- package/.agents/skills/microsoft-docs/SKILL.md +1 -1
- package/.agents/skills/polygot-test-agent/SKILL.md +0 -1
- package/.agents/skills/prompt-builder/SKILL.md +15 -14
- package/.husky/post-merge +10 -0
- package/.husky/pre-commit +2 -0
- package/AGENTS.md +54 -0
- package/README.md +177 -3
- package/memory-bank/.vocabulary.json +52 -0
- package/opencode.mcp.example.json +8 -1
- package/package.json +25 -5
- package/scripts/mcp-memory-server.js +243 -0
- package/scripts/memory-cli.js +110 -0
- package/scripts/memory-index.js +512 -0
- package/scripts/normalize-memory.js +349 -0
- package/scripts/sync.test.js +12 -0
- package/scripts/validate-memory-schema.js +190 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* memory-cli.js — CLI for manual memory index operations.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node scripts/memory-cli.js search "<query>" [--topK 5] [--file activeContext]
|
|
8
|
+
* node scripts/memory-cli.js rebuild
|
|
9
|
+
* node scripts/memory-cli.js update (incremental)
|
|
10
|
+
* node scripts/memory-cli.js get <fileId>
|
|
11
|
+
* node scripts/memory-cli.js stats
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { search, rebuildIndex, incrementalUpdate, getFile, getStats, closeDB } from './memory-index.js';
|
|
15
|
+
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
const command = args[0];
|
|
18
|
+
|
|
19
|
+
function parseFlags(argv) {
|
|
20
|
+
const flags = {};
|
|
21
|
+
const positional = [];
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
if (argv[i] === '--topK' && i + 1 < argv.length) {
|
|
24
|
+
flags.topK = parseInt(argv[++i], 10);
|
|
25
|
+
} else if (argv[i] === '--file' && i + 1 < argv.length) {
|
|
26
|
+
flags.sourceFile = argv[++i];
|
|
27
|
+
} else {
|
|
28
|
+
positional.push(argv[i]);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { flags, positional };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function main() {
|
|
35
|
+
try {
|
|
36
|
+
switch (command) {
|
|
37
|
+
case 'search': {
|
|
38
|
+
const { flags, positional } = parseFlags(args.slice(1));
|
|
39
|
+
const query = positional.join(' ');
|
|
40
|
+
if (!query) {
|
|
41
|
+
console.error('Usage: node scripts/memory-cli.js search "<query>" [--topK 5] [--file name]');
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const results = await search(query, {
|
|
46
|
+
topK: flags.topK || 5,
|
|
47
|
+
sourceFile: flags.sourceFile,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (results.length === 0) {
|
|
51
|
+
console.log(JSON.stringify({ results: [], hint: 'No results. Try running "rebuild" first.' }));
|
|
52
|
+
} else {
|
|
53
|
+
console.log(JSON.stringify({ results }, null, 2));
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 'rebuild': {
|
|
59
|
+
const result = await rebuildIndex();
|
|
60
|
+
console.log(JSON.stringify({ status: 'ok', ...result }, null, 2));
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
case 'update': {
|
|
65
|
+
const result = await incrementalUpdate();
|
|
66
|
+
console.log(JSON.stringify({ status: 'ok', ...result }, null, 2));
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
case 'get': {
|
|
71
|
+
const fileId = args[1];
|
|
72
|
+
if (!fileId) {
|
|
73
|
+
console.error('Usage: node scripts/memory-cli.js get <fileId>');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
const content = getFile(fileId);
|
|
77
|
+
if (content) {
|
|
78
|
+
console.log(content);
|
|
79
|
+
} else {
|
|
80
|
+
console.error(`File not found: ${fileId}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'stats': {
|
|
87
|
+
console.log(JSON.stringify(getStats(), null, 2));
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
default:
|
|
92
|
+
console.error(`Usage: node scripts/memory-cli.js <search|rebuild|update|get|stats> [args...]`);
|
|
93
|
+
console.error('');
|
|
94
|
+
console.error('Commands:');
|
|
95
|
+
console.error(' search "<query>" [--topK N] [--file name] Semantic search');
|
|
96
|
+
console.error(' rebuild Full index rebuild');
|
|
97
|
+
console.error(' update Incremental update');
|
|
98
|
+
console.error(' get <fileId> Read a memory file');
|
|
99
|
+
console.error(' stats Index statistics');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`Error: ${err.message}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
} finally {
|
|
106
|
+
closeDB();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
main();
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory-index.js — Core engine for vector-based memory search.
|
|
3
|
+
*
|
|
4
|
+
* Manages a sqlite-vec vector index over memory-bank/ markdown files.
|
|
5
|
+
* Supports content-hash incremental updates and semantic search.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { search, rebuildIndex, incrementalUpdate, getStats } from './memory-index.js';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
13
|
+
import { basename, dirname, extname, join, resolve } from 'node:path';
|
|
14
|
+
|
|
15
|
+
// ── Configuration ──────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const MEMORY_BANK_DIR = resolve(process.cwd(), 'memory-bank');
|
|
18
|
+
const DOCS_DIR = resolve(process.cwd(), 'docs');
|
|
19
|
+
const INDEX_DIR = resolve(MEMORY_BANK_DIR, '.index');
|
|
20
|
+
const DB_PATH = resolve(INDEX_DIR, 'memory.db');
|
|
21
|
+
const EMBEDDING_DIM = 384;
|
|
22
|
+
|
|
23
|
+
// ── Lazy-loaded dependencies ────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** @type {import('better-sqlite3').default|null} */
|
|
26
|
+
let BetterSqlite3 = null;
|
|
27
|
+
|
|
28
|
+
/** @type {import('sqlite-vec').default|null} */
|
|
29
|
+
let sqliteVec = null;
|
|
30
|
+
|
|
31
|
+
/** @type {import('@xenova/transformers').pipeline|null} */
|
|
32
|
+
let pipelineFn = null;
|
|
33
|
+
|
|
34
|
+
/** @type {Promise<any>|null} */
|
|
35
|
+
let embedderPromise = null;
|
|
36
|
+
|
|
37
|
+
/** @type {any|null} */
|
|
38
|
+
let embedder = null;
|
|
39
|
+
|
|
40
|
+
/** @type {import('better-sqlite3').Database|null} */
|
|
41
|
+
let _db = null;
|
|
42
|
+
|
|
43
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function ensureDir(dir) {
|
|
46
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hashContent(content) {
|
|
50
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function filenameToId(filePath) {
|
|
54
|
+
return basename(filePath, extname(filePath));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse YAML frontmatter from markdown content.
|
|
59
|
+
* Returns { frontmatter: object|null, body: string }
|
|
60
|
+
*/
|
|
61
|
+
function parseFrontmatter(content) {
|
|
62
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
63
|
+
if (!match) return { frontmatter: null, body: content };
|
|
64
|
+
|
|
65
|
+
const raw = match[1];
|
|
66
|
+
const frontmatter = {};
|
|
67
|
+
for (const line of raw.split('\n')) {
|
|
68
|
+
const kv = line.match(/^(\w[\w-]*):\s*(.*)/);
|
|
69
|
+
if (kv) {
|
|
70
|
+
const key = kv[1];
|
|
71
|
+
let value = kv[2].trim();
|
|
72
|
+
// Parse arrays: [foo, bar]
|
|
73
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
74
|
+
value = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
|
|
75
|
+
}
|
|
76
|
+
// Unquote strings
|
|
77
|
+
else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
78
|
+
value = value.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
frontmatter[key] = value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { frontmatter, body: content.slice(match[0].length) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Split markdown body into chunks by ## headings.
|
|
89
|
+
* Each chunk includes its heading and content up to the next ## heading.
|
|
90
|
+
*/
|
|
91
|
+
function chunkMarkdown(body) {
|
|
92
|
+
const chunks = [];
|
|
93
|
+
const sections = body.split(/(?=^## )/m);
|
|
94
|
+
|
|
95
|
+
for (const section of sections) {
|
|
96
|
+
const trimmed = section.trim();
|
|
97
|
+
if (!trimmed) continue;
|
|
98
|
+
// Skip if it's just the H1 title (no ## prefix)
|
|
99
|
+
if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) continue;
|
|
100
|
+
if (trimmed.length < 10) continue; // Skip tiny sections
|
|
101
|
+
chunks.push(trimmed);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// If no ## sections found, treat the whole body as one chunk
|
|
105
|
+
if (chunks.length === 0 && body.trim().length > 10) {
|
|
106
|
+
chunks.push(body.trim());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return chunks;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Database ─────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
async function getSQLite() {
|
|
115
|
+
if (!BetterSqlite3) {
|
|
116
|
+
try { BetterSqlite3 = (await import('better-sqlite3')).default; }
|
|
117
|
+
catch { throw new Error('better-sqlite3 not installed. Run: npm install better-sqlite3'); }
|
|
118
|
+
}
|
|
119
|
+
return BetterSqlite3;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function getVec() {
|
|
123
|
+
if (!sqliteVec) {
|
|
124
|
+
try { sqliteVec = await import('sqlite-vec'); }
|
|
125
|
+
catch { throw new Error('sqlite-vec not installed. Run: npm install sqlite-vec'); }
|
|
126
|
+
}
|
|
127
|
+
return sqliteVec;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function getDB() {
|
|
131
|
+
if (_db) return _db;
|
|
132
|
+
|
|
133
|
+
const SQLite = await getSQLite();
|
|
134
|
+
const vec = await getVec();
|
|
135
|
+
|
|
136
|
+
ensureDir(INDEX_DIR);
|
|
137
|
+
_db = new SQLite(DB_PATH);
|
|
138
|
+
_db.pragma('journal_mode = WAL');
|
|
139
|
+
vec.load(_db);
|
|
140
|
+
|
|
141
|
+
// Create tables if they don't exist
|
|
142
|
+
_db.exec(`
|
|
143
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
144
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
145
|
+
source_file TEXT NOT NULL,
|
|
146
|
+
chunk_index INTEGER NOT NULL,
|
|
147
|
+
content TEXT NOT NULL,
|
|
148
|
+
content_hash TEXT NOT NULL,
|
|
149
|
+
vec_rowid INTEGER,
|
|
150
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
151
|
+
);
|
|
152
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_chunk_lookup
|
|
153
|
+
ON memory_chunks(source_file, chunk_index);
|
|
154
|
+
`);
|
|
155
|
+
|
|
156
|
+
// Create vec0 virtual table (auto rowid — we link via memory_chunks.vec_rowid)
|
|
157
|
+
const vecTableExists = _db.prepare(
|
|
158
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='vec_memory'"
|
|
159
|
+
).get();
|
|
160
|
+
|
|
161
|
+
if (!vecTableExists) {
|
|
162
|
+
_db.exec(`CREATE VIRTUAL TABLE vec_memory USING vec0(embedding float[${EMBEDDING_DIM}])`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Migration: add vec_rowid column if missing (from older schema)
|
|
166
|
+
const hasVecRowid = _db.prepare(
|
|
167
|
+
"SELECT COUNT(*) as cnt FROM pragma_table_info('memory_chunks') WHERE name = 'vec_rowid'"
|
|
168
|
+
).get();
|
|
169
|
+
if (!hasVecRowid || hasVecRowid.cnt === 0) {
|
|
170
|
+
_db.exec('ALTER TABLE memory_chunks ADD COLUMN vec_rowid INTEGER');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return _db;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function closeDB() {
|
|
177
|
+
if (_db) {
|
|
178
|
+
try { _db.close(); } catch { /* ignore */ }
|
|
179
|
+
_db = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ── Embedding ────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
async function getEmbedder() {
|
|
186
|
+
if (embedder) return embedder;
|
|
187
|
+
if (embedderPromise) return embedderPromise;
|
|
188
|
+
|
|
189
|
+
embedderPromise = (async () => {
|
|
190
|
+
if (!pipelineFn) {
|
|
191
|
+
try {
|
|
192
|
+
const mod = await import('@xenova/transformers');
|
|
193
|
+
pipelineFn = mod.pipeline;
|
|
194
|
+
} catch {
|
|
195
|
+
console.warn('[memory-index] @xenova/transformers not installed. Embedding unavailable.');
|
|
196
|
+
console.warn('[memory-index] Install with: npm install @xenova/transformers');
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
embedder = await pipelineFn('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
203
|
+
return embedder;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.warn('[memory-index] Failed to load embedding model:', err.message);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
})();
|
|
209
|
+
|
|
210
|
+
return embedderPromise;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function embed(text) {
|
|
214
|
+
const model = await getEmbedder();
|
|
215
|
+
if (!model) throw new Error('Embedding model not available');
|
|
216
|
+
|
|
217
|
+
const result = await model(text, { pooling: 'mean', normalize: true });
|
|
218
|
+
return new Float32Array(result.data);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Indexing ────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Index a single markdown file.
|
|
225
|
+
* Skips if content hasn't changed (hash check).
|
|
226
|
+
* Returns { indexed: number, skipped: number }
|
|
227
|
+
*/
|
|
228
|
+
async function indexFile(filePath) {
|
|
229
|
+
const db = await getDB();
|
|
230
|
+
const content = readFileSync(filePath, 'utf8');
|
|
231
|
+
const sourceFile = basename(filePath);
|
|
232
|
+
const { body } = parseFrontmatter(content);
|
|
233
|
+
const chunks = chunkMarkdown(body);
|
|
234
|
+
|
|
235
|
+
let indexed = 0;
|
|
236
|
+
let skipped = 0;
|
|
237
|
+
|
|
238
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
239
|
+
const chunkHash = hashContent(chunks[i]);
|
|
240
|
+
|
|
241
|
+
// Check if already indexed with same hash
|
|
242
|
+
const existing = db.prepare(
|
|
243
|
+
'SELECT id, content_hash FROM memory_chunks WHERE source_file = ? AND chunk_index = ?'
|
|
244
|
+
).get(sourceFile, i);
|
|
245
|
+
|
|
246
|
+
if (existing && existing.content_hash === chunkHash) {
|
|
247
|
+
skipped++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let rowid;
|
|
252
|
+
if (existing) {
|
|
253
|
+
const existingId = Number(existing.id);
|
|
254
|
+
const oldVecRowid = existing.vec_rowid;
|
|
255
|
+
|
|
256
|
+
// Update content
|
|
257
|
+
db.prepare(
|
|
258
|
+
'UPDATE memory_chunks SET content = ?, content_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?'
|
|
259
|
+
).run(chunks[i], chunkHash, existingId);
|
|
260
|
+
|
|
261
|
+
// Remove old vector
|
|
262
|
+
if (oldVecRowid != null) {
|
|
263
|
+
db.prepare('DELETE FROM vec_memory WHERE rowid = ?').run(Number(oldVecRowid));
|
|
264
|
+
}
|
|
265
|
+
rowid = existingId;
|
|
266
|
+
} else {
|
|
267
|
+
// Insert new chunk
|
|
268
|
+
const info = db.prepare(
|
|
269
|
+
'INSERT INTO memory_chunks (source_file, chunk_index, content, content_hash) VALUES (?, ?, ?, ?)'
|
|
270
|
+
).run(sourceFile, i, chunks[i], chunkHash);
|
|
271
|
+
rowid = Number(info.lastInsertRowid);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
const embedding = await embed(chunks[i]);
|
|
276
|
+
// Insert into vec0 without explicit rowid (auto-assigned)
|
|
277
|
+
const vecInfo = db.prepare('INSERT INTO vec_memory (embedding) VALUES (?)').run(embedding);
|
|
278
|
+
const vecRowid = Number(vecInfo.lastInsertRowid);
|
|
279
|
+
// Link vec0 rowid back to memory_chunks
|
|
280
|
+
db.prepare('UPDATE memory_chunks SET vec_rowid = ? WHERE id = ?').run(vecRowid, rowid);
|
|
281
|
+
indexed++;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
console.warn(`[memory-index] Failed to embed chunk ${i} of ${sourceFile}: ${err.message}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { indexed, skipped };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Collect all .md files from a directory recursively.
|
|
292
|
+
*/
|
|
293
|
+
function collectMdFiles(dir) {
|
|
294
|
+
if (!existsSync(dir)) return [];
|
|
295
|
+
|
|
296
|
+
const files = [];
|
|
297
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
298
|
+
|
|
299
|
+
for (const entry of entries) {
|
|
300
|
+
const fullPath = join(dir, entry.name);
|
|
301
|
+
if (entry.isDirectory()) {
|
|
302
|
+
// Skip .index and node_modules
|
|
303
|
+
if (entry.name === '.index' || entry.name === 'node_modules') continue;
|
|
304
|
+
files.push(...collectMdFiles(fullPath));
|
|
305
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
306
|
+
files.push(fullPath);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return files;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Full rebuild: clears index and re-indexes all markdown files.
|
|
315
|
+
*/
|
|
316
|
+
async function rebuildIndex() {
|
|
317
|
+
const db = await getDB();
|
|
318
|
+
|
|
319
|
+
console.log('[memory-index] Full rebuild started...');
|
|
320
|
+
|
|
321
|
+
// Clear existing data
|
|
322
|
+
db.exec('DELETE FROM vec_memory');
|
|
323
|
+
db.exec('DELETE FROM memory_chunks');
|
|
324
|
+
|
|
325
|
+
const memoryFiles = collectMdFiles(MEMORY_BANK_DIR);
|
|
326
|
+
const docFiles = collectMdFiles(DOCS_DIR);
|
|
327
|
+
const allFiles = [...memoryFiles, ...docFiles];
|
|
328
|
+
|
|
329
|
+
let totalIndexed = 0;
|
|
330
|
+
let totalSkipped = 0;
|
|
331
|
+
|
|
332
|
+
for (const file of allFiles) {
|
|
333
|
+
// Skip files in .index/
|
|
334
|
+
if (file.includes('/.index/')) continue;
|
|
335
|
+
|
|
336
|
+
const { indexed, skipped } = await indexFile(file);
|
|
337
|
+
totalIndexed += indexed;
|
|
338
|
+
totalSkipped += skipped;
|
|
339
|
+
|
|
340
|
+
if (indexed > 0) {
|
|
341
|
+
console.log(`[memory-index] Indexed: ${basename(file)} (${indexed} chunks)`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const stats = getStats();
|
|
346
|
+
console.log(`[memory-index] Rebuild complete: ${totalIndexed} chunks indexed, ${totalSkipped} skipped. ${stats.totalVectors} total vectors.`);
|
|
347
|
+
|
|
348
|
+
return { indexed: totalIndexed, skipped: totalSkipped, stats };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Incremental update: only re-index files whose content has changed.
|
|
353
|
+
* Uses file mtime + content hash for change detection.
|
|
354
|
+
*/
|
|
355
|
+
async function incrementalUpdate() {
|
|
356
|
+
const memoryFiles = collectMdFiles(MEMORY_BANK_DIR);
|
|
357
|
+
const docFiles = collectMdFiles(DOCS_DIR);
|
|
358
|
+
const allFiles = [...memoryFiles, ...docFiles];
|
|
359
|
+
|
|
360
|
+
let totalIndexed = 0;
|
|
361
|
+
let totalSkipped = 0;
|
|
362
|
+
const changed = [];
|
|
363
|
+
|
|
364
|
+
for (const file of allFiles) {
|
|
365
|
+
if (file.includes('/.index/')) continue;
|
|
366
|
+
|
|
367
|
+
const content = readFileSync(file, 'utf8');
|
|
368
|
+
const sourceFile = basename(file);
|
|
369
|
+
const { body } = parseFrontmatter(content);
|
|
370
|
+
const bodyHash = hashContent(body);
|
|
371
|
+
|
|
372
|
+
// Quick check: if the full body hash matches the last indexed state, skip
|
|
373
|
+
const db = await getDB();
|
|
374
|
+
const existingChunks = db.prepare(
|
|
375
|
+
'SELECT content_hash FROM memory_chunks WHERE source_file = ?'
|
|
376
|
+
).all(sourceFile);
|
|
377
|
+
|
|
378
|
+
const combinedHash = existingChunks.map(c => c.content_hash).join('|');
|
|
379
|
+
|
|
380
|
+
if (existingChunks.length > 0) {
|
|
381
|
+
// Check if any chunk's content changed by recomputing hashes
|
|
382
|
+
const chunks = chunkMarkdown(body);
|
|
383
|
+
const newCombinedHash = chunks.map(c => hashContent(c)).join('|');
|
|
384
|
+
|
|
385
|
+
if (newCombinedHash === combinedHash) {
|
|
386
|
+
totalSkipped += existingChunks.length;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const { indexed, skipped } = await indexFile(file);
|
|
392
|
+
if (indexed > 0) {
|
|
393
|
+
changed.push(sourceFile);
|
|
394
|
+
}
|
|
395
|
+
totalIndexed += indexed;
|
|
396
|
+
totalSkipped += skipped;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { indexed: totalIndexed, skipped: totalSkipped, changed };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Search ──────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Semantic search across indexed memory.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} query - Natural language query
|
|
408
|
+
* @param {object} [opts]
|
|
409
|
+
* @param {number} [opts.topK=5] - Number of results
|
|
410
|
+
* @param {string} [opts.category] - Filter by frontmatter category
|
|
411
|
+
* @param {string} [opts.sourceFile] - Filter by specific file
|
|
412
|
+
* @returns {Promise<Array<{source_file: string, content: string, score: number}>>}
|
|
413
|
+
*/
|
|
414
|
+
async function search(query, opts = {}) {
|
|
415
|
+
const { topK = 5, category, sourceFile } = opts;
|
|
416
|
+
const db = await getDB();
|
|
417
|
+
|
|
418
|
+
let queryEmbedding;
|
|
419
|
+
try {
|
|
420
|
+
queryEmbedding = await embed(query);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
throw new Error(`Embedding failed: ${err.message}. Is @xenova/transformers installed?`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Build query with optional filters
|
|
426
|
+
let sql = `
|
|
427
|
+
SELECT mc.source_file, mc.content, vec_memory.distance as score
|
|
428
|
+
FROM vec_memory
|
|
429
|
+
JOIN memory_chunks mc ON mc.vec_rowid = vec_memory.rowid
|
|
430
|
+
WHERE vec_memory.embedding MATCH ?
|
|
431
|
+
AND k = ?
|
|
432
|
+
`;
|
|
433
|
+
const params = [queryEmbedding, topK];
|
|
434
|
+
|
|
435
|
+
if (sourceFile) {
|
|
436
|
+
sql += ' AND mc.source_file = ?';
|
|
437
|
+
params.push(sourceFile);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
sql += ' ORDER BY vec_memory.distance';
|
|
441
|
+
|
|
442
|
+
const results = db.prepare(sql).all(...params);
|
|
443
|
+
|
|
444
|
+
// Format results
|
|
445
|
+
return results.map(r => ({
|
|
446
|
+
source_file: r.source_file,
|
|
447
|
+
content: r.content,
|
|
448
|
+
score: Math.round((1 - r.score) * 1000) / 1000, // Convert distance to similarity
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── Read ─────────────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Read a specific memory file by id (filename without extension).
|
|
456
|
+
*/
|
|
457
|
+
function getFile(fileId) {
|
|
458
|
+
// Try memory-bank first
|
|
459
|
+
const memPath = join(MEMORY_BANK_DIR, `${fileId}.md`);
|
|
460
|
+
if (existsSync(memPath)) {
|
|
461
|
+
return readFileSync(memPath, 'utf8');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Try docs
|
|
465
|
+
const docPath = join(DOCS_DIR, `${fileId}.md`);
|
|
466
|
+
if (existsSync(docPath)) {
|
|
467
|
+
return readFileSync(docPath, 'utf8');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Try hidden docs
|
|
471
|
+
const hiddenPath = join(DOCS_DIR, `.${fileId}.md`);
|
|
472
|
+
if (existsSync(hiddenPath)) {
|
|
473
|
+
return readFileSync(hiddenPath, 'utf8');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── Stats ────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
function getStats() {
|
|
482
|
+
if (!_db) {
|
|
483
|
+
return { totalVectors: 0, totalFiles: 0, dbSize: 'N/A' };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const vectors = _db.prepare('SELECT COUNT(*) as count FROM vec_memory').get();
|
|
487
|
+
const files = _db.prepare(
|
|
488
|
+
'SELECT COUNT(DISTINCT source_file) as count FROM memory_chunks'
|
|
489
|
+
).get();
|
|
490
|
+
const dbSizeBytes = existsSync(DB_PATH) ? statSync(DB_PATH).size : 0;
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
totalVectors: vectors.count,
|
|
494
|
+
totalFiles: files.count,
|
|
495
|
+
dbSize: `${(dbSizeBytes / 1024 / 1024).toFixed(1)} MB`,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── Exports ──────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
export {
|
|
502
|
+
search,
|
|
503
|
+
rebuildIndex,
|
|
504
|
+
incrementalUpdate,
|
|
505
|
+
indexFile,
|
|
506
|
+
getFile,
|
|
507
|
+
getStats,
|
|
508
|
+
closeDB,
|
|
509
|
+
hashContent,
|
|
510
|
+
parseFrontmatter,
|
|
511
|
+
chunkMarkdown,
|
|
512
|
+
};
|