@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
466 lines (465 loc) • 22.7 kB
JavaScript
import { rm } from "node:fs/promises";
import * as fs from "node:fs";
import * as path from "node:path";
import { resolveAllStores } from "../store-registry.js";
import { git } from "./projects.js";
import { z } from "zod";
import { getProjectDirs } from "../shared.js";
import { codePackageHint, loadCodePackage } from "../modules/code-package.js";
const indexProject = async (store, ...args) => (await requireCodePackage(store)).indexProject(store, ...args);
import { isValidProjectName } from "../utils-paths.js";
import { errorMessage } from "../utils.js";
import { logger } from "../logger.js";
import { BridgeError, bridgeRoot } from "./protocol.js";
export async function requireCodePackage(store) {
const code = await loadCodePackage(store);
if (!code)
throw new BridgeError(503, codePackageHint(store));
return code;
}
/** Resolve only registered stores. Phone IDs are repository names; no client
* path can select a filesystem location. Omitting the ID supports older phones. */
export async function resolveCodeStore(base, value, write = false) {
if (!value)
return base;
const id = z.string().min(1).max(200).regex(/^[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)?$/).parse(value);
const stores = resolveAllStores(base).filter(store => store.available !== false);
const matches = [];
for (const store of stores) {
const remote = store.remote ?? (await git(store.path, "config", "--get", "remote.origin.url").catch(() => "")).trim();
const repo = /github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/.exec(remote)?.[1];
if (id === store.id || id === store.name || id.toLowerCase() === repo?.toLowerCase())
matches.push(store);
}
if (matches.length !== 1)
throw new BridgeError(404, "That store is not uniquely registered on this computer.");
if (write && matches[0].role === "readonly")
throw new BridgeError(403, "That store is read-only.");
return matches[0].path;
}
/**
* Read routes over the `code` module's local symbol index (stage 3).
*
* The phone's Code screen calls these over the Hook's HTTP pipe; each one is a
* thin JSON formatter over `code/query.ts`, so the CLI, the MCP tools and the
* phone all read the same index the same way. A project with no index is a 404
* with the command that builds one. `CodeReindexer` follows the git module's
* recorded change events and re-indexes the affected project after a short
* debounce; a branch switch (HEAD changed) forces a full re-index.
*/
const KIND_VALUES = ["function", "method", "class", "struct", "enum", "interface", "type", "variable", "types"];
const projectSchema = z.string().min(1).max(100).refine(value => isValidProjectName(value), "Choose a valid project name.");
const querySchema = z.string().max(500);
const symbolSchema = z.string().min(1).max(4600);
const pathSchema = z.string().min(1).max(4096).refine(value => !value.includes("\0") && !value.split("/").includes(".."), "Choose a valid file path.");
const relativePathSchema = pathSchema.refine(value => !path.isAbsolute(value) && !path.win32.isAbsolute(value)
&& !value.includes("\\") && !value.split("/").some(part => part === "." || part === ""));
const optionalPath = (value) => value ? relativePathSchema.parse(value) : undefined;
const offsetSchema = z.coerce.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const kindSchema = z.enum(KIND_VALUES);
const limitSchema = z.coerce.number().int().min(1).max(500);
const topSchema = z.coerce.number().int().min(1).max(100);
/** The 404 a route throws when the project has no index on this computer. */
function noIndex(project) {
return new BridgeError(404, `No code index for "${project}" on this computer. Build one with: phren code index ${project}`);
}
/** The phone names a project and an optional query; both are validated here. */
export class CodeRoutes {
store;
constructor(store) {
this.store = store;
}
/** A filename need not resemble a symbol declared inside it. Query the
* index's files table through @phren/code's existing public read API so
* installed optional packages need no new export to serve this route. */
async files(projectValue, nameValue, limitValue) {
const project = projectSchema.parse(projectValue ?? "");
const name = relativePathSchema.refine(value => !/[\x00-\x1f\x7f]/.test(value)
&& !value.split("/").includes(".git")).parse(nameValue ?? "");
const limit = limitValue ? topSchema.parse(limitValue) : 20;
const code = await requireCodePackage(this.store);
const database = await code.openCodeDatabase(this.store, project, false);
if (!database)
throw noIndex(project);
try {
const suffix = "/" + name;
const rows = code.rowsOf(database.db, `SELECT path, language FROM files WHERE path = ? OR substr(path, -?) = ?
ORDER BY CASE WHEN path = ? THEN 0 ELSE 1 END, length(path), path LIMIT ?`, [name, [...suffix].length, suffix, name, limit + 1]);
return { project, name, files: rows.slice(0, limit).map(row => ({ path: code.stringAt(row, 0), language: code.stringAt(row, 1) })),
truncated: rows.length > limit };
}
finally {
database.close();
}
}
async status(projectValue) {
const project = projectSchema.parse(projectValue ?? "");
const result = await (await requireCodePackage(this.store)).codeIndexStatus(this.store, project);
if (!result.available)
throw noIndex(project);
return result;
}
async search(projectValue, queryValue, kindValue, limitValue, directoryValue) {
const project = projectSchema.parse(projectValue ?? "");
const query = querySchema.parse(queryValue ?? "");
const kind = kindValue === null || kindValue === "" ? undefined : kindSchema.parse(kindValue);
const limit = limitValue === null || limitValue === "" ? undefined : limitSchema.parse(limitValue);
const result = await (await requireCodePackage(this.store)).search(this.store, project, query, kind, limit ?? 20, optionalPath(directoryValue));
if (!result.available)
throw noIndex(project);
return { project, query, symbols: result.value };
}
async tree(projectValue, directoryValue) {
const project = projectSchema.parse(projectValue ?? "");
const directory = optionalPath(directoryValue) ?? "";
const result = await (await requireCodePackage(this.store)).treeSummary(this.store, project, directory);
if (!result.available)
throw noIndex(project);
return { project, directory, entries: result.value };
}
async usagePage(projectValue, values = {}) {
const project = projectSchema.parse(projectValue ?? "");
const result = await (await requireCodePackage(this.store)).usagePage(this.store, project, {
kind: values.kind ? kindSchema.parse(values.kind) : undefined,
file: optionalPath(values.file), directory: optionalPath(values.directory),
offset: values.offset ? offsetSchema.parse(values.offset) : 0,
limit: values.limit ? topSchema.parse(values.limit) : 50,
end: values.end ? z.enum(["0", "1"]).parse(values.end) === "1" : false,
});
if (!result.available)
throw noIndex(project);
return { project, ...result.value };
}
/** The checkout this computer indexes for `project`, or a 404 naming the project. */
async checkout(project) {
try {
return fs.realpathSync((await requireCodePackage(this.store)).resolveRepoRoot(this.store, project));
}
catch {
throw new BridgeError(404, `${project} has no checkout on this computer.`);
}
}
async changedIn(project, added) {
const code = await requireCodePackage(this.store);
if (typeof code.changedDeclarations !== "function")
throw new BridgeError(503, "Update @phren/code to see what changed.");
const result = await code.changedDeclarations(this.store, project, new Map([...added].map(([file, lines]) => [file, [...lines].sort((a, b) => a - b)])));
if (!result.available)
throw noIndex(project);
return result.value;
}
/**
* What changed: the functions, types and variables that today's agent
* sessions edited in this project's checkout, and its last 10 commits,
* grouped by file, most recent work first. Session edits carry the line
* numbers they had when made, so a later edit to the same file can shift one.
*/
async whatChanged(projectValue, now = new Date()) {
const project = projectSchema.parse(projectValue ?? "");
const root = await this.checkout(project);
const code = await requireCodePackage(this.store);
const added = new Map();
const take = (patch) => {
for (const [file, lines] of code.addedLinesByFile(patch)) {
const set = added.get(file) ?? new Set();
for (const line of lines)
set.add(line);
added.set(file, set);
}
};
const midnight = new Date(now);
midnight.setHours(0, 0, 0, 0);
const dir = path.join(bridgeRoot(), "changes");
const logs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(name => name.endsWith(".jsonl")) : [];
const today = logs.map(name => ({ file: path.join(dir, name), at: fs.statSync(path.join(dir, name)).mtimeMs }))
.filter(entry => entry.at >= midnight.getTime()).sort((a, b) => b.at - a.at);
for (const log of today) {
for (const line of fs.readFileSync(log.file, "utf8").split("\n")) {
if (!line.trim())
continue;
let record;
try {
record = JSON.parse(line);
}
catch {
continue;
}
for (const file of record.files ?? []) {
if (typeof file.root !== "string" || typeof file.patch !== "string")
continue;
let same = false;
try {
same = fs.realpathSync(file.root) === root;
}
catch {
same = false;
}
if (same)
take(file.patch);
}
}
}
// A repository's first commit adds every file; it is the import, not work.
try {
take(await git(root, "-c", "log.showRoot=false", "log", "-10", "--format=", "-p", "-U0", "--no-color", "--no-ext-diff"));
}
catch { /* A checkout without commits yet has only its session edits. */ }
const items = await this.changedIn(project, added);
const files = [];
for (const item of items) {
let group = files.find(entry => entry.path === item.file);
if (!group) {
group = { path: item.file, items: [] };
files.push(group);
}
group.items.push(item);
}
return { project, files };
}
/**
* Per-file counts for the Changes tree's chips: functions and types the
* working tree changes or adds (variables stay out to keep a chip short).
* An untracked file is new throughout.
*/
async changeCounts(projectValue, pathsValue) {
const project = projectSchema.parse(projectValue ?? "");
let raw;
try {
raw = JSON.parse(pathsValue ?? "[]");
}
catch {
throw new BridgeError(400, "Choose valid paths.");
}
const paths = [...new Set(z.array(relativePathSchema).min(1).max(200).parse(raw))];
const root = await this.checkout(project);
const code = await requireCodePackage(this.store);
const added = new Map();
for (const [file, lines] of code.addedLinesByFile(await git(root, "diff", "HEAD", "-U0", "--no-color", "--no-ext-diff", "--", ...paths).catch(() => "")))
added.set(file, new Set(lines));
const untracked = (await git(root, "ls-files", "--others", "--exclude-standard", "--", ...paths).catch(() => "")).split("\n").filter(Boolean);
for (const file of untracked) {
let count = 0;
try {
count = fs.readFileSync(path.join(root, file), "utf8").split("\n").length;
}
catch {
continue;
}
added.set(file, new Set(Array.from({ length: count }, (_, index) => index + 1)));
}
const items = await this.changedIn(project, added);
const entries = paths.map(file => {
const mine = items.filter(item => item.file === file || item.file.startsWith(file + "/"));
const tally = (family) => ({
changed: mine.filter(item => item.family === family && !item.isNew).length,
added: mine.filter(item => item.family === family && item.isNew).length,
});
const first = mine.find(item => item.family !== "variable");
return { path: file, functions: tally("function"), types: tally("type"),
...(first ? { first: `${first.file}::${first.parent ? first.parent + "." : ""}${first.name}` } : {}) };
});
return { project, entries };
}
/** Turns code intelligence off for a project: its index is deleted, and
* the reindexer follows only projects that have one, so nothing rebuilds it
* until the phone turns it on again. */
async disable(projectValue) {
const project = projectSchema.parse(projectValue ?? "");
const { codeDatabasePath } = await requireCodePackage(this.store);
const file = codeDatabasePath(this.store, project);
await Promise.all([file, `${file}-journal`, `${file}-wal`, `${file}-shm`].map(path => rm(path, { force: true })));
return { project, disabled: true };
}
async reindex(projectValue) {
const project = projectSchema.parse(projectValue ?? "");
await indexProject(this.store, project);
return this.status(project);
}
async outline(projectValue, pathValue) {
const project = projectSchema.parse(projectValue ?? "");
const file = pathSchema.parse(pathValue ?? "");
const result = await (await requireCodePackage(this.store)).outline(this.store, project, file);
if (!result.available)
throw noIndex(project);
return { project, path: file, entries: result.value };
}
/** Resolved uses made from one file, with the declaration each names, so the
* phone's code viewer can make those identifiers tappable. */
async fileReferences(projectValue, pathValue) {
const project = projectSchema.parse(projectValue ?? "");
const file = relativePathSchema.parse(pathValue ?? "");
const code = await requireCodePackage(this.store);
if (typeof code.fileReferences !== "function")
throw new BridgeError(503, "Update @phren/code to make identifiers tappable.");
const result = await code.fileReferences(this.store, project, file);
if (!result.available)
throw noIndex(project);
return { project, path: file, references: result.value };
}
async outlineSummary(projectValue, pathsValue) {
const project = projectSchema.parse(projectValue ?? "");
let raw;
try {
raw = JSON.parse(pathsValue ?? "[]");
}
catch {
throw new BridgeError(400, "Choose valid paths.");
}
const paths = z.array(pathSchema.refine(value => !path.isAbsolute(value) && !path.win32.isAbsolute(value)
&& !value.split(/[\\/]/).includes(".."))).min(1).max(200).parse(raw);
const result = await (await requireCodePackage(this.store)).outlineSummary(this.store, project, [...new Set(paths)]);
if (!result.available)
throw noIndex(project);
return { project, entries: result.value };
}
async definition(projectValue, symbolValue) {
const project = projectSchema.parse(projectValue ?? "");
const symbol = symbolSchema.parse(symbolValue ?? "");
const result = await (await requireCodePackage(this.store)).definition(this.store, project, symbol);
if (!result.available)
throw noIndex(project);
if (!result.value)
throw new BridgeError(404, `Nothing named "${symbol}" in ${project}.`);
return { project, definition: { ...result.value, findings: (await requireCodePackage(this.store)).findingsCitingSymbol(this.store, project, (await requireCodePackage(this.store)).citationSymbolName(result.value.symbol)) } };
}
async references(projectValue, symbolValue, limitValue) {
const project = projectSchema.parse(projectValue ?? "");
const symbol = symbolSchema.parse(symbolValue ?? "");
const limit = limitValue === null || limitValue === "" ? undefined : limitSchema.parse(limitValue);
const result = await (await requireCodePackage(this.store)).references(this.store, project, symbol, limit ?? 200);
if (!result.available)
throw noIndex(project);
if (!result.value)
throw new BridgeError(404, `Nothing named "${symbol}" in ${project}.`);
return { project, references: result.value };
}
async usage(projectValue, topValue) {
const project = projectSchema.parse(projectValue ?? "");
const top = topValue === null || topValue === "" ? undefined : topSchema.parse(topValue);
const result = await (await requireCodePackage(this.store)).usage(this.store, project, top ?? 10);
if (!result.available)
throw noIndex(project);
return { project, usage: { hot: result.value.top, cold: result.value.bottom } };
}
}
/**
* Re-indexes a project when the git module's change capture records a file
* event inside it. The Hook only constructs this while the `code` module is on,
* and only projects that already have an index are followed. A branch switch
* (the repository's HEAD moved) upgrades the pending run to a full re-index.
*/
export class CodeReindexer {
store;
index;
log;
debounceMs;
timers = new Map();
pendingFull = new Set();
running = new Set();
rerun = new Set();
heads = new Map();
closed = false;
constructor(options) {
this.store = options.store;
this.index = options.index ?? indexProject;
this.log = options.log ?? (line => logger.info("code", line));
this.debounceMs = options.debounceMs ?? 500;
}
/** One recorded change event; schedules an incremental re-index of the project it belongs to. */
record(files) {
if (this.closed || files.length === 0)
return;
void this.recordAsync(files).catch(error => this.log(errorMessage(error)));
}
async recordAsync(files) {
const projects = await this.indexedProjects();
if (this.closed)
return;
for (const root of new Set(files.map(file => file.root))) {
const project = projects.find(entry => entry.root === root);
if (!project)
continue;
const head = readHead(root);
const previous = this.heads.get(root);
if (head !== undefined)
this.heads.set(root, head);
this.schedule(project.project, previous !== undefined && head !== undefined && previous !== head);
}
}
close() {
this.closed = true;
for (const timer of this.timers.values())
clearTimeout(timer);
this.timers.clear();
this.rerun.clear();
this.pendingFull.clear();
}
async indexedProjects() {
const { codeDatabasePath, resolveRepoRoot } = await requireCodePackage(this.store);
const result = [];
for (const directory of getProjectDirs(this.store)) {
const project = path.basename(directory);
if (!fs.existsSync(codeDatabasePath(this.store, project)))
continue;
try {
result.push({ project, root: fs.realpathSync(resolveRepoRoot(this.store, project)) });
}
catch { /* No checkout for this project on this computer. */ }
}
return result;
}
schedule(project, full) {
if (this.closed)
return;
if (full)
this.pendingFull.add(project);
const existing = this.timers.get(project);
if (existing)
clearTimeout(existing);
const timer = setTimeout(() => { this.timers.delete(project); void this.run(project); }, this.debounceMs);
timer.unref?.();
this.timers.set(project, timer);
}
async run(project) {
if (this.closed)
return;
// A run already in flight: let it finish, then take the newest event.
if (this.running.has(project)) {
this.rerun.add(project);
return;
}
const full = this.pendingFull.delete(project);
this.running.add(project);
try {
const result = await this.index(this.store, project, { full });
this.log(`re-indexed ${project}${full ? " (full)" : ""}: ${result.parsed} parsed, ${result.symbols} declarations, ${result.durationMs} ms`);
}
catch (error) {
this.log(`re-index of ${project} failed: ${errorMessage(error)}`);
}
finally {
this.running.delete(project);
if (this.rerun.delete(project))
this.schedule(project, false);
}
}
}
/** HEAD of the repository root, for branch-switch detection. A linked worktree's
* `.git` is a file that points at its real git directory. */
function readHead(root) {
try {
const dotGit = path.join(root, ".git");
const gitDirectory = fs.statSync(dotGit).isDirectory()
? dotGit
: (() => {
const match = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(dotGit, "utf8"));
return match ? path.resolve(root, match[1]) : undefined;
})();
if (!gitDirectory)
return undefined;
return fs.readFileSync(path.join(gitDirectory, "HEAD"), "utf8").trim();
}
catch {
return undefined;
}
}