@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.
Files changed (34) hide show
  1. package/.agents/instructions/knowledgebase.instructions.md +238 -0
  2. package/.agents/instructions/learned-knowledge.instructions.md +71 -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 +780 -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,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
+ };