@assistkick/create 1.31.0 → 1.33.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 (65) hide show
  1. package/package.json +1 -1
  2. package/templates/assistkick-product-system/package.json +3 -1
  3. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +10 -3
  4. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +8 -1
  5. package/templates/assistkick-product-system/packages/shared/db/migrate.ts +174 -3
  6. package/templates/assistkick-product-system/packages/shared/db/migrations/0003_solid_manta.sql +1 -0
  7. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0003_snapshot.json +992 -18
  8. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
  9. package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
  10. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +12 -0
  11. package/templates/assistkick-product-system/packages/shared/lib/embedding_service.test.ts +75 -0
  12. package/templates/assistkick-product-system/packages/shared/lib/embedding_service.ts +100 -0
  13. package/templates/assistkick-product-system/packages/shared/lib/relevance_search.ts +50 -38
  14. package/templates/assistkick-product-system/packages/shared/package.json +1 -0
  15. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +11 -3
  16. package/templates/assistkick-product-system/packages/shared/tools/delete_note.ts +50 -0
  17. package/templates/assistkick-product-system/packages/shared/tools/save_note.ts +79 -0
  18. package/templates/assistkick-product-system/packages/shared/tools/search_nodes.ts +6 -1
  19. package/templates/assistkick-product-system/packages/shared/tools/search_notes.ts +99 -0
  20. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +15 -0
  21. package/templates/assistkick-product-system/packages/shared/db/migrations/0000_dashing_gorgon.sql +0 -128
  22. package/templates/assistkick-product-system/packages/shared/db/migrations/0001_vengeful_wallop.sql +0 -1
  23. package/templates/assistkick-product-system/packages/shared/db/migrations/0002_greedy_excalibur.sql +0 -4
  24. package/templates/assistkick-product-system/packages/shared/db/migrations/0003_lonely_cyclops.sql +0 -17
  25. package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +0 -9
  26. package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +0 -692
  27. package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +0 -9
  28. package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +0 -39
  29. package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +0 -1
  30. package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +0 -8
  31. package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +0 -9
  32. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +0 -3
  33. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +0 -3
  34. package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +0 -15
  35. package/templates/assistkick-product-system/packages/shared/db/migrations/0014_nifty_punisher.sql +0 -15
  36. package/templates/assistkick-product-system/packages/shared/db/migrations/0015_magenta_jazinda.sql +0 -1
  37. package/templates/assistkick-product-system/packages/shared/db/migrations/0016_giant_xorn.sql +0 -1
  38. package/templates/assistkick-product-system/packages/shared/db/migrations/0017_sloppy_mentor.sql +0 -6
  39. package/templates/assistkick-product-system/packages/shared/db/migrations/0018_vengeful_kabuki.sql +0 -9
  40. package/templates/assistkick-product-system/packages/shared/db/migrations/0019_careful_sentinels.sql +0 -8
  41. package/templates/assistkick-product-system/packages/shared/db/migrations/0020_clever_spot.sql +0 -27
  42. package/templates/assistkick-product-system/packages/shared/db/migrations/0021_graceful_hex.sql +0 -1
  43. package/templates/assistkick-product-system/packages/shared/db/migrations/0022_short_kingpin.sql +0 -1
  44. package/templates/assistkick-product-system/packages/shared/db/migrations/0023_ambiguous_sharon_carter.sql +0 -1
  45. package/templates/assistkick-product-system/packages/shared/db/migrations/0024_fat_unus.sql +0 -1
  46. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +0 -921
  47. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +0 -1042
  48. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +0 -1101
  49. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +0 -1336
  50. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +0 -1275
  51. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +0 -1327
  52. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +0 -1393
  53. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +0 -1436
  54. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +0 -1538
  55. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0014_snapshot.json +0 -1545
  56. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0015_snapshot.json +0 -1552
  57. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0016_snapshot.json +0 -1560
  58. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0017_snapshot.json +0 -1598
  59. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0018_snapshot.json +0 -1657
  60. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0019_snapshot.json +0 -1709
  61. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0020_snapshot.json +0 -1733
  62. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0021_snapshot.json +0 -1740
  63. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0022_snapshot.json +0 -1755
  64. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0023_snapshot.json +0 -1762
  65. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0024_snapshot.json +0 -1769
@@ -22,6 +22,13 @@
22
22
  "when": 1773918822690,
23
23
  "tag": "0002_noisy_maelstrom",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "6",
29
+ "when": 1774032107925,
30
+ "tag": "0003_solid_manta",
31
+ "breakpoints": true
25
32
  }
26
33
  ]
27
34
  }
@@ -15,6 +15,7 @@ export const nodes = sqliteTable('nodes', {
15
15
  updatedAt: text('updated_at').notNull(),
16
16
  body: text('body'),
17
17
  projectId: text('project_id'),
18
+ embedding: text('embedding'),
18
19
  });
19
20
 
20
21
  // --- edges table ---
@@ -19,6 +19,7 @@ export const NODE_TYPES = {
19
19
  flow: 'flow',
20
20
  assumption: 'asmp',
21
21
  open_question: 'oq',
22
+ note: 'note',
22
23
  };
23
24
 
24
25
  // Required sections per node type — completeness scoring checks these
@@ -36,6 +37,7 @@ export const REQUIRED_SECTIONS = {
36
37
  flow: ['Description', 'Steps', 'Actors'],
37
38
  assumption: ['Description', 'Confidence', 'Depends On'],
38
39
  open_question: ['Description', 'Blocking', 'Affects'],
40
+ note: [],
39
41
  };
40
42
 
41
43
  // Valid edge relation types
@@ -330,7 +332,17 @@ export const SCORING_RULES = {
330
332
  check: (_fm, sections) => (sections['Affects'] || '').trim().length > 0,
331
333
  },
332
334
  ],
335
+ note: [
336
+ {
337
+ weight: 1.0,
338
+ description: 'Notes always have full completeness',
339
+ check: () => true,
340
+ },
341
+ ],
333
342
  };
334
343
 
344
+ // Node types that should be excluded from the kanban board
345
+ export const KANBAN_EXCLUDED_TYPES = new Set(['note']);
346
+
335
347
  // Optional sections that any node can have
336
348
  export const COMMON_OPTIONAL_SECTIONS = ['Resolved Questions', 'Notes', 'Relations'];
@@ -0,0 +1,75 @@
1
+ import {describe, it} from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {buildEmbeddingText, cosineSimilarity, embed} from './embedding_service.js';
4
+
5
+ describe('cosineSimilarity', () => {
6
+ it('returns 1.0 for identical vectors', () => {
7
+ const v = [0.1, 0.2, 0.3, 0.4];
8
+ const sim = cosineSimilarity(v, v);
9
+ assert.ok(Math.abs(sim - 1.0) < 0.0001, `Expected ~1.0, got ${sim}`);
10
+ });
11
+
12
+ it('returns 0.0 for orthogonal vectors', () => {
13
+ const a = [1, 0, 0];
14
+ const b = [0, 1, 0];
15
+ const sim = cosineSimilarity(a, b);
16
+ assert.ok(Math.abs(sim) < 0.0001, `Expected ~0.0, got ${sim}`);
17
+ });
18
+
19
+ it('returns -1.0 for opposite vectors', () => {
20
+ const a = [1, 0, 0];
21
+ const b = [-1, 0, 0];
22
+ const sim = cosineSimilarity(a, b);
23
+ assert.ok(Math.abs(sim - (-1.0)) < 0.0001, `Expected ~-1.0, got ${sim}`);
24
+ });
25
+ });
26
+
27
+ describe('buildEmbeddingText', () => {
28
+ it('returns just the name when no body', () => {
29
+ assert.equal(buildEmbeddingText('My Note', null), 'My Note');
30
+ assert.equal(buildEmbeddingText('My Note'), 'My Note');
31
+ });
32
+
33
+ it('combines name and body', () => {
34
+ const text = buildEmbeddingText('Auth Flow', '## Description\nHandles login');
35
+ assert.ok(text.includes('Auth Flow'));
36
+ assert.ok(text.includes('Handles login'));
37
+ });
38
+
39
+ it('strips section headers from body', () => {
40
+ const text = buildEmbeddingText('Test', '## Description\nSome content\n## Notes\nMore content');
41
+ assert.ok(!text.includes('## Description'));
42
+ assert.ok(!text.includes('## Notes'));
43
+ assert.ok(text.includes('Some content'));
44
+ assert.ok(text.includes('More content'));
45
+ });
46
+
47
+ it('truncates long body to 500 chars', () => {
48
+ const longBody = 'a'.repeat(1000);
49
+ const text = buildEmbeddingText('Name', longBody);
50
+ // name + newline + 500 chars
51
+ assert.ok(text.length <= 'Name'.length + 1 + 500);
52
+ });
53
+ });
54
+
55
+ describe('embed', () => {
56
+ it('returns a 384-dimensional vector', async () => {
57
+ const vector = await embed('JWT tokens expire after 24 hours');
58
+ assert.equal(vector.length, 384);
59
+ assert.equal(typeof vector[0], 'number');
60
+ });
61
+
62
+ it('produces similar vectors for related concepts', async () => {
63
+ const jwtVector = await embed('JWT tokens expire after 24 hours');
64
+ const authVector = await embed('authentication session duration');
65
+ const cookingVector = await embed('baking chocolate chip cookies');
66
+
67
+ const relatedSim = cosineSimilarity(jwtVector, authVector);
68
+ const unrelatedSim = cosineSimilarity(jwtVector, cookingVector);
69
+
70
+ assert.ok(relatedSim > unrelatedSim,
71
+ `Related similarity (${relatedSim}) should be > unrelated (${unrelatedSim})`);
72
+ assert.ok(relatedSim > 0.3,
73
+ `Related concepts should have similarity > 0.3, got ${relatedSim}`);
74
+ });
75
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Embedding service — computes sentence embeddings locally using
3
+ * @huggingface/transformers with the all-MiniLM-L6-v2 ONNX model.
4
+ *
5
+ * Lazy-loads the model on first use (~1-2s). Subsequent calls are fast (~5-20ms).
6
+ * Embeddings are 384-dimensional Float32 vectors.
7
+ */
8
+
9
+ import {eq, isNull} from 'drizzle-orm';
10
+
11
+ const MODEL_NAME = 'Xenova/all-MiniLM-L6-v2';
12
+
13
+ let _pipeline: any = null;
14
+
15
+ /**
16
+ * Get or create the feature-extraction pipeline (lazy singleton).
17
+ */
18
+ const getPipeline = async () => {
19
+ if (_pipeline) return _pipeline;
20
+ const { pipeline } = await import('@huggingface/transformers');
21
+ _pipeline = await pipeline('feature-extraction', MODEL_NAME, {
22
+ dtype: 'fp32',
23
+ });
24
+ return _pipeline;
25
+ };
26
+
27
+ /**
28
+ * Compute a 384-dim embedding for the given text.
29
+ * Returns a plain number array suitable for JSON serialization.
30
+ */
31
+ export const embed = async (text: string): Promise<number[]> => {
32
+ const pipe = await getPipeline();
33
+ const output = await pipe(text, { pooling: 'mean', normalize: true });
34
+ const data = output.tolist()[0] as number[];
35
+ return data;
36
+ };
37
+
38
+ /**
39
+ * Compute cosine similarity between two embedding vectors.
40
+ */
41
+ export const cosineSimilarity = (a: number[], b: number[]): number => {
42
+ let dot = 0;
43
+ let normA = 0;
44
+ let normB = 0;
45
+ for (let i = 0; i < a.length; i++) {
46
+ dot += a[i] * b[i];
47
+ normA += a[i] * a[i];
48
+ normB += b[i] * b[i];
49
+ }
50
+ return dot / (Math.sqrt(normA) * Math.sqrt(normB));
51
+ };
52
+
53
+ /**
54
+ * Build the text to embed for a node.
55
+ * For notes: just the name (one-liner).
56
+ * For other nodes: name + body content (truncated to ~500 chars for embedding quality).
57
+ */
58
+ export const buildEmbeddingText = (name: string, body?: string | null): string => {
59
+ if (!body) return name;
60
+ const cleaned = body.replace(/^##\s+.+$/gm, '').replace(/\n{2,}/g, '\n').trim();
61
+ const truncated = cleaned.slice(0, 500);
62
+ return `${name}\n${truncated}`;
63
+ };
64
+
65
+ /**
66
+ * Compute and store the embedding for a node in the database.
67
+ */
68
+ export const embedAndStore = async (nodeId: string, name: string, body?: string | null): Promise<void> => {
69
+ const { getDb } = await import('./db.js');
70
+ const { nodes } = await import('../db/schema.js');
71
+ const db = getDb();
72
+ const text = buildEmbeddingText(name, body);
73
+ const vector = await embed(text);
74
+ await db.update(nodes).set({ embedding: JSON.stringify(vector) }).where(eq(nodes.id, nodeId));
75
+ };
76
+
77
+ /**
78
+ * Backfill embeddings for all nodes that don't have one yet.
79
+ * Returns the number of nodes embedded.
80
+ */
81
+ export const backfillEmbeddings = async (log?: (msg: string) => void): Promise<number> => {
82
+ const { getDb } = await import('./db.js');
83
+ const { nodes } = await import('../db/schema.js');
84
+ const db = getDb();
85
+
86
+ const rows = await db.select({ id: nodes.id, name: nodes.name, body: nodes.body })
87
+ .from(nodes)
88
+ .where(isNull(nodes.embedding));
89
+
90
+ let count = 0;
91
+ for (const row of rows) {
92
+ const text = buildEmbeddingText(row.name, row.body);
93
+ const vector = await embed(text);
94
+ await db.update(nodes).set({ embedding: JSON.stringify(vector) }).where(eq(nodes.id, row.id));
95
+ count++;
96
+ if (log) log(` Embedded ${row.id} (${row.name})`);
97
+ }
98
+
99
+ return count;
100
+ };
@@ -1,14 +1,17 @@
1
1
  /**
2
- * Graph-aware relevance search with ranking.
2
+ * Graph-aware relevance search with ranking and optional vector similarity.
3
3
  *
4
4
  * Algorithm:
5
5
  * 1. Direct matches — keyword substring in node name or body
6
- * 2. Graph expansion1-hop neighbors of each direct match via edges
7
- * 3. Rank by edge type weight strong architectural signals score higher
8
- * 4. Deduplicatekeep highest relevance per node
9
- * 5. Cold start fallback if zero direct matches, return top-5 most-connected per type
6
+ * 2. Vector similaritysemantic matches via embeddings (if available)
7
+ * 3. Graph expansion 1-hop neighbors of each direct match via edges
8
+ * 4. Rank by combined score direct > vector > graph neighbors
9
+ * 5. Deduplicatekeep highest relevance per node
10
+ * 6. Cold start fallback — if zero matches, return top-5 most-connected per type
10
11
  */
11
12
 
13
+ import {cosineSimilarity} from './embedding_service.js';
14
+
12
15
  /** Edge types that carry strong architectural signal. */
13
16
  const STRONG_EDGES = new Set([
14
17
  'depends_on',
@@ -17,31 +20,13 @@ const STRONG_EDGES = new Set([
17
20
  'contains',
18
21
  ]);
19
22
 
20
- /**
21
- * @typedef {Object} RankedResult
22
- * @property {string} id
23
- * @property {string} name
24
- * @property {string} type
25
- * @property {string} status
26
- * @property {number} completeness
27
- * @property {number} open_questions_count
28
- * @property {string} relevance - 'direct' | 'via <sourceId> → <edgeType>' description
29
- * @property {number} _score - internal sort score (higher = more relevant)
30
- */
23
+ /** Minimum cosine similarity to include a vector match. */
24
+ const VECTOR_THRESHOLD = 0.25;
31
25
 
32
- /**
33
- * Run a relevance-ranked search over the graph.
34
- *
35
- * @param {string} keyword - search term (case-insensitive substring match)
36
- * @param {Array} allNodes - full node list from readGraph().nodes
37
- * @param {Array} allEdges - full edge list from readGraph().edges
38
- * @param {Function} getBody - async (nodeId) => string|null — fetches body content
39
- * @returns {Promise<RankedResult[]>} ranked results, direct first then neighbors
40
- */
41
- export const relevanceSearch = async (keyword, allNodes, allEdges, getBody) => {
26
+ export const relevanceSearch = async (keyword, allNodes, allEdges, getBody, getEmbedding?) => {
42
27
  const q = keyword.toLowerCase();
43
28
 
44
- // --- Step 1: direct matches ---
29
+ // --- Step 1: direct keyword matches ---
45
30
  const directMatches = [];
46
31
  for (const n of allNodes) {
47
32
  if (n.name.toLowerCase().includes(q)) {
@@ -54,14 +39,8 @@ export const relevanceSearch = async (keyword, allNodes, allEdges, getBody) => {
54
39
  }
55
40
  }
56
41
 
57
- // --- Cold start fallback ---
58
- if (directMatches.length === 0) {
59
- return coldStartFallback(allNodes, allEdges);
60
- }
61
-
62
42
  // Build a map for quick lookup
63
43
  const nodeMap = new Map(allNodes.map(n => [n.id, n]));
64
- const directIds = new Set(directMatches.map(n => n.id));
65
44
 
66
45
  // Results keyed by node id → best RankedResult
67
46
  const resultsMap = new Map();
@@ -71,14 +50,47 @@ export const relevanceSearch = async (keyword, allNodes, allEdges, getBody) => {
71
50
  resultsMap.set(n.id, toResult(n, 'direct', 100));
72
51
  }
73
52
 
74
- // --- Step 2: 1-hop graph expansion ---
75
- for (const match of directMatches) {
76
- const neighbors = getNeighbors(match.id, allEdges);
53
+ // --- Step 2: vector similarity matches ---
54
+ if (getEmbedding) {
55
+ try {
56
+ const { embed } = await import('./embedding_service.js');
57
+ const queryVector = await embed(keyword);
58
+
59
+ for (const n of allNodes) {
60
+ const nodeVector = await getEmbedding(n.id);
61
+ if (!nodeVector) continue;
62
+
63
+ const similarity = cosineSimilarity(queryVector, nodeVector);
64
+ if (similarity >= VECTOR_THRESHOLD) {
65
+ // Vector score: 0-80 range (below direct match's 100, above graph expansion's 50)
66
+ const score = Math.round(similarity * 80);
67
+ const label = `semantic (${(similarity * 100).toFixed(0)}%)`;
68
+
69
+ const existing = resultsMap.get(n.id);
70
+ if (!existing || existing._score < score) {
71
+ resultsMap.set(n.id, toResult(n, label, score));
72
+ }
73
+ }
74
+ }
75
+ } catch {
76
+ // Vector search unavailable — continue with keyword-only results
77
+ }
78
+ }
79
+
80
+ // --- Cold start fallback ---
81
+ if (resultsMap.size === 0) {
82
+ return coldStartFallback(allNodes, allEdges);
83
+ }
84
+
85
+ // --- Step 3: 1-hop graph expansion from direct + vector matches ---
86
+ const matchedIds = [...resultsMap.keys()];
87
+ for (const matchId of matchedIds) {
88
+ const neighbors = getNeighbors(matchId, allEdges);
77
89
  for (const { nodeId, edgeType, direction } of neighbors) {
78
90
  if (!nodeMap.has(nodeId)) continue;
79
91
  const isStrong = STRONG_EDGES.has(edgeType);
80
92
  const score = isStrong ? 50 : 20;
81
- const label = `via ${match.id} ${direction} ${edgeType}`;
93
+ const label = `via ${matchId} ${direction} ${edgeType}`;
82
94
 
83
95
  const existing = resultsMap.get(nodeId);
84
96
  if (!existing || existing._score < score) {
@@ -87,7 +99,7 @@ export const relevanceSearch = async (keyword, allNodes, allEdges, getBody) => {
87
99
  }
88
100
  }
89
101
 
90
- // --- Step 3: sort by score descending, then by id for stability ---
102
+ // --- Step 4: sort by score descending, then by id for stability ---
91
103
  const results = [...resultsMap.values()];
92
104
  results.sort((a, b) => b._score - a._score || a.id.localeCompare(b.id));
93
105
 
@@ -13,6 +13,7 @@
13
13
  "db:assign-project": "npx tsx scripts/assign-project.ts --"
14
14
  },
15
15
  "dependencies": {
16
+ "@huggingface/transformers": "^3.8.1",
16
17
  "@libsql/client": "^0.17.0",
17
18
  "chalk": "^5.3.0",
18
19
  "commander": "^11.0.0",
@@ -9,12 +9,13 @@ import { program } from 'commander';
9
9
  import chalk from 'chalk';
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { eq } from 'drizzle-orm';
12
- import { NODE_TYPES, VALID_FEATURE_KINDS } from '../lib/constants.js';
12
+ import { NODE_TYPES, VALID_FEATURE_KINDS, KANBAN_EXCLUDED_TYPES } from '../lib/constants.js';
13
13
  import { getDb } from '../lib/db.js';
14
14
  import { nodes, projects } from '../db/schema.js';
15
15
  import { deriveMetadata, templateSections } from '../lib/markdown.js';
16
16
  import { assertValidType, assertUniqueName } from '../lib/validator.js';
17
17
  import { saveKanbanEntry } from '../lib/kanban.js';
18
+ import { embedAndStore } from '../lib/embedding_service.js';
18
19
 
19
20
  program
20
21
  .requiredOption('--type <type>', 'Node type (e.g., feature, component, decision)')
@@ -97,12 +98,19 @@ const opts = program.opts();
97
98
  projectId: opts.projectId,
98
99
  });
99
100
 
100
- // Auto-add features to the kanban backlog
101
- if (opts.type === 'feature') {
101
+ // Auto-add features to the kanban backlog (skip excluded types like notes)
102
+ if (opts.type === 'feature' && !KANBAN_EXCLUDED_TYPES.has(opts.type)) {
102
103
  await saveKanbanEntry(id, { column: 'backlog', rejection_count: 0, notes: [], reviews: [] }, opts.projectId);
103
104
  console.log(chalk.green(`✓ Added ${id} to kanban backlog`));
104
105
  }
105
106
 
107
+ // Compute and store embedding
108
+ try {
109
+ await embedAndStore(id, opts.name, body);
110
+ } catch (embErr: any) {
111
+ console.log(chalk.yellow(`⚠ Embedding skipped: ${embErr.message}`));
112
+ }
113
+
106
114
  console.log(chalk.green(`✓ Created ${opts.type} node: ${id}`));
107
115
  console.log(JSON.stringify({ id, type: opts.type, name: opts.name }));
108
116
  } catch (err) {
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * delete_note — Removes a note node and all its edges from the knowledge graph.
5
+ */
6
+
7
+ import { program } from 'commander';
8
+ import chalk from 'chalk';
9
+ import { eq, or } from 'drizzle-orm';
10
+ import { getDb } from '../lib/db.js';
11
+ import { nodes, edges } from '../db/schema.js';
12
+
13
+ program
14
+ .argument('<id>', 'Note node ID to delete (e.g. note_a1b2c3d4)')
15
+ .requiredOption('--project-id <id>', 'Project ID')
16
+ .parse();
17
+
18
+ const [id] = program.args;
19
+
20
+ (async () => {
21
+ try {
22
+ const db = getDb();
23
+
24
+ // Verify node exists and is a note
25
+ const rows = await db.select({ id: nodes.id, type: nodes.type, name: nodes.name })
26
+ .from(nodes)
27
+ .where(eq(nodes.id, id));
28
+
29
+ if (!rows[0]) {
30
+ throw new Error(`Node not found: ${id}`);
31
+ }
32
+ if (rows[0].type !== 'note') {
33
+ throw new Error(`Node ${id} is a ${rows[0].type}, not a note. Use remove_node for non-note nodes.`);
34
+ }
35
+
36
+ const noteName = rows[0].name;
37
+
38
+ // Delete edges and node in a transaction
39
+ await db.transaction(async (tx) => {
40
+ await tx.delete(edges).where(or(eq(edges.fromId, id), eq(edges.toId, id)));
41
+ await tx.delete(nodes).where(eq(nodes.id, id));
42
+ });
43
+
44
+ console.log(chalk.green(`✓ Deleted note: ${id}`));
45
+ console.log(JSON.stringify({ id, name: noteName, deleted: true }));
46
+ } catch (err: any) {
47
+ console.error(chalk.red(`Error: ${err.message}`));
48
+ process.exit(1);
49
+ }
50
+ })();
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * save_note — Creates a lightweight note node in the knowledge graph.
5
+ * Notes are one-liner memories stored as the node name, with optional tags
6
+ * and edges to related nodes.
7
+ */
8
+
9
+ import { program } from 'commander';
10
+ import chalk from 'chalk';
11
+ import { randomBytes } from 'node:crypto';
12
+ import { getDb } from '../lib/db.js';
13
+ import { nodes, edges } from '../db/schema.js';
14
+ import { embedAndStore } from '../lib/embedding_service.js';
15
+ import { assertNodeExists } from '../lib/validator.js';
16
+
17
+ program
18
+ .requiredOption('--project-id <id>', 'Project ID')
19
+ .requiredOption('--content <text>', 'The note content (one-liner)')
20
+ .option('--tags <tags>', 'Comma-separated tags (e.g. "auth,security")')
21
+ .option('--relates-to <nodeIds>', 'Comma-separated node IDs to create relates_to edges')
22
+ .parse();
23
+
24
+ const opts = program.opts();
25
+
26
+ (async () => {
27
+ try {
28
+ const id = `note_${randomBytes(4).toString('hex')}`;
29
+ const now = new Date().toISOString();
30
+
31
+ // Build body from tags if provided
32
+ let body = '';
33
+ if (opts.tags) {
34
+ body = `## Tags\n${opts.tags.split(',').map((t: string) => t.trim()).join(', ')}\n`;
35
+ }
36
+
37
+ // Insert the note node
38
+ const db = getDb();
39
+ await db.insert(nodes).values({
40
+ id,
41
+ type: 'note',
42
+ name: opts.content,
43
+ status: 'defined',
44
+ priority: 'low',
45
+ completeness: 1.0,
46
+ openQuestionsCount: 0,
47
+ kind: null,
48
+ featureType: null,
49
+ createdAt: now,
50
+ updatedAt: now,
51
+ body: body || null,
52
+ projectId: opts.projectId,
53
+ });
54
+
55
+ // Create edges to related nodes
56
+ if (opts.relatesTo) {
57
+ const relatedIds = opts.relatesTo.split(',').map((s: string) => s.trim());
58
+ for (const targetId of relatedIds) {
59
+ await assertNodeExists(targetId);
60
+ await db.insert(edges).values({
61
+ fromId: id,
62
+ relation: 'relates_to',
63
+ toId: targetId,
64
+ projectId: opts.projectId,
65
+ });
66
+ console.log(chalk.gray(` + edge: ${id} --relates_to--> ${targetId}`));
67
+ }
68
+ }
69
+
70
+ // Compute and store embedding
71
+ await embedAndStore(id, opts.content, body || null);
72
+
73
+ console.log(chalk.green(`✓ Saved note: ${id}`));
74
+ console.log(JSON.stringify({ id, type: 'note', content: opts.content }));
75
+ } catch (err: any) {
76
+ console.error(chalk.red(`Error: ${err.message}`));
77
+ process.exit(1);
78
+ }
79
+ })();
@@ -43,8 +43,13 @@ const opts = program.opts();
43
43
  const rows = await db.select({ body: nodes.body }).from(nodes).where(eq(nodes.id, nodeId));
44
44
  return rows[0]?.body ?? null;
45
45
  };
46
+ const getEmbedding = async (nodeId) => {
47
+ const rows = await db.select({ embedding: nodes.embedding }).from(nodes).where(eq(nodes.id, nodeId));
48
+ const raw = rows[0]?.embedding;
49
+ return raw ? JSON.parse(raw) : null;
50
+ };
46
51
 
47
- results = await relevanceSearch(opts.query, graph.nodes, graph.edges, getBody);
52
+ results = await relevanceSearch(opts.query, graph.nodes, graph.edges, getBody, getEmbedding);
48
53
  } else {
49
54
  // No keyword — return all nodes without relevance (backward compat)
50
55
  results = graph.nodes.map(n => ({
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * search_notes — Semantic vector search across all graph nodes.
5
+ *
6
+ * Embeds the query using the same model, then computes cosine similarity
7
+ * against all nodes with embeddings. Returns results ranked by relevance.
8
+ * Works across all node types, not just notes.
9
+ */
10
+
11
+ import { program } from 'commander';
12
+ import chalk from 'chalk';
13
+ import { eq } from 'drizzle-orm';
14
+ import { getDb } from '../lib/db.js';
15
+ import { nodes } from '../db/schema.js';
16
+ import { embed, cosineSimilarity } from '../lib/embedding_service.js';
17
+
18
+ program
19
+ .requiredOption('--project-id <id>', 'Project ID')
20
+ .requiredOption('--query <text>', 'Search query (semantic search)')
21
+ .option('--type <type>', 'Filter by node type (e.g. "note", "feature")')
22
+ .option('--limit <n>', 'Max results to return', '10')
23
+ .option('--threshold <n>', 'Minimum similarity score (0-1)', '0.25')
24
+ .parse();
25
+
26
+ const opts = program.opts();
27
+
28
+ (async () => {
29
+ try {
30
+ const db = getDb();
31
+ const limit = parseInt(opts.limit, 10);
32
+ const threshold = parseFloat(opts.threshold);
33
+
34
+ // Load all nodes with embeddings for this project
35
+ const allNodes = await db.select({
36
+ id: nodes.id,
37
+ name: nodes.name,
38
+ type: nodes.type,
39
+ status: nodes.status,
40
+ completeness: nodes.completeness,
41
+ openQuestionsCount: nodes.openQuestionsCount,
42
+ embedding: nodes.embedding,
43
+ }).from(nodes).where(eq(nodes.projectId, opts.projectId));
44
+
45
+ // Embed the query
46
+ const queryVector = await embed(opts.query);
47
+
48
+ // Score each node
49
+ const scored: Array<{
50
+ id: string;
51
+ name: string;
52
+ type: string;
53
+ status: string;
54
+ completeness: number;
55
+ open_questions_count: number;
56
+ similarity: number;
57
+ }> = [];
58
+
59
+ for (const node of allNodes) {
60
+ if (!node.embedding) continue;
61
+ if (opts.type && node.type !== opts.type) continue;
62
+
63
+ const nodeVector = JSON.parse(node.embedding) as number[];
64
+ const similarity = cosineSimilarity(queryVector, nodeVector);
65
+
66
+ if (similarity >= threshold) {
67
+ scored.push({
68
+ id: node.id,
69
+ name: node.name,
70
+ type: node.type,
71
+ status: node.status,
72
+ completeness: node.completeness,
73
+ open_questions_count: node.openQuestionsCount,
74
+ similarity: Math.round(similarity * 1000) / 1000,
75
+ });
76
+ }
77
+ }
78
+
79
+ // Sort by similarity descending
80
+ scored.sort((a, b) => b.similarity - a.similarity);
81
+ const results = scored.slice(0, limit);
82
+
83
+ if (results.length === 0) {
84
+ console.log(chalk.yellow('No matching nodes found above threshold.'));
85
+ } else {
86
+ console.log(chalk.cyan(`Found ${results.length} result(s):\n`));
87
+ for (const r of results) {
88
+ const simBar = chalk.green('█'.repeat(Math.round(r.similarity * 10)));
89
+ const simPad = chalk.gray('░'.repeat(10 - Math.round(r.similarity * 10)));
90
+ console.log(` ${chalk.bold(r.id)} ${r.name} [${r.type}] ${simBar}${simPad} ${(r.similarity * 100).toFixed(1)}%`);
91
+ }
92
+ }
93
+
94
+ console.log('\n' + JSON.stringify(results));
95
+ } catch (err: any) {
96
+ console.error(chalk.red(`Error: ${err.message}`));
97
+ process.exit(1);
98
+ }
99
+ })();