UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

500 lines (499 loc) • 19.2 kB
import { KIND_COLORS, STORE_COLORS, TOPIC_COLORS } from "./types.js"; /** Hands out a distinct palette colour per non-primary store, first come first served. */ export class StoreColorAssigner { assigned = new Map(); color(storeName) { if (!storeName || storeName === "primary") return null; const existing = this.assigned.get(storeName); if (existing) return existing; const idx = (this.assigned.size + 1) % STORE_COLORS.length; this.assigned.set(storeName, STORE_COLORS[idx]); return STORE_COLORS[idx]; } } export function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } export function hashString(value) { let hash = 2166136261; for (let i = 0; i < value.length; i++) { hash ^= value.charCodeAt(i); hash = Math.imul(hash, 16777619); } return hash >>> 0; } /** Deterministic pseudo-random in [0, 1) derived from a value + salt. */ export function seeded(value, salt) { return (hashString(`${salt}:${value}`) % 10000) / 10000; } export function deriveKind(node) { if (node.group === "project") return "project"; if (node.group === "entity") return "entity"; if (node.group === "reference") return "reference"; if (node.group === "topic") return "topic"; if (node.group === "note") return "note"; if (node.group.startsWith("task-")) return "task"; if (node.group.startsWith("topic:")) return "finding"; return "other"; } export function topicColor(slug) { if (!slug) return KIND_COLORS.other; return TOPIC_COLORS[slug] || TOPIC_COLORS.general; } export function scoreForNode(node, scores) { if (node.scoreKey && scores[node.scoreKey]) return scores[node.scoreKey]; if (node.scoreKeys) { for (const key of node.scoreKeys) { if (scores[key]) return scores[key]; } } if (node.refDocs) { for (const ref of node.refDocs) { if (ref.scoreKey && scores[ref.scoreKey]) return scores[ref.scoreKey]; } } return undefined; } export function inferHealth(score, now = Date.now()) { if (!score || !score.lastUsedAt) return "healthy"; const ageMs = now - new Date(score.lastUsedAt).getTime(); const ageDays = Number.isFinite(ageMs) ? ageMs / 86400000 : 0; const penalties = (score.repromptPenalty || 0) + (score.regressionPenalty || 0) * 2; if (ageDays > 150 || penalties >= 4) return "stale"; if (ageDays > 60 || penalties >= 2) return "decaying"; return "healthy"; } export function qualityScore(node, scores) { const score = scoreForNode(node, scores); if (!score) return null; const helpful = score.helpful || 0; const impressions = score.impressions || 0; const penalties = (score.repromptPenalty || 0) + (score.regressionPenalty || 0) * 2; const raw = 0.55 + helpful * 0.1 + Math.min(0.2, impressions * 0.02) - penalties * 0.08; return clamp(raw, 0.1, 1); } export function baseColorForNode(node, storeColor) { const kind = deriveKind(node); if (kind === "finding") return topicColor(node.topicSlug || node.group.slice(6)); if (kind === "task") { if (node.section === "Done" || node.group === "task-done") return KIND_COLORS["task-done"]; if (node.section === "Active" || node.group === "task-active") return KIND_COLORS["task-active"]; return KIND_COLORS["task-queue"]; } if (kind === "project") return storeColor(node.store) || KIND_COLORS.project; if (kind === "entity") return KIND_COLORS.entity; if (kind === "reference") return KIND_COLORS.reference; if (kind === "topic") return topicColor(node.topicSlug); if (kind === "note") return KIND_COLORS.note; return KIND_COLORS.other; } export function sizeForNode(node, scores) { const kind = deriveKind(node); const refCount = Math.max(0, node.refCount || 0); const score = scoreForNode(node, scores); const helpful = Math.max(0, score?.helpful || 0); if (kind === "project") return clamp(20 + Math.sqrt(refCount + 4) * 4, 24, 38); if (kind === "entity") return clamp(8 + Math.sqrt(refCount + 1) * 2.3, 10, 22); if (kind === "finding") return clamp(7.5 + Math.sqrt(helpful + 1) * 1.8 + (node.tagged ? 1.4 : 0), 9, 18); if (kind === "task") return clamp(8 + (node.section === "Active" ? 2 : 0) + (node.priority === "high" ? 1 : 0), 8, 15); if (kind === "reference") return clamp(7 + Math.sqrt(refCount + 1) * 1.2, 7, 12); if (kind === "topic") return clamp(10 + Math.sqrt(refCount + 1) * 2.2, 11, 24); if (kind === "note") return 9; return 9; } export function nodeRadius(node) { return clamp(node.size * 0.5, 4, 18); } export function searchTextForNode(node) { return [ node.label, node.fullLabel, node.project, node.entityType, node.section, node.priority, node.topicSlug, node.topicLabel, ...(node.connectedProjects || []), ...(node.refDocs || []).map((ref) => ref.doc), ].join(" ").toLowerCase(); } export function normalizeNode(node, scores, storeColor) { const score = scoreForNode(node, scores); const kind = deriveKind(node); return { ...node, kind, searchText: searchTextForNode(node), health: inferHealth(score), baseColor: baseColorForNode(node, storeColor), size: sizeForNode(node, scores), forceLabel: kind === "project" || (kind === "entity" && (node.refCount || 0) >= 12) || (kind === "topic" && (node.refCount || 0) >= 8), }; } /** Undirected adjacency over every node/link, ignoring links to unknown ids. */ export function buildFullAdjacency(nodes, links) { const adjacency = new Map(); for (const node of nodes) adjacency.set(node.id, new Set()); for (const link of links) { if (!adjacency.has(link.source) || !adjacency.has(link.target)) continue; adjacency.get(link.source).add(link.target); adjacency.get(link.target).add(link.source); } return adjacency; } export function connectionCounts(model, nodeId) { const counts = { total: 0, projects: 0, findings: 0, tasks: 0, entities: 0, references: 0, topics: 0, notes: 0 }; const adjacency = model.fullAdjacency.get(nodeId); if (!adjacency) return counts; counts.total = adjacency.size; adjacency.forEach((neighborId) => { const neighbor = model.nodeById.get(neighborId); if (!neighbor) return; if (neighbor.kind === "project") counts.projects++; else if (neighbor.kind === "finding") counts.findings++; else if (neighbor.kind === "task") counts.tasks++; else if (neighbor.kind === "entity") counts.entities++; else if (neighbor.kind === "reference") counts.references++; else if (neighbor.kind === "topic") counts.topics++; else if (neighbor.kind === "note") counts.notes++; }); return counts; } /** Check if a node is in a project's direct network (1-hop neighbors). */ export function isInProjectNetwork(visibleAdjacency, nodeId, projectId) { if (nodeId === projectId) return true; const neighbors = visibleAdjacency.get(projectId); if (neighbors?.has(nodeId)) return true; const nodeNeighbors = visibleAdjacency.get(nodeId); if (nodeNeighbors) { for (const nn of nodeNeighbors) { if (neighbors?.has(nn)) return true; } } return false; } export function nodeDetail(model, nodeId) { const node = model.nodeById.get(nodeId); if (!node) return null; return { ...node, displayLabel: node.label, tooltipLabel: node.fullLabel || node.label, text: node.fullLabel || node.label, docs: (node.refDocs || []).map((ref) => ref.doc), projectName: node.project || "", qualityScore: qualityScore(node, model.scores), connections: connectionCounts(model, nodeId), score: scoreForNode(node, model.scores), }; } /** * Structural filters only. The search query deliberately does NOT remove * nodes — search dims non-matches instead, so the graph keeps its shape * while matches light up. */ export function nodeMatchesFilters(node, filters) { if (!filters.filterTypes[node.kind]) return false; if (node.kind === "finding" && node.topicSlug && filters.filterTopics[node.topicSlug] === false) return false; if (filters.filterHealth === "aging") { if (node.health === "healthy") return false; } else if (filters.filterHealth !== "all" && node.health !== filters.filterHealth) { return false; } if (filters.filterStore !== "all" && node.store && node.store !== filters.filterStore) return false; if (filters.filterProject !== "all") { const project = filters.filterProject; const connectedProjects = new Set(); if (node.project) connectedProjects.add(node.project); (node.connectedProjects || []).forEach((name) => connectedProjects.add(name)); (node.refDocs || []).forEach((ref) => { if (ref.project) connectedProjects.add(ref.project); else if (ref.doc.includes("/")) connectedProjects.add(ref.doc.slice(0, ref.doc.indexOf("/"))); }); if (node.kind === "project") { if ((node.project || "") !== project) return false; } else if (!connectedProjects.has(project)) { return false; } } return true; } export function nodeRank(node, filters, scores) { let rank = 0; if (node.kind === "project") rank += 2000; if (node.kind === "entity") rank += 800 + (node.refCount || 0) * 8; if (node.kind === "finding") rank += 600 + (scoreForNode(node, scores)?.helpful || 0) * 14 + (node.tagged ? 45 : 0); if (node.kind === "task") rank += node.section === "Active" ? 540 : 470; if (node.kind === "reference") rank += 180 + (node.refCount || 0) * 3; if (node.kind === "topic") rank += 700 + (node.refCount || 0) * 4; if (node.kind === "note") rank += 440; if (node.priority === "high") rank += 60; if (node.health === "healthy") rank += 24; if (node.health === "decaying") rank -= 12; if (node.health === "stale") rank -= 25; if (filters.filterProject !== "all" && node.project === filters.filterProject) rank += 80; if (filters.filterStore !== "all" && node.store === filters.filterStore) rank += 40; if (filters.searchQuery && node.searchText.includes(filters.searchQuery.toLowerCase())) rank += 120; return rank; } /** * Split a budget across buckets so every one gets a share. Buckets smaller than * an even share release what they cannot use, and the surplus goes round again, * so a store of many small projects and a few huge ones still spends the whole * budget without starving the small ones. */ function allocateQuotas(buckets, budget) { const quotas = new Map(); let hungry = [...buckets.keys()]; let remaining = budget; while (hungry.length > 0 && remaining > 0) { const share = Math.floor(remaining / hungry.length); if (share < 1) break; const next = []; for (const key of hungry) { const taken = quotas.get(key) ?? 0; const want = buckets.get(key).length - taken; const give = Math.min(share, want); if (give > 0) { quotas.set(key, taken + give); remaining -= give; } if (want > give) next.push(key); } if (next.length === hungry.length && next.length > 0 && remaining === budget) break; hungry = next; } // Hand out the indivisible remainder one at a time, largest bucket first. if (remaining > 0 && hungry.length > 0) { const bySize = hungry.slice().sort((a, b) => buckets.get(b).length - buckets.get(a).length); for (const key of bySize) { if (remaining <= 0) break; const taken = quotas.get(key) ?? 0; if (taken >= buckets.get(key).length) continue; quotas.set(key, taken + 1); remaining--; } } return quotas; } /** * Fill a bucket's quota by taking the best of each kind in turn rather than the * best overall, so a project's tasks and reference docs cannot be crowded out * by its own findings. */ function pickAcrossKinds(nodes, quota, filters, scores) { if (quota <= 0) return []; if (nodes.length <= quota) return nodes; const byKind = new Map(); for (const node of nodes) { const list = byKind.get(node.kind); if (list) list.push(node); else byKind.set(node.kind, [node]); } for (const list of byKind.values()) list.sort((a, b) => nodeRank(b, filters, scores) - nodeRank(a, filters, scores)); const queues = [...byKind.values()]; const picked = []; let cursor = 0; while (picked.length < quota && queues.some((q) => q.length > 0)) { const queue = queues[cursor % queues.length]; const node = queue.shift(); if (node) picked.push(node); cursor++; } return picked; } /** * Apply structural filters + the node cap. Projects and the selected node are * always kept when the cap trims the list. */ export function buildVisibleData(model, filters, selectedId) { const filteredNodes = model.rawNodes.filter((node) => nodeMatchesFilters(node, filters)); let limitedNodes = filteredNodes.slice(); if (limitedNodes.length > filters.nodeLimit) { // Taking the globally highest-ranked nodes concentrates the whole budget on // whichever projects happen to rank well: on a 40-project store two of them // took every slot and the other 38 showed as bare dots, which makes the map // misrepresent the store. Give every project a share instead. const projects = filteredNodes.filter((node) => node.kind === "project"); const buckets = new Map(); for (const node of filteredNodes) { if (node.kind === "project") continue; // Fragments carry no project of their own; they bucket together so // cross-project structure survives the cap. const key = node.project || ""; const list = buckets.get(key); if (list) list.push(node); else buckets.set(key, [node]); } const quotas = allocateQuotas(buckets, Math.max(0, filters.nodeLimit - projects.length)); const keepIds = new Set(projects.map((node) => node.id)); for (const [key, list] of buckets) { for (const node of pickAcrossKinds(list, quotas.get(key) ?? 0, filters, model.scores)) keepIds.add(node.id); } if (selectedId) keepIds.add(selectedId); limitedNodes = filteredNodes.filter((node) => keepIds.has(node.id)); } const visibleIds = new Set(limitedNodes.map((node) => node.id)); const visibleLinks = model.rawLinks.filter((link) => visibleIds.has(link.source) && visibleIds.has(link.target)); const visibleAdjacency = new Map(); limitedNodes.forEach((node) => visibleAdjacency.set(node.id, new Set())); visibleLinks.forEach((link) => { visibleAdjacency.get(link.source).add(link.target); visibleAdjacency.get(link.target).add(link.source); }); const connectedIds = new Set(); visibleLinks.forEach((link) => { connectedIds.add(link.source); connectedIds.add(link.target); }); const prunedNodes = limitedNodes.filter((node) => node.kind !== "project" || connectedIds.has(node.id) || (node.project || "") === filters.filterProject || filters.filterTypes.project); return { nodes: prunedNodes, links: visibleLinks, visibleAdjacency }; } /** Score a match the way Enter-to-fly ranks: label prefix > substring > deep text. */ export function matchRank(node, query, filters, scores) { const label = node.label.toLowerCase(); const score = label.startsWith(query) ? 3 : label.includes(query) ? 2 : 1; return score * 100000 + nodeRank(node, filters, scores); } /** Recompute the set of visible nodes matching a search query. */ export function recomputeSearchMatches(visibleNodes, query, filters, scores) { const matchIds = new Set(); const needle = query.trim().toLowerCase(); if (!needle) return { matchIds, results: [] }; const matched = []; for (const node of visibleNodes) { if (node.searchText.includes(needle)) { matchIds.add(node.id); matched.push(node); } } matched.sort((a, b) => matchRank(b, needle, filters, scores) - matchRank(a, needle, filters, scores)); return { matchIds, results: matched }; } /** The best search hit for Enter-to-fly: first of the ranked results. */ export function bestSearchMatch(results) { return results.length ? results[0] : null; } /** * The dossier's ranked walk through one project: findings newest date first * (payload order breaks ties), then tasks in payload order. That is the order * the Memory list shows rows for the project, so Previous/Next and the * keyboard arrows land where a reader scrolling the list would. */ export function rankedProjectIds(nodes, project) { const findings = nodes.filter((node) => node.project === project && deriveKind(node) === "finding"); findings.sort((a, b) => { const left = a.date || ""; const right = b.date || ""; if (left === right) return 0; return left > right ? -1 : 1; }); const tasks = nodes.filter((node) => node.project === project && deriveKind(node) === "task"); const notes = nodes.filter((node) => node.project === project && deriveKind(node) === "note"); return [...findings, ...tasks, ...notes].map((node) => node.id); } /** * Previous/next id in a ranked list, wrapping at both ends. The list is the * host's own order (the Memory list, the contents pane); `delta` is -1 or +1. * Returns null for an empty list or an id that is not in it. */ export function stepRanked(rankedIds, currentId, delta) { if (rankedIds.length === 0) return null; const index = rankedIds.indexOf(currentId); if (index < 0) return null; const total = rankedIds.length; const next = (((index + delta) % total) + total) % total; return rankedIds[next]; }