@assistkick/create 1.32.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.
- package/package.json +1 -1
- package/templates/assistkick-product-system/package.json +3 -1
- package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +10 -3
- package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +8 -1
- package/templates/assistkick-product-system/packages/shared/db/migrate.ts +21 -2
- package/templates/assistkick-product-system/packages/shared/db/migrations/0003_solid_manta.sql +1 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0003_snapshot.json +1836 -0
- package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +7 -0
- package/templates/assistkick-product-system/packages/shared/db/schema.ts +1 -0
- package/templates/assistkick-product-system/packages/shared/lib/constants.ts +12 -0
- package/templates/assistkick-product-system/packages/shared/lib/embedding_service.test.ts +75 -0
- package/templates/assistkick-product-system/packages/shared/lib/embedding_service.ts +100 -0
- package/templates/assistkick-product-system/packages/shared/lib/relevance_search.ts +50 -38
- package/templates/assistkick-product-system/packages/shared/package.json +1 -0
- package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +11 -3
- package/templates/assistkick-product-system/packages/shared/tools/delete_note.ts +50 -0
- package/templates/assistkick-product-system/packages/shared/tools/save_note.ts +79 -0
- package/templates/assistkick-product-system/packages/shared/tools/search_nodes.ts +6 -1
- package/templates/assistkick-product-system/packages/shared/tools/search_notes.ts +99 -0
- package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +15 -0
|
@@ -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.
|
|
7
|
-
* 3.
|
|
8
|
-
* 4.
|
|
9
|
-
* 5.
|
|
6
|
+
* 2. Vector similarity — semantic 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. Deduplicate — keep 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
|
-
|
|
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:
|
|
75
|
-
|
|
76
|
-
|
|
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 ${
|
|
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
|
|
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
|
|
|
@@ -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
|
+
})();
|