@abarcenas/ai-workflow-template 1.34.0 → 1.39.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.
Files changed (34) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +238 -0
  2. package/.agents/instructions/learned-knowledge.instructions.md +91 -0
  3. package/.husky/post-commit +10 -0
  4. package/.husky/post-merge +0 -1
  5. package/.opencode/agents/architect.agent.md +1 -0
  6. package/.opencode/agents/coder.agent.md +1 -0
  7. package/.opencode/agents/deployer.agent.md +1 -0
  8. package/.opencode/agents/designer.agent.md +1 -0
  9. package/.opencode/agents/e2e-tester.agent.md +1 -0
  10. package/.opencode/agents/implementer.agent.md +1 -0
  11. package/.opencode/agents/orchestrator/feature-pipeline.agent.md +1 -0
  12. package/.opencode/agents/orchestrator/orchestrator.agent.md +1 -0
  13. package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +1 -0
  14. package/.opencode/agents/researcher.agent.md +1 -0
  15. package/.opencode/agents/reviewer.agent.md +1 -0
  16. package/.opencode/agents/tracker.agent.md +2 -0
  17. package/.opencode/agents/unit-tester.agent.md +1 -0
  18. package/README.md +1 -1
  19. package/memory-bank/.vocabulary.json +8 -2
  20. package/package.json +12 -6
  21. package/scripts/knowledgebase-cli.js +228 -0
  22. package/scripts/knowledgebase-cli.test.js +576 -0
  23. package/scripts/knowledgebase-index.js +786 -0
  24. package/scripts/knowledgebase-index.test.js +759 -0
  25. package/scripts/knowledgebase-init.sql +193 -0
  26. package/scripts/mcp-knowledgebase-server.js +330 -0
  27. package/scripts/setup/constants.js +36 -3
  28. package/scripts/setup/hooks.js +2 -2
  29. package/scripts/setup/hooks.test.js +13 -5
  30. package/scripts/setup/index.js +29 -2
  31. package/scripts/setup/index.test.js +36 -1
  32. package/scripts/setup/knowledgebase.js +204 -0
  33. package/scripts/setup/knowledgebase.test.js +248 -0
  34. package/scripts/sync.js +40 -6
@@ -0,0 +1,786 @@
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
+ *
294
+ * NOTE: pgvector@0.3.0's `toSql()` rejects typed arrays (`Array.isArray()`
295
+ * returns false for Float32Array and it throws 'expected array or sparse
296
+ * vector'). Callers MUST convert to a plain Array (`Array.from(vec)`) at the
297
+ * pgvector boundary before passing to `_toSql()`.
298
+ *
299
+ * @param {string} text - Text to embed
300
+ * @returns {Promise<Float32Array>} Float32Array of 384 floats
301
+ * @throws {Error} When the embedding model is not available
302
+ */
303
+ async function embed(text) {
304
+ const model = await getEmbedder();
305
+ if (!model) {
306
+ throw new Error(
307
+ 'Embedding model not available. Install @xenova/transformers.'
308
+ );
309
+ }
310
+
311
+ const result = await model(text, { pooling: 'mean', normalize: true });
312
+ // Wrap in Float32Array — the pipeline may return a different typed array type
313
+ return new Float32Array(result.data);
314
+ }
315
+
316
+ /**
317
+ * Set the embedding provider strategy.
318
+ *
319
+ * @param {'local'|'openai'} provider - Embedding backend to use
320
+ * 'local' — @xenova/transformers all-MiniLM-L6-v2 (384d, default)
321
+ * 'openai' — OpenAI text-embedding-3-small (1536d, requires OPENAI_API_KEY)
322
+ */
323
+ function setEmbeddingProvider(provider) {
324
+ // Currently only 'local' is implemented.
325
+ // Future: 'openai' support will use OpenAI API with 1536-d embeddings.
326
+ console.warn(`[knowledgebase] Embedding provider set to: ${provider}`);
327
+ }
328
+
329
+ // ── CRUD ───────────────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Register or update a project in the projects table.
333
+ *
334
+ * Creates the project if it doesn't exist, or updates the display name
335
+ * and last_indexed_at timestamp if it does (idempotent upsert).
336
+ *
337
+ * @param {string} projectId - Project identifier (from package.json "name")
338
+ * @param {string} name - Display name for the project
339
+ * @returns {Promise<{id: string, first_indexed_at: string|null}>}
340
+ */
341
+ async function registerProject(projectId, name) {
342
+ const pool = await getPool();
343
+ if (!pool) return { id: projectId, first_indexed_at: null };
344
+
345
+ try {
346
+ const result = await pool.query(
347
+ `INSERT INTO projects (id, name)
348
+ VALUES ($1, $2)
349
+ ON CONFLICT (id) DO UPDATE SET
350
+ name = EXCLUDED.name,
351
+ last_indexed_at = NOW()
352
+ RETURNING id, first_indexed_at`,
353
+ [projectId, name]
354
+ );
355
+ return result.rows[0];
356
+ } catch (err) {
357
+ console.warn('[knowledgebase] registerProject failed:', err.message);
358
+ return { id: projectId, first_indexed_at: null };
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Upsert multiple knowledge chunks in batch.
364
+ *
365
+ * Each chunk is embedded and upserted with ON CONFLICT idempotency using
366
+ * the (project_id, session_date, content_hash) unique constraint.
367
+ * Chunks with matching hashes update metadata but preserve idempotency.
368
+ *
369
+ * @param {Array<{
370
+ * project_id: string,
371
+ * session_date: string,
372
+ * session_title?: string,
373
+ * pipeline?: string,
374
+ * coverage?: string,
375
+ * tdd_iterations?: number,
376
+ * content: string,
377
+ * content_hash?: string
378
+ * }>} chunks - Array of chunk objects to upsert
379
+ * @returns {Promise<{inserted: number, updated: number, skipped: number}>}
380
+ */
381
+ async function upsertChunks(chunks) {
382
+ const pool = await getPool();
383
+ if (!pool || !Array.isArray(chunks) || chunks.length === 0) {
384
+ return { inserted: 0, updated: 0, skipped: 0 };
385
+ }
386
+
387
+ let inserted = 0;
388
+ let updated = 0;
389
+ let skipped = 0;
390
+
391
+ for (const chunk of chunks) {
392
+ try {
393
+ // Compute content hash if not provided
394
+ const contentHash = chunk.content_hash ||
395
+ hashContent(chunk.content + (chunk.session_date || '') + (chunk.pipeline || ''));
396
+
397
+ // Generate embedding from session-contextualised content
398
+ let embeddingSql = null;
399
+ try {
400
+ const contextText = `Session: ${chunk.session_date || ''}` +
401
+ (chunk.pipeline ? ` — Pipeline: ${chunk.pipeline}` : '') +
402
+ `\n${chunk.content}`;
403
+ const vec = await embed(contextText);
404
+ // pgvector toSql() requires a plain Array (Array.isArray() === true);
405
+ // Float32Array would throw 'expected array or sparse vector'.
406
+ embeddingSql = _toSql(Array.from(vec));
407
+ } catch (embedErr) {
408
+ console.warn('[knowledgebase] Embedding failed for chunk, ' +
409
+ 'inserting without vector:', embedErr.message);
410
+ }
411
+
412
+ // ON CONFLICT uses the composite unique constraint
413
+ const result = await pool.query(
414
+ `INSERT INTO knowledge_chunks
415
+ (project_id, session_date, session_title, pipeline, coverage,
416
+ tdd_iterations, content, content_hash, embedding)
417
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
418
+ ON CONFLICT (project_id, session_date, content_hash) DO UPDATE SET
419
+ content = EXCLUDED.content,
420
+ session_title = COALESCE(EXCLUDED.session_title,
421
+ knowledge_chunks.session_title),
422
+ pipeline = COALESCE(EXCLUDED.pipeline,
423
+ knowledge_chunks.pipeline),
424
+ coverage = COALESCE(EXCLUDED.coverage,
425
+ knowledge_chunks.coverage),
426
+ tdd_iterations = COALESCE(EXCLUDED.tdd_iterations,
427
+ knowledge_chunks.tdd_iterations),
428
+ embedding = COALESCE(EXCLUDED.embedding,
429
+ knowledge_chunks.embedding),
430
+ indexed_at = NOW()
431
+ RETURNING (xmax = 0) AS is_insert`,
432
+ [
433
+ chunk.project_id,
434
+ chunk.session_date || null,
435
+ chunk.session_title || null,
436
+ chunk.pipeline || null,
437
+ chunk.coverage || null,
438
+ chunk.tdd_iterations || 0,
439
+ chunk.content,
440
+ contentHash,
441
+ embeddingSql,
442
+ ]
443
+ );
444
+
445
+ const row = result.rows[0];
446
+ if (row && row.is_insert) {
447
+ inserted++;
448
+ } else {
449
+ updated++;
450
+ }
451
+ } catch (err) {
452
+ // Unique violation when hash matches exactly → truly a skip
453
+ if (err.code === '23505') {
454
+ skipped++;
455
+ } else {
456
+ console.warn('[knowledgebase] upsertChunks chunk failed:', err.message);
457
+ skipped++;
458
+ }
459
+ }
460
+ }
461
+
462
+ return { inserted, updated, skipped };
463
+ }
464
+
465
+ // ── Search ──────────────────────────────────────────────────────────────
466
+
467
+ /**
468
+ * Semantic search across knowledge chunks.
469
+ *
470
+ * Generates an embedding for the query text and performs cosine similarity
471
+ * search via the <=> pgvector operator. Results are ranked by similarity
472
+ * (0.0–1.0, higher = more relevant).
473
+ *
474
+ * @param {string} query - Natural language query
475
+ * @param {object} [options]
476
+ * @param {string} [options.project_id] - Filter results to a specific project
477
+ * @param {number} [options.threshold=0.0] - Minimum similarity threshold (0.0–1.0)
478
+ * @param {number} [options.limit=5] - Maximum number of results (1–50)
479
+ * @returns {Promise<Array<{
480
+ * project_id: string,
481
+ * session_date: string,
482
+ * session_title: string|null,
483
+ * pipeline: string|null,
484
+ * content: string,
485
+ * similarity: number
486
+ * }>>}
487
+ */
488
+ async function search(query, options = {}) {
489
+ const pool = await getPool();
490
+ if (!pool) return [];
491
+
492
+ const { project_id, threshold = 0.0, limit = 5 } = options;
493
+
494
+ try {
495
+ const queryEmbedding = await embed(query);
496
+
497
+ // Parameterised query — never interpolate user input into SQL
498
+ let sql = `
499
+ SELECT
500
+ project_id,
501
+ session_date,
502
+ session_title,
503
+ pipeline,
504
+ content,
505
+ 1 - (embedding <=> $1) AS similarity
506
+ FROM knowledge_chunks
507
+ WHERE embedding IS NOT NULL
508
+ AND 1 - (embedding <=> $1) >= $2
509
+ `;
510
+ // pgvector toSql() requires a plain Array (see embed() note)
511
+ const params = [_toSql(Array.from(queryEmbedding)), threshold];
512
+
513
+ if (project_id) {
514
+ sql += ` AND project_id = $${params.length + 1}`;
515
+ params.push(project_id);
516
+ }
517
+
518
+ sql += ` ORDER BY similarity DESC LIMIT $${params.length + 1}`;
519
+ params.push(limit);
520
+
521
+ const result = await pool.query(sql, params);
522
+
523
+ return result.rows.map((r) => ({
524
+ project_id: r.project_id,
525
+ session_date: r.session_date,
526
+ session_title: r.session_title || null,
527
+ pipeline: r.pipeline || null,
528
+ content: r.content,
529
+ similarity: Math.round(r.similarity * 1000) / 1000,
530
+ }));
531
+ } catch (err) {
532
+ console.warn('[knowledgebase] Search failed:', err.message);
533
+ return [];
534
+ }
535
+ }
536
+
537
+ // ── Stats ───────────────────────────────────────────────────────────────
538
+
539
+ /**
540
+ * Get knowledgebase statistics.
541
+ *
542
+ * Returns aggregate counts and database size information.
543
+ * Returns zero-filled defaults when the database is not configured.
544
+ *
545
+ * @returns {Promise<{
546
+ * total_chunks: number,
547
+ * total_projects: number,
548
+ * db_size: string,
549
+ * last_sync: string|null
550
+ * }>}
551
+ */
552
+ async function getStats() {
553
+ const pool = await getPool();
554
+ if (!pool) {
555
+ return { total_chunks: 0, total_projects: 0, db_size: '0 MB', last_sync: null };
556
+ }
557
+
558
+ try {
559
+ const [chunksResult, projectsResult, sizeResult, syncResult] =
560
+ await Promise.all([
561
+ pool.query('SELECT COUNT(*)::int AS count FROM knowledge_chunks'),
562
+ pool.query('SELECT COUNT(*)::int AS count FROM projects'),
563
+ pool.query(
564
+ 'SELECT pg_size_pretty(pg_database_size(current_database())) AS size'
565
+ ),
566
+ pool.query('SELECT MAX(indexed_at) AS last_sync FROM knowledge_chunks'),
567
+ ]);
568
+
569
+ return {
570
+ total_chunks: chunksResult.rows[0].count,
571
+ total_projects: projectsResult.rows[0].count,
572
+ db_size: sizeResult.rows[0].size,
573
+ last_sync: syncResult.rows[0].last_sync || null,
574
+ };
575
+ } catch (err) {
576
+ console.warn('[knowledgebase] getStats failed:', err.message);
577
+ return { total_chunks: 0, total_projects: 0, db_size: '0 MB', last_sync: null };
578
+ }
579
+ }
580
+
581
+ /**
582
+ * List all indexed projects with chunk counts and last indexed timestamp.
583
+ *
584
+ * Returns an empty array when the database is not configured.
585
+ *
586
+ * @returns {Promise<Array<{
587
+ * project_id: string,
588
+ * name: string,
589
+ * chunk_count: number,
590
+ * last_indexed: string|null
591
+ * }>>}
592
+ */
593
+ async function listProjects() {
594
+ const pool = await getPool();
595
+ if (!pool) return [];
596
+
597
+ try {
598
+ const result = await pool.query(`
599
+ SELECT
600
+ p.id AS project_id,
601
+ p.name,
602
+ COUNT(kc.id)::int AS chunk_count,
603
+ MAX(kc.indexed_at) AS last_indexed
604
+ FROM projects p
605
+ LEFT JOIN knowledge_chunks kc ON kc.project_id = p.id
606
+ GROUP BY p.id, p.name
607
+ ORDER BY p.name
608
+ `);
609
+
610
+ return result.rows.map((r) => ({
611
+ project_id: r.project_id,
612
+ name: r.name,
613
+ chunk_count: r.chunk_count,
614
+ last_indexed: r.last_indexed || null,
615
+ }));
616
+ } catch (err) {
617
+ console.warn('[knowledgebase] listProjects failed:', err.message);
618
+ return [];
619
+ }
620
+ }
621
+
622
+ // ── Chunking Utility ────────────────────────────────────────────────────
623
+
624
+ /**
625
+ * Parse a learned-knowledge.instructions.md file into structured chunks.
626
+ *
627
+ * Splits the markdown content by "## Session:" headers and extracts
628
+ * session metadata (date, pipeline, coverage, TDD iterations) plus
629
+ * the "New knowledge:" bullet content.
630
+ *
631
+ * Only includes sessions that have non-empty "New knowledge" content.
632
+ * Each chunk includes a content_hash for idempotent upserting.
633
+ *
634
+ * @param {string} markdown - Raw markdown content of the learned-knowledge file
635
+ * @param {string} projectId - Project identifier to assign to all chunks
636
+ * @returns {Array<{
637
+ * project_id: string,
638
+ * session_date: string,
639
+ * session_title: string,
640
+ * pipeline: string|null,
641
+ * coverage: string|null,
642
+ * tdd_iterations: number,
643
+ * content: string,
644
+ * content_hash: string
645
+ * }>}
646
+ */
647
+ function chunkLearnedKnowledge(markdown, projectId) {
648
+ if (!markdown || !projectId) return [];
649
+
650
+ // Split by "## Session: " headers
651
+ const blocks = markdown.split(/(?=^## Session: )/m);
652
+ const chunks = [];
653
+
654
+ for (const block of blocks) {
655
+ const trimmed = block.trim();
656
+ if (!trimmed || !trimmed.startsWith('## Session:')) continue;
657
+
658
+ // Extract session date from heading
659
+ // Format: "## Session: YYYY-MM-DD" or "## Session: YYYY-MM-DD — Title"
660
+ const dateMatch = trimmed.match(/^## Session:\s*(\d{4}-\d{2}-\d{2})/);
661
+ if (!dateMatch) continue;
662
+ const sessionDate = dateMatch[1];
663
+
664
+ // Extract full session title (the heading minus "## ")
665
+ const titleLine = trimmed.match(/^##\s*(.+)$/m);
666
+ const sessionTitle = titleLine
667
+ ? titleLine[1].trim()
668
+ : `Session: ${sessionDate}`;
669
+
670
+ // Extract metadata fields
671
+ const pipelineMatch = trimmed.match(/\*\*Pipeline:\*\*\s*(.+)/);
672
+ const pipeline = pipelineMatch ? pipelineMatch[1].trim() : null;
673
+
674
+ const coverageMatch = trimmed.match(/\*\*Coverage:\*\*\s*(.+)/);
675
+ const coverage = coverageMatch ? coverageMatch[1].trim() : null;
676
+
677
+ const tddMatch = trimmed.match(/\*\*TDD Iterations:\*\*\s*(\d+)/);
678
+ const tddIterations = tddMatch ? parseInt(tddMatch[1], 10) : 0;
679
+
680
+ // Extract "New knowledge:" content
681
+ // Find the marker position, then capture all text until the next section
682
+ // header (e.g., "**Agent tuning notes:**"). This correctly handles
683
+ // knowledge bullets that contain inline bold (e.g., "**npm v12 blocks**"),
684
+ // which the previous regex incorrectly stopped at by matching the first
685
+ // "**" it encountered instead of the next section header's "**".
686
+ const nkMarker = '**New knowledge:**';
687
+ const nkIndex = trimmed.indexOf(nkMarker);
688
+ if (nkIndex === -1) continue;
689
+
690
+ const afterNK = trimmed.slice(nkIndex + nkMarker.length);
691
+
692
+ // Find the next section header: a line starting with "**" and ending
693
+ // with ":**" (e.g., "**Agent tuning notes:**"). The [^:\n]* ensures we
694
+ // match the closing ":**" after the header name, not inline bold like
695
+ // "**npm v12 blocks...**" which appears within bullet content.
696
+ const nextSectionRegex = /(?:\n|^)\s*\*\*[^:\n]*:\*\*/m;
697
+ const nextSectionMatch = afterNK.match(nextSectionRegex);
698
+
699
+ const rawNK = nextSectionMatch
700
+ ? afterNK.slice(0, nextSectionMatch.index)
701
+ : afterNK;
702
+
703
+ const newKnowledgeRaw = rawNK.trim();
704
+
705
+ // Process knowledge content or use fallback
706
+ let content;
707
+
708
+ if (newKnowledgeRaw) {
709
+ // Strip leading "- " or "* " from each bullet line while preserving
710
+ // indentation of sub-bullets and continuation lines
711
+ const bullets = newKnowledgeRaw
712
+ .split('\n')
713
+ .map((line) => {
714
+ const bulletMatch = line.match(/^(\s*)[-*]\s+(.*)/);
715
+ if (bulletMatch) {
716
+ return bulletMatch[1] + bulletMatch[2].trim();
717
+ }
718
+ // Continuation or sub-bullet: keep content, trim trailing whitespace
719
+ return line.trimRight();
720
+ })
721
+ .filter((line) => line.length > 0);
722
+
723
+ if (bullets.length > 0) {
724
+ content = bullets.join('\n');
725
+ }
726
+ }
727
+
728
+ // Fallback: if no knowledge bullets were extracted (section is empty),
729
+ // extract all non-header text from the session — metadata values,
730
+ // other section content, etc. — as a fallback so the session is not
731
+ // entirely lost from the knowledgebase.
732
+ if (!content) {
733
+ const standaloneHeader = /^\*\*[^*:]+:\*\*\s*$/;
734
+ const fallbackLines = trimmed
735
+ .split('\n')
736
+ .filter((line) => {
737
+ const l = line.trim();
738
+ // Skip the session heading line
739
+ if (/^##\s/.test(l)) return false;
740
+ // Skip standalone section headers (bold with nothing after colon)
741
+ if (standaloneHeader.test(l)) return false;
742
+ // Skip empty lines
743
+ if (!l) return false;
744
+ return true;
745
+ })
746
+ .join('\n');
747
+
748
+ const fallback = fallbackLines.trim();
749
+ if (!fallback) continue;
750
+ content = fallback;
751
+ }
752
+
753
+ const contentHash = hashContent(
754
+ content + sessionDate + (pipeline || '')
755
+ );
756
+
757
+ chunks.push({
758
+ project_id: projectId,
759
+ session_date: sessionDate,
760
+ session_title: sessionTitle,
761
+ pipeline,
762
+ coverage,
763
+ tdd_iterations: tddIterations,
764
+ content,
765
+ content_hash: contentHash,
766
+ });
767
+ }
768
+
769
+ return chunks;
770
+ }
771
+
772
+ // ── Exports ─────────────────────────────────────────────────────────────
773
+
774
+ export {
775
+ chunkLearnedKnowledge,
776
+ closePool,
777
+ embed,
778
+ ensureSchema,
779
+ getPool,
780
+ getStats,
781
+ listProjects,
782
+ registerProject,
783
+ search,
784
+ setEmbeddingProvider,
785
+ upsertChunks,
786
+ };