@abarcenas/ai-workflow-template 1.34.0 → 1.38.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/knowledgebase.instructions.md +238 -0
- package/.agents/instructions/learned-knowledge.instructions.md +71 -0
- package/.husky/post-commit +10 -0
- package/.husky/post-merge +0 -1
- package/.opencode/agents/architect.agent.md +1 -0
- package/.opencode/agents/coder.agent.md +1 -0
- package/.opencode/agents/deployer.agent.md +1 -0
- package/.opencode/agents/designer.agent.md +1 -0
- package/.opencode/agents/e2e-tester.agent.md +1 -0
- package/.opencode/agents/implementer.agent.md +1 -0
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +1 -0
- package/.opencode/agents/orchestrator/orchestrator.agent.md +1 -0
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +1 -0
- package/.opencode/agents/researcher.agent.md +1 -0
- package/.opencode/agents/reviewer.agent.md +1 -0
- package/.opencode/agents/tracker.agent.md +2 -0
- package/.opencode/agents/unit-tester.agent.md +1 -0
- package/README.md +1 -1
- package/memory-bank/.vocabulary.json +8 -2
- package/package.json +12 -6
- package/scripts/knowledgebase-cli.js +228 -0
- package/scripts/knowledgebase-cli.test.js +576 -0
- package/scripts/knowledgebase-index.js +780 -0
- package/scripts/knowledgebase-index.test.js +759 -0
- package/scripts/knowledgebase-init.sql +193 -0
- package/scripts/mcp-knowledgebase-server.js +330 -0
- package/scripts/setup/constants.js +36 -3
- package/scripts/setup/hooks.js +2 -2
- package/scripts/setup/hooks.test.js +13 -5
- package/scripts/setup/index.js +29 -2
- package/scripts/setup/index.test.js +36 -1
- package/scripts/setup/knowledgebase.js +204 -0
- package/scripts/setup/knowledgebase.test.js +248 -0
- package/scripts/sync.js +40 -6
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knowledgebase-index.js — Core engine for PostgreSQL + pgvector knowledgebase.
|
|
3
|
+
*
|
|
4
|
+
* Centralized knowledgebase for cross-project learned knowledge sharing.
|
|
5
|
+
* Manages connection pool, embedding generation, CRUD operations,
|
|
6
|
+
* semantic search, and statistics.
|
|
7
|
+
*
|
|
8
|
+
* All exported functions gracefully return empty/zero results when the
|
|
9
|
+
* database is not configured — never throw.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* import { search, upsertChunks, getStats } from './knowledgebase-index.js';
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ── Configuration ──────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const EMBEDDING_DIM = 384;
|
|
22
|
+
|
|
23
|
+
// ── Lazy-loaded module state ───────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** @type {import('pg').Pool|null} */
|
|
26
|
+
let _pool = null;
|
|
27
|
+
|
|
28
|
+
/** @type {boolean} */
|
|
29
|
+
let _poolInitialised = false;
|
|
30
|
+
|
|
31
|
+
/** @type {import('pgvector').toSql|null} */
|
|
32
|
+
let _toSql = null;
|
|
33
|
+
|
|
34
|
+
/** @type {import('pgvector').fromSql|null} */
|
|
35
|
+
let _fromSql = null;
|
|
36
|
+
|
|
37
|
+
/** @type {Function|null} */
|
|
38
|
+
let _pipelineFn = null;
|
|
39
|
+
|
|
40
|
+
/** @type {Promise<any>|null} */
|
|
41
|
+
let _embedderPromise = null;
|
|
42
|
+
|
|
43
|
+
/** @type {any|null} */
|
|
44
|
+
let _embedder = null;
|
|
45
|
+
|
|
46
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute SHA-256 hex digest of a string.
|
|
50
|
+
* @param {string} content
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
function hashContent(content) {
|
|
54
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Redact a connection string for safe logging.
|
|
59
|
+
* @param {string} url
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function redactConnectionString(url) {
|
|
63
|
+
try {
|
|
64
|
+
const u = new URL(url);
|
|
65
|
+
const auth = u.username ? '***@' : '';
|
|
66
|
+
return `${u.protocol}//${auth}${u.hostname}:${u.port}${u.pathname}`;
|
|
67
|
+
} catch {
|
|
68
|
+
return '***';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Connection Management ──────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get or create the PostgreSQL connection pool.
|
|
76
|
+
*
|
|
77
|
+
* Lazily initialised on first call. Reads DATABASE_URL from process.env.
|
|
78
|
+
* Returns null when DATABASE_URL is not set or optional deps (pg, pgvector)
|
|
79
|
+
* are missing — never throws.
|
|
80
|
+
*
|
|
81
|
+
* Pool configuration:
|
|
82
|
+
* max: 5 — conservative for single-user MCP stdio
|
|
83
|
+
* idleTimeoutMillis: 30000 — 30s idle timeout
|
|
84
|
+
* connectionTimeoutMillis: 5000 — fail fast if unreachable
|
|
85
|
+
*
|
|
86
|
+
* @returns {Promise<import('pg').Pool|null>}
|
|
87
|
+
*/
|
|
88
|
+
async function getPool() {
|
|
89
|
+
if (_poolInitialised) return _pool;
|
|
90
|
+
|
|
91
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
92
|
+
if (!dbUrl) {
|
|
93
|
+
console.warn('[knowledgebase] DATABASE_URL not set. Knowledgebase unavailable. ' +
|
|
94
|
+
'Set DATABASE_URL in your .env file to enable cross-project knowledge search.');
|
|
95
|
+
_poolInitialised = true;
|
|
96
|
+
_pool = null;
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Lazy import: pg (optional dependency)
|
|
101
|
+
let pgMod;
|
|
102
|
+
try {
|
|
103
|
+
pgMod = await import('pg');
|
|
104
|
+
} catch {
|
|
105
|
+
console.warn('[knowledgebase] pg module not installed. Install with: npm install pg');
|
|
106
|
+
_poolInitialised = true;
|
|
107
|
+
_pool = null;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const { Pool } = pgMod.default || pgMod;
|
|
111
|
+
|
|
112
|
+
// Lazy import: pgvector (optional dependency)
|
|
113
|
+
try {
|
|
114
|
+
const vecMod = await import('pgvector');
|
|
115
|
+
const v = vecMod.default || vecMod;
|
|
116
|
+
_toSql = v.toSql;
|
|
117
|
+
_fromSql = v.fromSql;
|
|
118
|
+
} catch {
|
|
119
|
+
console.warn('[knowledgebase] pgvector module not installed. Install with: npm install pgvector');
|
|
120
|
+
_poolInitialised = true;
|
|
121
|
+
_pool = null;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
_pool = new Pool({
|
|
127
|
+
connectionString: dbUrl,
|
|
128
|
+
max: 5,
|
|
129
|
+
idleTimeoutMillis: 30000,
|
|
130
|
+
connectionTimeoutMillis: 5000,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
_pool.on('error', (err) => {
|
|
134
|
+
console.error('[knowledgebase] Unexpected connection pool error:', err.message);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
_poolInitialised = true;
|
|
138
|
+
return _pool;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.warn('[knowledgebase] Failed to create connection pool:', err.message);
|
|
141
|
+
_poolInitialised = true;
|
|
142
|
+
_pool = null;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Close the connection pool and release all connections.
|
|
149
|
+
* Safe to call even if the pool was never created.
|
|
150
|
+
*/
|
|
151
|
+
async function closePool() {
|
|
152
|
+
if (_pool) {
|
|
153
|
+
try {
|
|
154
|
+
await _pool.end();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
console.warn('[knowledgebase] Error closing pool:', err.message);
|
|
157
|
+
}
|
|
158
|
+
_pool = null;
|
|
159
|
+
_poolInitialised = false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Ensure the database schema exists.
|
|
165
|
+
*
|
|
166
|
+
* Creates the pgvector extension, projects and knowledge_chunks tables,
|
|
167
|
+
* and all indexes. Catches CREATE EXTENSION failures gracefully and
|
|
168
|
+
* directs the user to run the manual init script.
|
|
169
|
+
*
|
|
170
|
+
* @returns {Promise<void>}
|
|
171
|
+
*/
|
|
172
|
+
async function ensureSchema() {
|
|
173
|
+
const pool = await getPool();
|
|
174
|
+
if (!pool) return;
|
|
175
|
+
|
|
176
|
+
// Attempt to create the pgvector extension (may fail due to permissions)
|
|
177
|
+
try {
|
|
178
|
+
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.warn('[knowledgebase] pgvector extension not available. ' +
|
|
181
|
+
'Run manually: psql $DATABASE_URL -f scripts/knowledgebase-init.sql');
|
|
182
|
+
console.warn('[knowledgebase] Extension error:', err.message);
|
|
183
|
+
// Continue — tables can exist without the extension
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
// Projects table
|
|
188
|
+
await pool.query(`
|
|
189
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
190
|
+
id TEXT PRIMARY KEY,
|
|
191
|
+
name TEXT NOT NULL,
|
|
192
|
+
first_indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
193
|
+
last_indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
194
|
+
)
|
|
195
|
+
`);
|
|
196
|
+
|
|
197
|
+
// Knowledge chunks table with vector embedding column
|
|
198
|
+
await pool.query(`
|
|
199
|
+
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
|
200
|
+
id BIGSERIAL PRIMARY KEY,
|
|
201
|
+
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
202
|
+
session_date DATE NOT NULL,
|
|
203
|
+
session_title TEXT,
|
|
204
|
+
pipeline TEXT,
|
|
205
|
+
coverage TEXT,
|
|
206
|
+
tdd_iterations INTEGER NOT NULL DEFAULT 0,
|
|
207
|
+
content TEXT NOT NULL,
|
|
208
|
+
content_hash TEXT NOT NULL,
|
|
209
|
+
embedding VECTOR(${EMBEDDING_DIM}),
|
|
210
|
+
indexed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
211
|
+
CONSTRAINT uq_knowledge_chunk UNIQUE (project_id, session_date, content_hash)
|
|
212
|
+
)
|
|
213
|
+
`);
|
|
214
|
+
|
|
215
|
+
// HNSW index for cosine similarity search
|
|
216
|
+
try {
|
|
217
|
+
await pool.query(`
|
|
218
|
+
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_embedding
|
|
219
|
+
ON knowledge_chunks
|
|
220
|
+
USING hnsw (embedding vector_cosine_ops)
|
|
221
|
+
WITH (m = 16, ef_construction = 64)
|
|
222
|
+
`);
|
|
223
|
+
} catch (idxErr) {
|
|
224
|
+
console.warn('[knowledgebase] Could not create HNSW index ' +
|
|
225
|
+
'(pgvector extension may not be available):', idxErr.message);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// B-tree indexes for structured queries and filters
|
|
229
|
+
await pool.query(`
|
|
230
|
+
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_project_id
|
|
231
|
+
ON knowledge_chunks (project_id)
|
|
232
|
+
`);
|
|
233
|
+
await pool.query(`
|
|
234
|
+
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_session_date
|
|
235
|
+
ON knowledge_chunks (session_date DESC)
|
|
236
|
+
`);
|
|
237
|
+
await pool.query(`
|
|
238
|
+
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_pipeline
|
|
239
|
+
ON knowledge_chunks (pipeline)
|
|
240
|
+
`);
|
|
241
|
+
await pool.query(`
|
|
242
|
+
CREATE INDEX IF NOT EXISTS idx_projects_last_indexed
|
|
243
|
+
ON projects (last_indexed_at DESC)
|
|
244
|
+
`);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.warn('[knowledgebase] Schema creation failed:', err.message);
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Embedding ───────────────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get or create the embedding model singleton.
|
|
255
|
+
* Uses @xenova/transformers pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2').
|
|
256
|
+
* Model is created once and cached for reuse across calls.
|
|
257
|
+
*
|
|
258
|
+
* @returns {Promise<any|null>}
|
|
259
|
+
*/
|
|
260
|
+
async function getEmbedder() {
|
|
261
|
+
if (_embedder) return _embedder;
|
|
262
|
+
if (_embedderPromise) return _embedderPromise;
|
|
263
|
+
|
|
264
|
+
_embedderPromise = (async () => {
|
|
265
|
+
if (!_pipelineFn) {
|
|
266
|
+
try {
|
|
267
|
+
const mod = await import('@xenova/transformers');
|
|
268
|
+
_pipelineFn = mod.pipeline;
|
|
269
|
+
} catch {
|
|
270
|
+
console.warn('[knowledgebase] @xenova/transformers not installed. ' +
|
|
271
|
+
'Embedding unavailable. Install with: npm install @xenova/transformers');
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
_embedder = await _pipelineFn('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
|
|
278
|
+
return _embedder;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
console.warn('[knowledgebase] Failed to load embedding model:', err.message);
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
|
|
285
|
+
return _embedderPromise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Generate an embedding vector for the given text.
|
|
290
|
+
*
|
|
291
|
+
* Uses the local @xenova/transformers pipeline with all-MiniLM-L6-v2 (384d).
|
|
292
|
+
* Returns a Float32Array of 384 floats using mean pooling + L2 normalization.
|
|
293
|
+
* pgvector's `toSql()` accepts both Float32Array and plain arrays, so callers
|
|
294
|
+
* can rely on receiving a Float32Array.
|
|
295
|
+
*
|
|
296
|
+
* @param {string} text - Text to embed
|
|
297
|
+
* @returns {Promise<Float32Array>} Float32Array of 384 floats
|
|
298
|
+
* @throws {Error} When the embedding model is not available
|
|
299
|
+
*/
|
|
300
|
+
async function embed(text) {
|
|
301
|
+
const model = await getEmbedder();
|
|
302
|
+
if (!model) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
'Embedding model not available. Install @xenova/transformers.'
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const result = await model(text, { pooling: 'mean', normalize: true });
|
|
309
|
+
// Wrap in Float32Array — the pipeline may return a different typed array type
|
|
310
|
+
return new Float32Array(result.data);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Set the embedding provider strategy.
|
|
315
|
+
*
|
|
316
|
+
* @param {'local'|'openai'} provider - Embedding backend to use
|
|
317
|
+
* 'local' — @xenova/transformers all-MiniLM-L6-v2 (384d, default)
|
|
318
|
+
* 'openai' — OpenAI text-embedding-3-small (1536d, requires OPENAI_API_KEY)
|
|
319
|
+
*/
|
|
320
|
+
function setEmbeddingProvider(provider) {
|
|
321
|
+
// Currently only 'local' is implemented.
|
|
322
|
+
// Future: 'openai' support will use OpenAI API with 1536-d embeddings.
|
|
323
|
+
console.warn(`[knowledgebase] Embedding provider set to: ${provider}`);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── CRUD ───────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Register or update a project in the projects table.
|
|
330
|
+
*
|
|
331
|
+
* Creates the project if it doesn't exist, or updates the display name
|
|
332
|
+
* and last_indexed_at timestamp if it does (idempotent upsert).
|
|
333
|
+
*
|
|
334
|
+
* @param {string} projectId - Project identifier (from package.json "name")
|
|
335
|
+
* @param {string} name - Display name for the project
|
|
336
|
+
* @returns {Promise<{id: string, first_indexed_at: string|null}>}
|
|
337
|
+
*/
|
|
338
|
+
async function registerProject(projectId, name) {
|
|
339
|
+
const pool = await getPool();
|
|
340
|
+
if (!pool) return { id: projectId, first_indexed_at: null };
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const result = await pool.query(
|
|
344
|
+
`INSERT INTO projects (id, name)
|
|
345
|
+
VALUES ($1, $2)
|
|
346
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
347
|
+
name = EXCLUDED.name,
|
|
348
|
+
last_indexed_at = NOW()
|
|
349
|
+
RETURNING id, first_indexed_at`,
|
|
350
|
+
[projectId, name]
|
|
351
|
+
);
|
|
352
|
+
return result.rows[0];
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.warn('[knowledgebase] registerProject failed:', err.message);
|
|
355
|
+
return { id: projectId, first_indexed_at: null };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Upsert multiple knowledge chunks in batch.
|
|
361
|
+
*
|
|
362
|
+
* Each chunk is embedded and upserted with ON CONFLICT idempotency using
|
|
363
|
+
* the (project_id, session_date, content_hash) unique constraint.
|
|
364
|
+
* Chunks with matching hashes update metadata but preserve idempotency.
|
|
365
|
+
*
|
|
366
|
+
* @param {Array<{
|
|
367
|
+
* project_id: string,
|
|
368
|
+
* session_date: string,
|
|
369
|
+
* session_title?: string,
|
|
370
|
+
* pipeline?: string,
|
|
371
|
+
* coverage?: string,
|
|
372
|
+
* tdd_iterations?: number,
|
|
373
|
+
* content: string,
|
|
374
|
+
* content_hash?: string
|
|
375
|
+
* }>} chunks - Array of chunk objects to upsert
|
|
376
|
+
* @returns {Promise<{inserted: number, updated: number, skipped: number}>}
|
|
377
|
+
*/
|
|
378
|
+
async function upsertChunks(chunks) {
|
|
379
|
+
const pool = await getPool();
|
|
380
|
+
if (!pool || !Array.isArray(chunks) || chunks.length === 0) {
|
|
381
|
+
return { inserted: 0, updated: 0, skipped: 0 };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
let inserted = 0;
|
|
385
|
+
let updated = 0;
|
|
386
|
+
let skipped = 0;
|
|
387
|
+
|
|
388
|
+
for (const chunk of chunks) {
|
|
389
|
+
try {
|
|
390
|
+
// Compute content hash if not provided
|
|
391
|
+
const contentHash = chunk.content_hash ||
|
|
392
|
+
hashContent(chunk.content + (chunk.session_date || '') + (chunk.pipeline || ''));
|
|
393
|
+
|
|
394
|
+
// Generate embedding from session-contextualised content
|
|
395
|
+
let embeddingSql = null;
|
|
396
|
+
try {
|
|
397
|
+
const contextText = `Session: ${chunk.session_date || ''}` +
|
|
398
|
+
(chunk.pipeline ? ` — Pipeline: ${chunk.pipeline}` : '') +
|
|
399
|
+
`\n${chunk.content}`;
|
|
400
|
+
const vec = await embed(contextText);
|
|
401
|
+
embeddingSql = _toSql(vec);
|
|
402
|
+
} catch (embedErr) {
|
|
403
|
+
console.warn('[knowledgebase] Embedding failed for chunk, ' +
|
|
404
|
+
'inserting without vector:', embedErr.message);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ON CONFLICT uses the composite unique constraint
|
|
408
|
+
const result = await pool.query(
|
|
409
|
+
`INSERT INTO knowledge_chunks
|
|
410
|
+
(project_id, session_date, session_title, pipeline, coverage,
|
|
411
|
+
tdd_iterations, content, content_hash, embedding)
|
|
412
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
413
|
+
ON CONFLICT (project_id, session_date, content_hash) DO UPDATE SET
|
|
414
|
+
content = EXCLUDED.content,
|
|
415
|
+
session_title = COALESCE(EXCLUDED.session_title,
|
|
416
|
+
knowledge_chunks.session_title),
|
|
417
|
+
pipeline = COALESCE(EXCLUDED.pipeline,
|
|
418
|
+
knowledge_chunks.pipeline),
|
|
419
|
+
coverage = COALESCE(EXCLUDED.coverage,
|
|
420
|
+
knowledge_chunks.coverage),
|
|
421
|
+
tdd_iterations = COALESCE(EXCLUDED.tdd_iterations,
|
|
422
|
+
knowledge_chunks.tdd_iterations),
|
|
423
|
+
embedding = COALESCE(EXCLUDED.embedding,
|
|
424
|
+
knowledge_chunks.embedding),
|
|
425
|
+
indexed_at = NOW()
|
|
426
|
+
RETURNING (xmax = 0) AS is_insert`,
|
|
427
|
+
[
|
|
428
|
+
chunk.project_id,
|
|
429
|
+
chunk.session_date || null,
|
|
430
|
+
chunk.session_title || null,
|
|
431
|
+
chunk.pipeline || null,
|
|
432
|
+
chunk.coverage || null,
|
|
433
|
+
chunk.tdd_iterations || 0,
|
|
434
|
+
chunk.content,
|
|
435
|
+
contentHash,
|
|
436
|
+
embeddingSql,
|
|
437
|
+
]
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const row = result.rows[0];
|
|
441
|
+
if (row && row.is_insert) {
|
|
442
|
+
inserted++;
|
|
443
|
+
} else {
|
|
444
|
+
updated++;
|
|
445
|
+
}
|
|
446
|
+
} catch (err) {
|
|
447
|
+
// Unique violation when hash matches exactly → truly a skip
|
|
448
|
+
if (err.code === '23505') {
|
|
449
|
+
skipped++;
|
|
450
|
+
} else {
|
|
451
|
+
console.warn('[knowledgebase] upsertChunks chunk failed:', err.message);
|
|
452
|
+
skipped++;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return { inserted, updated, skipped };
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Search ──────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Semantic search across knowledge chunks.
|
|
464
|
+
*
|
|
465
|
+
* Generates an embedding for the query text and performs cosine similarity
|
|
466
|
+
* search via the <=> pgvector operator. Results are ranked by similarity
|
|
467
|
+
* (0.0–1.0, higher = more relevant).
|
|
468
|
+
*
|
|
469
|
+
* @param {string} query - Natural language query
|
|
470
|
+
* @param {object} [options]
|
|
471
|
+
* @param {string} [options.project_id] - Filter results to a specific project
|
|
472
|
+
* @param {number} [options.threshold=0.0] - Minimum similarity threshold (0.0–1.0)
|
|
473
|
+
* @param {number} [options.limit=5] - Maximum number of results (1–50)
|
|
474
|
+
* @returns {Promise<Array<{
|
|
475
|
+
* project_id: string,
|
|
476
|
+
* session_date: string,
|
|
477
|
+
* session_title: string|null,
|
|
478
|
+
* pipeline: string|null,
|
|
479
|
+
* content: string,
|
|
480
|
+
* similarity: number
|
|
481
|
+
* }>>}
|
|
482
|
+
*/
|
|
483
|
+
async function search(query, options = {}) {
|
|
484
|
+
const pool = await getPool();
|
|
485
|
+
if (!pool) return [];
|
|
486
|
+
|
|
487
|
+
const { project_id, threshold = 0.0, limit = 5 } = options;
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const queryEmbedding = await embed(query);
|
|
491
|
+
|
|
492
|
+
// Parameterised query — never interpolate user input into SQL
|
|
493
|
+
let sql = `
|
|
494
|
+
SELECT
|
|
495
|
+
project_id,
|
|
496
|
+
session_date,
|
|
497
|
+
session_title,
|
|
498
|
+
pipeline,
|
|
499
|
+
content,
|
|
500
|
+
1 - (embedding <=> $1) AS similarity
|
|
501
|
+
FROM knowledge_chunks
|
|
502
|
+
WHERE embedding IS NOT NULL
|
|
503
|
+
AND 1 - (embedding <=> $1) >= $2
|
|
504
|
+
`;
|
|
505
|
+
const params = [_toSql(queryEmbedding), threshold];
|
|
506
|
+
|
|
507
|
+
if (project_id) {
|
|
508
|
+
sql += ` AND project_id = $${params.length + 1}`;
|
|
509
|
+
params.push(project_id);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
sql += ` ORDER BY similarity DESC LIMIT $${params.length + 1}`;
|
|
513
|
+
params.push(limit);
|
|
514
|
+
|
|
515
|
+
const result = await pool.query(sql, params);
|
|
516
|
+
|
|
517
|
+
return result.rows.map((r) => ({
|
|
518
|
+
project_id: r.project_id,
|
|
519
|
+
session_date: r.session_date,
|
|
520
|
+
session_title: r.session_title || null,
|
|
521
|
+
pipeline: r.pipeline || null,
|
|
522
|
+
content: r.content,
|
|
523
|
+
similarity: Math.round(r.similarity * 1000) / 1000,
|
|
524
|
+
}));
|
|
525
|
+
} catch (err) {
|
|
526
|
+
console.warn('[knowledgebase] Search failed:', err.message);
|
|
527
|
+
return [];
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ── Stats ───────────────────────────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Get knowledgebase statistics.
|
|
535
|
+
*
|
|
536
|
+
* Returns aggregate counts and database size information.
|
|
537
|
+
* Returns zero-filled defaults when the database is not configured.
|
|
538
|
+
*
|
|
539
|
+
* @returns {Promise<{
|
|
540
|
+
* total_chunks: number,
|
|
541
|
+
* total_projects: number,
|
|
542
|
+
* db_size: string,
|
|
543
|
+
* last_sync: string|null
|
|
544
|
+
* }>}
|
|
545
|
+
*/
|
|
546
|
+
async function getStats() {
|
|
547
|
+
const pool = await getPool();
|
|
548
|
+
if (!pool) {
|
|
549
|
+
return { total_chunks: 0, total_projects: 0, db_size: '0 MB', last_sync: null };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const [chunksResult, projectsResult, sizeResult, syncResult] =
|
|
554
|
+
await Promise.all([
|
|
555
|
+
pool.query('SELECT COUNT(*)::int AS count FROM knowledge_chunks'),
|
|
556
|
+
pool.query('SELECT COUNT(*)::int AS count FROM projects'),
|
|
557
|
+
pool.query(
|
|
558
|
+
'SELECT pg_size_pretty(pg_database_size(current_database())) AS size'
|
|
559
|
+
),
|
|
560
|
+
pool.query('SELECT MAX(indexed_at) AS last_sync FROM knowledge_chunks'),
|
|
561
|
+
]);
|
|
562
|
+
|
|
563
|
+
return {
|
|
564
|
+
total_chunks: chunksResult.rows[0].count,
|
|
565
|
+
total_projects: projectsResult.rows[0].count,
|
|
566
|
+
db_size: sizeResult.rows[0].size,
|
|
567
|
+
last_sync: syncResult.rows[0].last_sync || null,
|
|
568
|
+
};
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.warn('[knowledgebase] getStats failed:', err.message);
|
|
571
|
+
return { total_chunks: 0, total_projects: 0, db_size: '0 MB', last_sync: null };
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* List all indexed projects with chunk counts and last indexed timestamp.
|
|
577
|
+
*
|
|
578
|
+
* Returns an empty array when the database is not configured.
|
|
579
|
+
*
|
|
580
|
+
* @returns {Promise<Array<{
|
|
581
|
+
* project_id: string,
|
|
582
|
+
* name: string,
|
|
583
|
+
* chunk_count: number,
|
|
584
|
+
* last_indexed: string|null
|
|
585
|
+
* }>>}
|
|
586
|
+
*/
|
|
587
|
+
async function listProjects() {
|
|
588
|
+
const pool = await getPool();
|
|
589
|
+
if (!pool) return [];
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
const result = await pool.query(`
|
|
593
|
+
SELECT
|
|
594
|
+
p.id AS project_id,
|
|
595
|
+
p.name,
|
|
596
|
+
COUNT(kc.id)::int AS chunk_count,
|
|
597
|
+
MAX(kc.indexed_at) AS last_indexed
|
|
598
|
+
FROM projects p
|
|
599
|
+
LEFT JOIN knowledge_chunks kc ON kc.project_id = p.id
|
|
600
|
+
GROUP BY p.id, p.name
|
|
601
|
+
ORDER BY p.name
|
|
602
|
+
`);
|
|
603
|
+
|
|
604
|
+
return result.rows.map((r) => ({
|
|
605
|
+
project_id: r.project_id,
|
|
606
|
+
name: r.name,
|
|
607
|
+
chunk_count: r.chunk_count,
|
|
608
|
+
last_indexed: r.last_indexed || null,
|
|
609
|
+
}));
|
|
610
|
+
} catch (err) {
|
|
611
|
+
console.warn('[knowledgebase] listProjects failed:', err.message);
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ── Chunking Utility ────────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Parse a learned-knowledge.instructions.md file into structured chunks.
|
|
620
|
+
*
|
|
621
|
+
* Splits the markdown content by "## Session:" headers and extracts
|
|
622
|
+
* session metadata (date, pipeline, coverage, TDD iterations) plus
|
|
623
|
+
* the "New knowledge:" bullet content.
|
|
624
|
+
*
|
|
625
|
+
* Only includes sessions that have non-empty "New knowledge" content.
|
|
626
|
+
* Each chunk includes a content_hash for idempotent upserting.
|
|
627
|
+
*
|
|
628
|
+
* @param {string} markdown - Raw markdown content of the learned-knowledge file
|
|
629
|
+
* @param {string} projectId - Project identifier to assign to all chunks
|
|
630
|
+
* @returns {Array<{
|
|
631
|
+
* project_id: string,
|
|
632
|
+
* session_date: string,
|
|
633
|
+
* session_title: string,
|
|
634
|
+
* pipeline: string|null,
|
|
635
|
+
* coverage: string|null,
|
|
636
|
+
* tdd_iterations: number,
|
|
637
|
+
* content: string,
|
|
638
|
+
* content_hash: string
|
|
639
|
+
* }>}
|
|
640
|
+
*/
|
|
641
|
+
function chunkLearnedKnowledge(markdown, projectId) {
|
|
642
|
+
if (!markdown || !projectId) return [];
|
|
643
|
+
|
|
644
|
+
// Split by "## Session: " headers
|
|
645
|
+
const blocks = markdown.split(/(?=^## Session: )/m);
|
|
646
|
+
const chunks = [];
|
|
647
|
+
|
|
648
|
+
for (const block of blocks) {
|
|
649
|
+
const trimmed = block.trim();
|
|
650
|
+
if (!trimmed || !trimmed.startsWith('## Session:')) continue;
|
|
651
|
+
|
|
652
|
+
// Extract session date from heading
|
|
653
|
+
// Format: "## Session: YYYY-MM-DD" or "## Session: YYYY-MM-DD — Title"
|
|
654
|
+
const dateMatch = trimmed.match(/^## Session:\s*(\d{4}-\d{2}-\d{2})/);
|
|
655
|
+
if (!dateMatch) continue;
|
|
656
|
+
const sessionDate = dateMatch[1];
|
|
657
|
+
|
|
658
|
+
// Extract full session title (the heading minus "## ")
|
|
659
|
+
const titleLine = trimmed.match(/^##\s*(.+)$/m);
|
|
660
|
+
const sessionTitle = titleLine
|
|
661
|
+
? titleLine[1].trim()
|
|
662
|
+
: `Session: ${sessionDate}`;
|
|
663
|
+
|
|
664
|
+
// Extract metadata fields
|
|
665
|
+
const pipelineMatch = trimmed.match(/\*\*Pipeline:\*\*\s*(.+)/);
|
|
666
|
+
const pipeline = pipelineMatch ? pipelineMatch[1].trim() : null;
|
|
667
|
+
|
|
668
|
+
const coverageMatch = trimmed.match(/\*\*Coverage:\*\*\s*(.+)/);
|
|
669
|
+
const coverage = coverageMatch ? coverageMatch[1].trim() : null;
|
|
670
|
+
|
|
671
|
+
const tddMatch = trimmed.match(/\*\*TDD Iterations:\*\*\s*(\d+)/);
|
|
672
|
+
const tddIterations = tddMatch ? parseInt(tddMatch[1], 10) : 0;
|
|
673
|
+
|
|
674
|
+
// Extract "New knowledge:" content
|
|
675
|
+
// Find the marker position, then capture all text until the next section
|
|
676
|
+
// header (e.g., "**Agent tuning notes:**"). This correctly handles
|
|
677
|
+
// knowledge bullets that contain inline bold (e.g., "**npm v12 blocks**"),
|
|
678
|
+
// which the previous regex incorrectly stopped at by matching the first
|
|
679
|
+
// "**" it encountered instead of the next section header's "**".
|
|
680
|
+
const nkMarker = '**New knowledge:**';
|
|
681
|
+
const nkIndex = trimmed.indexOf(nkMarker);
|
|
682
|
+
if (nkIndex === -1) continue;
|
|
683
|
+
|
|
684
|
+
const afterNK = trimmed.slice(nkIndex + nkMarker.length);
|
|
685
|
+
|
|
686
|
+
// Find the next section header: a line starting with "**" and ending
|
|
687
|
+
// with ":**" (e.g., "**Agent tuning notes:**"). The [^:\n]* ensures we
|
|
688
|
+
// match the closing ":**" after the header name, not inline bold like
|
|
689
|
+
// "**npm v12 blocks...**" which appears within bullet content.
|
|
690
|
+
const nextSectionRegex = /(?:\n|^)\s*\*\*[^:\n]*:\*\*/m;
|
|
691
|
+
const nextSectionMatch = afterNK.match(nextSectionRegex);
|
|
692
|
+
|
|
693
|
+
const rawNK = nextSectionMatch
|
|
694
|
+
? afterNK.slice(0, nextSectionMatch.index)
|
|
695
|
+
: afterNK;
|
|
696
|
+
|
|
697
|
+
const newKnowledgeRaw = rawNK.trim();
|
|
698
|
+
|
|
699
|
+
// Process knowledge content or use fallback
|
|
700
|
+
let content;
|
|
701
|
+
|
|
702
|
+
if (newKnowledgeRaw) {
|
|
703
|
+
// Strip leading "- " or "* " from each bullet line while preserving
|
|
704
|
+
// indentation of sub-bullets and continuation lines
|
|
705
|
+
const bullets = newKnowledgeRaw
|
|
706
|
+
.split('\n')
|
|
707
|
+
.map((line) => {
|
|
708
|
+
const bulletMatch = line.match(/^(\s*)[-*]\s+(.*)/);
|
|
709
|
+
if (bulletMatch) {
|
|
710
|
+
return bulletMatch[1] + bulletMatch[2].trim();
|
|
711
|
+
}
|
|
712
|
+
// Continuation or sub-bullet: keep content, trim trailing whitespace
|
|
713
|
+
return line.trimRight();
|
|
714
|
+
})
|
|
715
|
+
.filter((line) => line.length > 0);
|
|
716
|
+
|
|
717
|
+
if (bullets.length > 0) {
|
|
718
|
+
content = bullets.join('\n');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Fallback: if no knowledge bullets were extracted (section is empty),
|
|
723
|
+
// extract all non-header text from the session — metadata values,
|
|
724
|
+
// other section content, etc. — as a fallback so the session is not
|
|
725
|
+
// entirely lost from the knowledgebase.
|
|
726
|
+
if (!content) {
|
|
727
|
+
const standaloneHeader = /^\*\*[^*:]+:\*\*\s*$/;
|
|
728
|
+
const fallbackLines = trimmed
|
|
729
|
+
.split('\n')
|
|
730
|
+
.filter((line) => {
|
|
731
|
+
const l = line.trim();
|
|
732
|
+
// Skip the session heading line
|
|
733
|
+
if (/^##\s/.test(l)) return false;
|
|
734
|
+
// Skip standalone section headers (bold with nothing after colon)
|
|
735
|
+
if (standaloneHeader.test(l)) return false;
|
|
736
|
+
// Skip empty lines
|
|
737
|
+
if (!l) return false;
|
|
738
|
+
return true;
|
|
739
|
+
})
|
|
740
|
+
.join('\n');
|
|
741
|
+
|
|
742
|
+
const fallback = fallbackLines.trim();
|
|
743
|
+
if (!fallback) continue;
|
|
744
|
+
content = fallback;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const contentHash = hashContent(
|
|
748
|
+
content + sessionDate + (pipeline || '')
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
chunks.push({
|
|
752
|
+
project_id: projectId,
|
|
753
|
+
session_date: sessionDate,
|
|
754
|
+
session_title: sessionTitle,
|
|
755
|
+
pipeline,
|
|
756
|
+
coverage,
|
|
757
|
+
tdd_iterations: tddIterations,
|
|
758
|
+
content,
|
|
759
|
+
content_hash: contentHash,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return chunks;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ── Exports ─────────────────────────────────────────────────────────────
|
|
767
|
+
|
|
768
|
+
export {
|
|
769
|
+
chunkLearnedKnowledge,
|
|
770
|
+
closePool,
|
|
771
|
+
embed,
|
|
772
|
+
ensureSchema,
|
|
773
|
+
getPool,
|
|
774
|
+
getStats,
|
|
775
|
+
listProjects,
|
|
776
|
+
registerProject,
|
|
777
|
+
search,
|
|
778
|
+
setEmbeddingProvider,
|
|
779
|
+
upsertChunks,
|
|
780
|
+
};
|