@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
500 lines (499 loc) • 19.2 kB
JavaScript
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];
}