@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
274 lines (273 loc) • 15.8 kB
JavaScript
import { cachedCitationFreshness, refreshCitationFreshness } from "../content/citation-freshness.js";
import * as fs from "node:fs";
import * as path from "node:path";
import { readFindings } from "../data/access.js";
import { completeTask, readTasks } from "../data/tasks.js";
import { taskStores } from "../data/task-store-directory.js";
import { FINDING_TAGS } from "../phren-core.js";
import { getProjectRemote, readProjectConfig } from "../project-config.js";
import { redactSecretsForLog } from "../content/secrets.js";
import { resolveProject } from "../store-routing.js";
import { isValidProjectName } from "../utils.js";
import { BridgeError } from "./protocol.js";
import { moduleEnabled } from "../modules/runtime.js";
import { loadCodePackage } from "../modules/code-package.js";
import { bestChunkMatch, tokenizeForOverlap } from "../shared/retrieval.js";
import { findingWriteSchema, taskCompleteSchema, isBranchName, isRepoPath, isSearchText, MAX_FILE_PATHS, MAX_FINDING_BODY, MAX_SEARCH_LIMIT } from "./gitboy-contract.js";
// `GET /v1/projects/:project/memory`: one project's findings, truths and
// tasks as a fixed read-only contract, for a client that renders them beside
// the repository (gitboy). Phren stays the source of truth; nothing here
// writes. The scoped `gitboy-read` SSH key reaches only this route
// (scoped-gateway.ts). Contract: docs/phren-hook.md#gitboy-read-only-memory.
export const PROJECT_MEMORY_ROUTE = /^\/v1\/projects\/([^/]+)\/(memory|memory\/files|memory\/search|tasks|tasks\/complete|findings)$/;
export const MEMORY_LIMITS = { findings: 500, active: 200, queue: 200, done: 50 };
const TAG = new RegExp(`^\\[(${FINDING_TAGS.join("|")})\\]\\s*`, "i");
const DATE = /^\d{4}-\d{2}-\d{2}/;
/** Text that trips phren's credential detector is withheld whole, never cut. */
const safe = (text) => redactSecretsForLog(text);
function freshnessOptions(root, project) {
return { phrenPath: root, projectRemote: getProjectRemote(root, project, readProjectConfig(root, project)) };
}
function finding(item, opts) {
const citationFreshness = item.citationData ? cachedCitationFreshness(item.citationData, opts) : undefined;
const tag = TAG.exec(item.text);
const cite = item.citationData;
const citation = cite && (cite.file || cite.line || cite.commit || cite.symbol)
? { file: cite.file ?? null, line: Number.isInteger(cite.line) ? cite.line : null, commit: cite.commit ?? null, name: cite.symbol ?? null }
: null;
const created = DATE.test(item.date) ? item.date.slice(0, 10) : cite?.created_at && DATE.test(cite.created_at) ? cite.created_at.slice(0, 10) : null;
return {
id: item.stableId ? `fid:${item.stableId}` : item.id,
text: safe(tag ? item.text.slice(tag[0].length) : item.text),
type: tag ? tag[1].toLowerCase() : null,
status: item.status, created, citation,
...(citationFreshness ? { citationFreshness } : {}),
};
}
function task(item) {
return {
id: item.stableId ? `bid:${item.stableId}` : item.id,
text: safe(item.line), created: item.createdAt ?? null,
context: item.context ? safe(item.context) : null,
};
}
function locate(base, rawProject) {
let project;
try {
project = decodeURIComponent(rawProject);
}
catch {
throw new BridgeError(400, "Invalid project name.");
}
if (!isValidProjectName(project))
throw new BridgeError(400, "Invalid project name.");
let root;
try {
root = resolveProject(base, project).store.path;
}
catch {
throw new BridgeError(404, "Project not found.");
}
const storeId = taskStores(base).find(entry => path.resolve(entry.path) === path.resolve(root))?.taskStoreId ?? null;
return { project, root, storeId };
}
/** Newest first, so a cap drops the oldest. Undated findings sort last. */
function sortedFindings(root, project, opts) {
const result = readFindings(root, project);
if (!result.ok)
throw new BridgeError(404, "Project not found.");
const citations = result.data.flatMap(item => item.citationData ? [item.citationData] : []);
// One batch per poll, after the response has been shaped. Refresh deduplicates overlapping polls.
if (citations.length)
setImmediate(() => { void refreshCitationFreshness(citations, { ...opts, budgetMs: 1500 }); });
return result.data.map((item, index) => ({ item, index, date: DATE.test(item.date) ? item.date : "" }))
.sort((a, b) => b.date.localeCompare(a.date) || a.index - b.index).map(entry => entry.item);
}
function readTruths(root, project) {
const file = path.join(root, project, "truths.md");
return fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n").filter(line => line.startsWith("- ")).map(line => line.slice(2).trim()) : [];
}
export function projectMemory(base, rawProject) {
const { project, root, storeId } = locate(base, rawProject);
const opts = freshnessOptions(root, project);
const allFindings = sortedFindings(root, project, opts);
const truths = readTruths(root, project).map(text => ({ text: safe(text) }));
// Tasks are their own module; with it off the sections are empty.
const taskDoc = readTasks(root, project);
const items = taskDoc.ok ? taskDoc.data.items : { Active: [], Queue: [], Done: [] };
const truncated = allFindings.length > MEMORY_LIMITS.findings || items.Active.length > MEMORY_LIMITS.active
|| items.Queue.length > MEMORY_LIMITS.queue || items.Done.length > MEMORY_LIMITS.done;
return {
project, store_id: storeId, remote: opts.projectRemote ?? null, truncated,
findings: allFindings.slice(0, MEMORY_LIMITS.findings).map(item => finding(item, opts)),
truths,
tasks: {
active: items.Active.slice(0, MEMORY_LIMITS.active).map(task),
queue: items.Queue.slice(0, MEMORY_LIMITS.queue).map(task),
// Done is newest first in tasks.md; keep the most recent.
done: items.Done.slice(0, MEMORY_LIMITS.done).map(task),
},
};
}
/** Findings about these repository files: cited by file, or citing a function,
* type or variable the code index places in one of them. */
export async function memoryForFiles(base, rawProject, paths) {
const { project, root, storeId } = locate(base, rawProject);
const opts = freshnessOptions(root, project);
if (paths.length < 1 || paths.length > MAX_FILE_PATHS || !paths.every(isRepoPath)) {
throw new BridgeError(400, `Send 1 to ${MAX_FILE_PATHS} repository-relative paths.`);
}
const wanted = new Set(paths);
const all = sortedFindings(root, project, opts);
const citedFile = (item) => item.citationData?.file?.replace(/^\.\//, "");
const named = all.filter(item => item.citationData?.symbol && !wanted.has(citedFile(item) ?? ""));
let symbols = "unavailable";
let files = {};
if (named.length && moduleEnabled(root, "code")) {
const code = await loadCodePackage(root).catch(() => undefined);
if (code && typeof code.resolveSymbolFiles === "function") {
const resolved = await code.resolveSymbolFiles(root, project, named.map(item => item.citationData.symbol)).catch(() => undefined);
if (resolved?.available) {
symbols = "indexed";
files = resolved.value;
}
}
}
const matches = [];
for (const item of all) {
const file = citedFile(item), name = item.citationData?.symbol;
if (file && wanted.has(file))
matches.push({ ...finding(item, opts), match: "file" });
else if (name && files[name] && wanted.has(files[name]))
matches.push({ ...finding(item, opts), match: "symbol" });
}
return { project, store_id: storeId, symbols, truncated: matches.length > MEMORY_LIMITS.findings,
findings: matches.slice(0, MEMORY_LIMITS.findings) };
}
/**
* Findings and truths ranked against `q` with phren's retrieval tokenizer and
* chunk matcher (the hook's relevance floor). Each query token is weighted by
* how rare it is across this project's findings and truths (the normalised IDF
* `tokenRarity` uses), so a stack trace's common words count for little.
* `score` is the matched share of that weight, 0 to 1; findings that are no
* longer active count half.
*/
export function memorySearch(base, rawProject, q, limit) {
const { project, root } = locate(base, rawProject);
const opts = freshnessOptions(root, project);
if (!isSearchText(q))
throw new BridgeError(400, "q must be 1 to 1000 characters of text.");
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_SEARCH_LIMIT)
throw new BridgeError(400, `limit must be 1 to ${MAX_SEARCH_LIMIT}.`);
const entries = [
...sortedFindings(root, project, opts).map(item => ({ kind: "finding", text: item.text, item })),
...readTruths(root, project).map(text => ({ kind: "truth", text, item: undefined })),
];
const tokens = tokenizeForOverlap(q, 64);
const corpus = entries.map(entry => new Set(tokenizeForOverlap(entry.text, Number.POSITIVE_INFINITY)));
const total = entries.length, scale = Math.log(total + 1);
const rarity = new Map();
for (const token of tokens) {
const df = corpus.filter(set => set.has(token)).length;
if (df > 0)
rarity.set(token, total > 1 ? Math.max(0.05, Math.min(1, Math.log((total + 1) / (df + 1)) / scale)) : 1);
}
const present = [...rarity.keys()];
const weight = present.reduce((sum, token) => sum + rarity.get(token), 0);
const minimum = Math.min(2, present.length);
const hits = [];
if (weight > 0)
for (const entry of entries) {
const best = bestChunkMatch(present, { content: entry.text }, rarity);
if (!best || best.distinct < minimum)
continue;
const inactive = entry.item && entry.item.status !== "active";
const score = Math.round((best.rarity / weight) * (inactive ? 0.5 : 1) * 1000) / 1000;
const shaped = entry.item ? finding(entry.item, opts) : null;
hits.push({ kind: entry.kind, score, matched: best.distinct, text: shaped ? shaped.text : safe(entry.text), finding: shaped });
}
hits.sort((a, b) => b.score - a.score || b.matched - a.matched);
return { project, query: q, results: hits.slice(0, limit) };
}
// ── /tasks?branch= ───────────────────────────────────────────────────────────
/** Open tasks that name this branch (or its last segment) or the issue number
* in it (`fix/123-login`, `issue-123`, `gh-123`). One read of tasks.md. */
export function tasksForBranch(base, rawProject, branch) {
const { project, root } = locate(base, rawProject);
if (!isBranchName(branch))
throw new BridgeError(400, "Invalid branch name.");
const taskDoc = readTasks(root, project);
const items = taskDoc.ok ? taskDoc.data.items : { Active: [], Queue: [], Done: [] };
const lower = branch.toLowerCase();
const tail = lower.includes("/") ? lower.slice(lower.lastIndexOf("/") + 1) : "";
const names = [lower, ...(tail.length >= 4 ? [tail] : [])];
const issues = [...lower.matchAll(/(?:^|[\/_-])(?:issue-?|gh-?|#)?(\d{1,7})(?=$|[\/_-])/g)].map(match => Number(match[1]));
const mentionsIssue = (text, issue) => new RegExp(`(?:#|/issues/|/pull/|/pulls/)${issue}(?!\\d)`).test(text);
const matches = [];
for (const [section, list] of [["active", items.Active], ["queue", items.Queue]]) {
for (const item of list) {
const text = `${item.line}\n${item.context ?? ""}`.toLowerCase();
const match = names.some(name => text.includes(name)) ? "branch"
: issues.some(issue => item.githubIssue === issue || (item.githubUrl && mentionsIssue(item.githubUrl, issue)) || mentionsIssue(text, issue)) ? "issue"
: undefined;
if (match)
matches.push({ ...task(item), section, match });
}
}
return { project, branch, truncated: matches.length > 50, tasks: matches.slice(0, 50) };
}
// ── POST /findings ───────────────────────────────────────────────────────────
let writes = Promise.resolve();
/** Save one finding through the same path as the add_finding MCP tool: the
* project's write permission, duplicate and conflict checks, the secret
* scan and citation validation against the code index. */
export async function saveFinding(base, rawProject, input) {
const { project, root } = locate(base, rawProject);
const opts = freshnessOptions(root, project);
if (Buffer.byteLength(JSON.stringify(input ?? null)) > MAX_FINDING_BODY)
throw new BridgeError(413, `The body must be at most ${MAX_FINDING_BODY} bytes.`);
const body = findingWriteSchema.parse(input);
const { handleAddFinding } = await import("../tools/finding.js");
const ctx = {
phrenPath: base, profile: process.env.PHREN_PROFILE ?? "",
db: () => { throw new Error("No index in the Hook."); }, rebuildIndex: async () => { }, updateFileInIndex: () => { },
withWriteQueue: (fn) => { const next = writes.then(fn, fn); writes = next.catch(() => undefined); return next; },
};
const response = await handleAddFinding(ctx, {
project, finding: body.text, findingType: body.type, tool: "gitboy",
...(body.citation && Object.keys(body.citation).length ? { citation: body.citation } : {}),
});
const result = JSON.parse(response.content[0].text);
if (!result.ok) {
const error = result.error ?? "The finding was not saved.";
throw new BridgeError(/permission|denied|not allowed/i.test(error) ? 403 : 400, safe(error));
}
const status = result.data?.status === "skipped" ? "duplicate" : "saved";
const saved = status === "saved" ? sortedFindings(root, project, opts)
.find(item => item.text === (result.data?.finding ?? "").replace(/^-\s+/, "").trim()) : undefined;
return { project, status, finding: saved ? finding(saved, opts) : null };
}
// ── POST /tasks/complete ─────────────────────────────────────────────────────
/** Mark one open task done, by the stable id `/tasks?branch=` gave gitboy when
* its pull request merged. Exact ids only: no text matching, so a wrong guess
* can never complete another task. A task already done answers `already-done`. */
export function completeProjectTask(base, rawProject, input) {
const { project, root } = locate(base, rawProject);
if (Buffer.byteLength(JSON.stringify(input ?? null)) > 256)
throw new BridgeError(413, "The body must be at most 256 bytes.");
const { id } = taskCompleteSchema.parse(input);
const doc = readTasks(root, project);
if (!doc.ok)
throw new BridgeError(404, "No tasks for this project.");
const stable = id.slice(4);
const open = [...doc.data.items.Active, ...doc.data.items.Queue].find(item => item.stableId === stable);
if (!open) {
if (doc.data.items.Done.some(item => item.stableId === stable))
return { project, id, status: "already-done" };
throw new BridgeError(404, "No task with this id.");
}
const done = completeTask(root, project, id);
if (!done.ok)
throw new BridgeError(400, safe(done.error));
return { project, id, status: "completed" };
}