@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
147 lines (146 loc) • 7.91 kB
JavaScript
import { z } from "zod";
import * as path from "path";
import { FINDING_TYPES } from "../shared.js";
import { FINDINGS_FILENAME } from "../data/access.js";
import { addNote, editNote, listNotes, removeNote } from "../data/notes.js";
import { promoteNote } from "../core/note.js";
import { permissionDeniedError } from "../governance/rbac.js";
import { mcpResponse, resolveStoreForProject } from "./types.js";
function resolve(ctx, projectInput, mode = "write") {
try {
return { ok: true, value: resolveStoreForProject(ctx, projectInput, mode) };
}
catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
function noteSummary(note) {
const promoted = note.promoted ? " · promoted" : "";
return `- ${note.date} ${note.time.slice(0, 5)} · ${note.id}${promoted}\n ${note.text.replace(/\n/g, "\n ")}`;
}
async function refreshIndex(ctx, storeRole, ...files) {
if (storeRole === "primary") {
for (const file of files)
ctx.updateFileInIndex(file);
return;
}
// Incremental indexing is rooted at the primary store. A federated/team-store
// write therefore needs a rebuild so the file retains its real project/store identity.
await ctx.rebuildIndex();
}
export function register(server, ctx) {
server.registerTool("get_notes", {
title: "◆ phren · notes",
description: "List lightweight daily notes for a project. Notes are searchable scratch context and are not injected into agent prompts automatically.",
inputSchema: z.object({
project: z.string().describe("Project name, optionally store-qualified (for example team/project)."),
date: z.string().optional().describe("Optional YYYY-MM-DD date filter."),
limit: z.number().int().min(1).max(500).optional().describe("Maximum notes to return. Defaults to 100."),
}),
}, async ({ project: projectInput, date, limit }) => {
const target = resolve(ctx, projectInput, "read");
if (!target.ok)
return mcpResponse({ ok: false, error: target.error });
const { phrenPath, project } = target.value;
const result = listNotes(phrenPath, project, { date, limit: limit ?? 100 });
if (!result.ok)
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
return mcpResponse({
ok: true,
message: result.data.length ? result.data.map(noteSummary).join("\n\n") : `No notes found for ${project}${date ? ` on ${date}` : ""}.`,
data: { project, notes: result.data },
});
});
server.registerTool("add_note", {
title: "◆ phren · add note",
description: "Add a lightweight Markdown note to a project's daily notes. Use findings instead for durable, agent-curated knowledge.",
inputSchema: z.object({
project: z.string().describe("Project name, optionally store-qualified."),
text: z.string().min(1).describe("Note text; Markdown and multiple lines are supported."),
date: z.string().optional().describe("Optional YYYY-MM-DD date. Defaults to today."),
}),
}, async ({ project: projectInput, text, date }) => {
const target = resolve(ctx, projectInput);
if (!target.ok)
return mcpResponse({ ok: false, error: target.error });
const { phrenPath, project, storeRole } = target.value;
const denied = permissionDeniedError(phrenPath, "add_note", project);
if (denied)
return mcpResponse({ ok: false, error: denied });
return ctx.withWriteQueue(async () => {
const result = addNote(phrenPath, project, text, { date });
if (!result.ok)
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
await refreshIndex(ctx, storeRole, result.data.path);
return mcpResponse({ ok: true, message: `Note added to ${project}: ${result.data.id}`, data: { project, note: result.data } });
});
});
server.registerTool("edit_note", {
title: "◆ phren · edit note",
description: "Replace the text of one daily note. Identify it with the stable nid returned by get_notes.",
inputSchema: z.object({
project: z.string(),
note: z.string().min(1).describe("Stable nid (preferred) or an unambiguous text match."),
text: z.string().min(1).describe("Replacement Markdown text."),
}),
}, async ({ project: projectInput, note, text }) => {
const target = resolve(ctx, projectInput);
if (!target.ok)
return mcpResponse({ ok: false, error: target.error });
const { phrenPath, project, storeRole } = target.value;
const denied = permissionDeniedError(phrenPath, "edit_note", project);
if (denied)
return mcpResponse({ ok: false, error: denied });
return ctx.withWriteQueue(async () => {
const result = editNote(phrenPath, project, note, text);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
await refreshIndex(ctx, storeRole, result.data.path);
return mcpResponse({ ok: true, message: `Updated ${result.data.id}.`, data: { project, note: result.data } });
});
});
server.registerTool("remove_note", {
title: "◆ phren · remove note",
description: "Remove one daily note by stable nid or unambiguous text match.",
inputSchema: z.object({ project: z.string(), note: z.string().min(1) }),
}, async ({ project: projectInput, note }) => {
const target = resolve(ctx, projectInput);
if (!target.ok)
return mcpResponse({ ok: false, error: target.error });
const { phrenPath, project, storeRole } = target.value;
const denied = permissionDeniedError(phrenPath, "remove_note", project);
if (denied)
return mcpResponse({ ok: false, error: denied });
return ctx.withWriteQueue(async () => {
const result = removeNote(phrenPath, project, note);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
await refreshIndex(ctx, storeRole, result.data.path);
return mcpResponse({ ok: true, message: `Removed ${result.data.id}.`, data: { project, note: result.data } });
});
});
server.registerTool("promote_note", {
title: "◆ phren · promote note",
description: "Copy a daily note into durable findings and mark the source note as promoted. The note remains in the daily record.",
inputSchema: z.object({
project: z.string(),
note: z.string().min(1),
findingType: z.enum(FINDING_TYPES).optional(),
}),
}, async ({ project: projectInput, note, findingType }) => {
const target = resolve(ctx, projectInput);
if (!target.ok)
return mcpResponse({ ok: false, error: target.error });
const { phrenPath, project, storeRole } = target.value;
const denied = permissionDeniedError(phrenPath, "promote_note", project);
if (denied)
return mcpResponse({ ok: false, error: denied });
return ctx.withWriteQueue(async () => {
const result = promoteNote(phrenPath, project, note, findingType);
if (!result.ok)
return mcpResponse({ ok: false, error: result.error, errorCode: result.code });
await refreshIndex(ctx, storeRole, result.data.note.path, path.join(phrenPath, project, FINDINGS_FILENAME));
return mcpResponse({ ok: true, message: `Promoted ${result.data.note.id} to a finding.`, data: { project, ...result.data } });
});
});
}