Skip to content

Commit fb1d9f6

Browse files
bwlclaude
andcommitted
Add visual scoring matrix and short ID support to edge management
Enhanced 'forest edges propose' with color-coded scoring breakdown: - Displays aggregate (ag), embedding (em), token (tk), title (ti), and tag (tg) components - Gradient heat maps with forest-inspired hues (amber/bark, green, moss, gold, rust) - Fixed-width layout prevents alignment issues on wide terminals - Improved header format shows column labels and node relationship Implemented git-style short ID resolution in getNodeById(): - Accepts 6-8 character hex prefixes for nodes - Fast path: exact match first, then prefix search if needed - Errors on ambiguous matches with helpful message - Maintains backward compatibility with full UUIDs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 84548e1 commit fb1d9f6

2 files changed

Lines changed: 154 additions & 15 deletions

File tree

src/cli/commands/edges.ts

Lines changed: 117 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import chalk from 'chalk';
2+
13
import {
24
EdgeRecord,
35
deleteSuggestion,
@@ -385,6 +387,15 @@ async function runEdgesList(flags: EdgesListFlags) {
385387
});
386388
}
387389

390+
/**
391+
* Format score component as 2-digit integer (0-99 scale)
392+
*/
393+
function formatScoreComponent(value: number): string {
394+
const scaled = Math.floor(value * 100);
395+
const clamped = Math.max(0, Math.min(99, scaled));
396+
return clamped.toString().padStart(2, '0');
397+
}
398+
388399
async function runEdgesPropose(flags: EdgesProposeFlags) {
389400
const limit =
390401
typeof flags.limit === 'number' && !Number.isNaN(flags.limit) && flags.limit > 0 ? flags.limit : 10;
@@ -424,19 +435,113 @@ async function runEdgesPropose(flags: EdgesProposeFlags) {
424435
return;
425436
}
426437

427-
console.log('Suggested edges (ordered by score):');
428-
edges.forEach((edge, index) => {
429-
const desc = describeSuggestion(edge, nodeMap, { longIds, allEdges });
430-
const indexLabel = String(index + 1).padStart(2, ' ');
431-
console.log(
432-
`${indexLabel}. [${desc.code}] ${desc.edgeId} score=${edge.score.toFixed(3)} ${desc.sourceLabel}${desc.targetLabel}`,
433-
);
434-
});
438+
// New header format
439+
console.log('Top identified links sorted by aggregate score.');
440+
console.log('/forest edges accept/reject [ref]');
435441
console.log('');
436-
console.log('To accept or reject suggestions:');
437-
console.log(' forest edges accept <ref> # ref can be index (1), code (0L5a), or short pair');
438-
console.log(' forest edges reject <ref>');
439-
console.log(' forest edges promote # bulk accept above threshold');
442+
443+
// Helper to convert HSL to RGB (used for headers)
444+
const hslToRgb = (h: number, s: number, l: number): [number, number, number] => {
445+
s /= 100;
446+
l /= 100;
447+
const k = (n: number) => (n + h / 30) % 12;
448+
const a = s * Math.min(l, 1 - l);
449+
const f = (n: number) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
450+
return [Math.round(255 * f(0)), Math.round(255 * f(8)), Math.round(255 * f(4))];
451+
};
452+
453+
// Header styling
454+
const makeHeaderColor = (hue: number) => {
455+
const [r, g, b] = hslToRgb(hue + 10, 45, 40); // 40% brightness, hue twist +10°
456+
return chalk.rgb(r, g, b);
457+
};
458+
459+
const headerLine =
460+
'◌ ' +
461+
chalk.grey('ref') + ' ' +
462+
makeHeaderColor(35)('ag') + ' ' +
463+
makeHeaderColor(120)('em') + ' ' +
464+
makeHeaderColor(100)('tk') + ' ' +
465+
makeHeaderColor(45)('ti') + ' ' +
466+
makeHeaderColor(10)('tg') + ' ' +
467+
chalk.hex('#A8C5DD')('nodeA') +
468+
chalk.grey('::') +
469+
chalk.hex('#A8DDB5')('nodeB');
470+
471+
console.log(headerLine);
472+
473+
// Cap maximum display width to prevent excessive gaps on wide terminals
474+
const MAX_DISPLAY_WIDTH = 100;
475+
const terminalWidth = Math.min(process.stdout.columns || 120, MAX_DISPLAY_WIDTH);
476+
477+
// Fixed column widths for consistent alignment (fits perfectly under nodeA::nodeB)
478+
const TITLE_A_WIDTH = 29; // Fixed width for titleA
479+
const TITLE_B_WIDTH = 29; // Fixed width for titleB
480+
481+
// Helper to colorize score value based on magnitude (gradient heat map)
482+
const colorizeScore = (value: number, hue: number): string => {
483+
const formattedValue = formatScoreComponent(value);
484+
485+
// Gradient from dark (low) to bright (high)
486+
// HSL interpolation: consistent hue, varying lightness & saturation
487+
const t = Math.max(0, Math.min(1, value)); // Clamp to [0, 1]
488+
489+
// Dark muted -> bright vibrant
490+
const lightness = 20 + t * 50; // 20% to 70%
491+
const saturation = 25 + t * 45; // 25% to 70%
492+
493+
const [r, g, b] = hslToRgb(hue, saturation, lightness);
494+
return chalk.rgb(r, g, b)(formattedValue);
495+
};
496+
497+
// Helper to color node IDs with subtle per-character hue variation
498+
const colorNodeId = (id: string): string => {
499+
return id
500+
.split('')
501+
.map((char, i) => {
502+
const hueOffset = (i * 13) % 30; // Subtle variation
503+
const [r, g, b] = hslToRgb(200 + hueOffset, 10, 65); // Light grey with slight hue shift
504+
return chalk.rgb(r, g, b)(char);
505+
})
506+
.join('');
507+
};
508+
509+
edges.forEach((edge) => {
510+
const nodeA = nodeMap.get(edge.sourceId);
511+
const nodeB = nodeMap.get(edge.targetId);
512+
if (!nodeA || !nodeB) return;
513+
514+
// Get or compute scoring components
515+
const components = (edge.metadata as any)?.components ?? computeScore(nodeA, nodeB).components;
516+
517+
// Format score columns with distinct forest hues
518+
const ag = colorizeScore(edge.score, 35); // Amber/bark brown
519+
const em = colorizeScore(components.embeddingSimilarity, 120); // Forest green
520+
const tk = colorizeScore(components.tokenSimilarity, 100); // Moss/lime green
521+
const ti = colorizeScore(components.titleSimilarity, 45); // Autumn gold/yellow
522+
const tg = colorizeScore(components.tagOverlap, 10); // Clay red/rust
523+
524+
// Get edge code and pad to 5 chars - color orange, no ◌ prefix
525+
const code = getEdgePrefix(edge.sourceId, edge.targetId, allEdges).padEnd(5, ' ');
526+
const coloredCode = chalk.hex('#FF8C00')(code);
527+
528+
// Simple fixed-width truncation (no balancing complexity)
529+
const truncA = nodeA.title.length > TITLE_A_WIDTH
530+
? nodeA.title.slice(0, TITLE_A_WIDTH - 1) + '…'
531+
: nodeA.title;
532+
const truncB = nodeB.title.length > TITLE_B_WIDTH
533+
? nodeB.title.slice(0, TITLE_B_WIDTH - 1) + '…'
534+
: nodeB.title;
535+
536+
// Pad titleA to fixed width for perfect :: alignment
537+
const titleAPadded = truncA.padEnd(TITLE_A_WIDTH, ' ');
538+
539+
// Format node IDs with subtle color variation
540+
const idA = colorNodeId(formatId(edge.sourceId));
541+
const idB = colorNodeId(formatId(edge.targetId));
542+
543+
console.log(`${coloredCode} ${ag} ${em} ${tk} ${ti} ${tg} ${titleAPadded} ${idA}${chalk.grey('::')}${idB} ${truncB}`);
544+
});
440545
}
441546

442547
async function runEdgesPromote(flags: EdgesPromoteFlags) {

src/lib/db.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,11 @@ export async function listNodes(): Promise<NodeRecord[]> {
254254

255255
export async function getNodeById(id: string): Promise<NodeRecord | null> {
256256
const db = await ensureDatabase();
257-
const stmt = db.prepare('SELECT * FROM nodes WHERE id = :id LIMIT 1');
257+
258+
// Try exact match first (fast path)
259+
let stmt = db.prepare('SELECT * FROM nodes WHERE id = :id LIMIT 1');
258260
stmt.bind({ ':id': id });
259-
const node = stmt.step()
261+
let node = stmt.step()
260262
? (() => {
261263
const row = stmt.getAsObject();
262264
return {
@@ -272,7 +274,39 @@ export async function getNodeById(id: string): Promise<NodeRecord | null> {
272274
})()
273275
: null;
274276
stmt.free();
275-
return node;
277+
278+
if (node) return node;
279+
280+
// If not found and looks like a short ID (hex chars, 6-8 chars), try prefix match
281+
const isShortId = /^[0-9a-f]{6,8}$/i.test(id);
282+
if (!isShortId) return null;
283+
284+
// Git-style prefix search
285+
const normalized = id.toLowerCase();
286+
stmt = db.prepare('SELECT * FROM nodes WHERE lower(id) LIKE :prefix');
287+
stmt.bind({ ':prefix': `${normalized}%` });
288+
289+
const matches: NodeRecord[] = [];
290+
while (stmt.step()) {
291+
const row = stmt.getAsObject();
292+
matches.push({
293+
id: String(row.id),
294+
title: String(row.title),
295+
body: String(row.body),
296+
tags: JSON.parse(String(row.tags)),
297+
tokenCounts: JSON.parse(String(row.token_counts)),
298+
embedding: row.embedding ? (JSON.parse(String(row.embedding)) as number[]) : undefined,
299+
createdAt: String(row.created_at),
300+
updatedAt: String(row.updated_at),
301+
} satisfies NodeRecord);
302+
}
303+
stmt.free();
304+
305+
if (matches.length === 0) return null;
306+
if (matches.length === 1) return matches[0];
307+
308+
// Ambiguous short ID
309+
throw new Error(`Ambiguous short ID '${id}': matches ${matches.length} nodes. Use a longer prefix.`);
276310
}
277311

278312
export async function findNodeByTitle(title: string): Promise<NodeRecord | null> {

0 commit comments

Comments
 (0)