@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
154 lines (153 loc) • 4.96 kB
JavaScript
/**
* Watch mode for the terminal graph.
*
* Every memory phren lands on — an MCP `search_knowledge` hit, a memory a
* hook injected before a prompt, a finding just written — is appended to
* `.runtime/lookup-events.jsonl`. Watch mode tails that log so a graph open
* in one terminal lights up as an agent works in another: the node pulses,
* the camera flies to it, and the finding's full text lands in the pane.
*
* This module owns the event plumbing only. Drawing lives in graph-view.ts
* and camera/selection in controller.ts.
*/
import { lookupEventsLogFile } from "../../shared.js";
import { readRecentLookups } from "../../governance/activity.js";
import { LookupTail } from "../../shared/lookup-tail.js";
/** How long a touched node stays lit, in ms. */
export const HEAT_MS = 6000;
/** Events kept for the activity feed. */
export const ACTIVITY_LIMIT = 60;
const DEFAULT_POLL_MS = 700;
/** Backfilled rows shown when watch mode starts, so the feed is never empty. */
const BACKFILL = 12;
export class GraphWatch {
phrenPath;
/** Newest first. */
activity = [];
heat = new Map();
tail = null;
timer = null;
onEvents = null;
pollMs;
injectedTail;
backfillFn;
now;
constructor(phrenPath, opts = {}) {
this.phrenPath = phrenPath;
this.pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
this.injectedTail = opts.tail;
this.backfillFn = opts.backfill ?? readRecentLookups;
this.now = opts.now ?? (() => Date.now());
}
get running() {
return this.tail !== null;
}
/**
* Begin tailing. Recent history is loaded into the feed immediately but is
* never treated as live: it does not pulse and never moves the camera.
*/
start(onEvents) {
if (this.tail)
return;
this.onEvents = onEvents;
this.tail = this.injectedTail ?? new LookupTail(lookupEventsLogFile(this.phrenPath));
if (!this.activity.length) {
try {
const seenAt = this.now();
this.activity = this.backfillFn(this.phrenPath, BACKFILL).map((event) => ({
event,
seenAt,
nodeId: targetNodeId(event),
historical: true,
}));
}
catch {
this.activity = [];
}
}
if (!this.injectedTail) {
this.timer = setInterval(() => this.poll(), this.pollMs);
this.timer.unref?.();
}
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.tail = null;
this.onEvents = null;
this.heat.clear();
}
/** Drain the log. Returns the new items, newest last, and lights their nodes. */
poll() {
if (!this.tail)
return [];
let events;
try {
events = this.tail.poll();
}
catch {
return [];
}
if (!events.length)
return [];
const seenAt = this.now();
const items = events.map((event) => ({ event, seenAt, nodeId: targetNodeId(event), historical: false }));
for (const item of items) {
if (item.nodeId)
this.heat.set(item.nodeId, seenAt);
}
this.activity = [...items].reverse().concat(this.activity).slice(0, ACTIVITY_LIMIT);
this.onEvents?.(items);
return items;
}
/** 1 right after a node was touched, easing to 0 over HEAT_MS. */
heatOf(nodeId) {
const at = this.heat.get(nodeId);
if (at === undefined)
return 0;
const age = this.now() - at;
if (age >= HEAT_MS) {
this.heat.delete(nodeId);
return 0;
}
return 1 - age / HEAT_MS;
}
/** True while any node is still lit, so the host keeps animating. */
get hot() {
if (!this.heat.size)
return false;
const now = this.now();
for (const at of this.heat.values()) {
if (now - at < HEAT_MS)
return true;
}
this.heat.clear();
return false;
}
clearActivity() {
this.activity = [];
this.heat.clear();
}
}
/**
* The graph node an event points at. Findings carry a precomputed `nodeId`
* (the same id `buildGraph` assigns); everything else falls back to the
* project node, whose id is the project name.
*/
export function targetNodeId(event) {
if (event.nodeId)
return event.nodeId;
return event.project || undefined;
}
/** "just now", "4s", "2m" — the feed's age column. */
export function formatAge(ms) {
if (ms < 1500)
return "now";
if (ms < 60_000)
return `${Math.round(ms / 1000)}s`;
if (ms < 3_600_000)
return `${Math.round(ms / 60_000)}m`;
return `${Math.round(ms / 3_600_000)}h`;
}