@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
354 lines (353 loc) • 16.6 kB
JavaScript
import { getPhrenPath } from "../shared.js";
import { getIndexPolicy, updateIndexPolicy, getRetentionPolicy, updateRetentionPolicy, getWorkflowPolicy, updateWorkflowPolicy, mergeConfig, VALID_TASK_MODES, VALID_FINDING_SENSITIVITY, } from "../shared/governance.js";
import { updateProjectConfigOverrides } from "../project-config.js";
import { isValidProjectName } from "../utils.js";
import { parseProjectArg, warnIfUnregistered } from "./config-shared.js";
/**
* Exit 1 unless every `set` token is a `--key=value` pair. The parsers below skip
* anything else, so `set ttlDays 90` or `set --ttlDays 90` used to write an
* unchanged config and print it as if the change had applied.
*/
function requireFlagPairs(setArgs, usage) {
const isPair = (arg) => {
if (!arg.startsWith("--"))
return false;
const [k, v] = arg.slice(2).split("=");
return Boolean(k) && v !== undefined;
};
const bad = setArgs.filter((arg) => !isPair(arg));
if (setArgs.length > 0 && bad.length === 0)
return;
console.error("No settings changed: every value must be given as --key=value.");
if (bad.length > 0)
console.error(` Not understood: ${bad.join(" ")}`);
console.error(` Usage: ${usage}`);
process.exit(1);
}
// ── Finding sensitivity config ────────────────────────────────────────────────
export const FINDING_SENSITIVITY_CONFIG = {
minimal: {
sessionCap: 0,
proactivityFindings: "low",
agentInstruction: "Only save findings when the user explicitly asks you to remember something.",
},
conservative: {
sessionCap: 3,
proactivityFindings: "medium",
agentInstruction: "Save decisions and pitfalls only — skip patterns and observations.",
},
balanced: {
sessionCap: 10,
proactivityFindings: "high",
agentInstruction: "Save non-obvious patterns, decisions, pitfalls, and bugs worth remembering next session.",
},
aggressive: {
sessionCap: 20,
proactivityFindings: "high",
agentInstruction: "Save everything worth remembering — err on the side of capturing.",
},
};
function handleWorkflowField(args, opts) {
const phrenPath = getPhrenPath();
const { project: projectArg, rest: filteredArgs } = parseProjectArg(args);
const action = filteredArgs[0];
if (!action || action === "get") {
if (projectArg) {
if (!isValidProjectName(projectArg)) {
console.error(`Invalid project name: "${projectArg}"`);
process.exit(1);
}
const resolved = mergeConfig(phrenPath, projectArg);
const value = opts.getProjectValue(resolved);
console.log(JSON.stringify(opts.formatProjectOutput(projectArg, value), null, 2));
return;
}
console.log(JSON.stringify(opts.getSnapshot(phrenPath), null, 2));
return;
}
const applyValue = (value) => {
if (projectArg) {
if (!isValidProjectName(projectArg)) {
console.error(`Invalid project name: "${projectArg}"`);
process.exit(1);
}
warnIfUnregistered(phrenPath, projectArg);
updateProjectConfigOverrides(phrenPath, projectArg, (current) => ({ ...current, [opts.projectOverrideKey]: value }));
const resolved = mergeConfig(phrenPath, projectArg);
const eff = opts.getProjectValue(resolved);
console.log(JSON.stringify(opts.formatProjectOutput(projectArg, eff), null, 2));
return;
}
const result = updateWorkflowPolicy(phrenPath, { [opts.workflowPatchKey]: value });
if (!result.ok) {
console.error(result.error);
if (result.code === "PERMISSION_DENIED")
process.exit(1);
return;
}
console.log(JSON.stringify(opts.getSnapshot(phrenPath), null, 2));
};
if (action === "set") {
const value = opts.normalize(filteredArgs[1]);
if (!value) {
console.error(`Usage: phren config ${opts.fieldName} set [${opts.validValues.join("|")}]`);
process.exit(1);
}
return applyValue(value);
}
const value = opts.normalize(action);
if (value)
return applyValue(value);
console.error(`Usage: phren config ${opts.fieldName} [--project <name>] [get|set <value>|<value>] — values: ${opts.validValues.join("|")}`);
process.exit(1);
}
function normalizeFromList(raw, validValues) {
if (!raw)
return null;
const lower = raw.trim().toLowerCase();
return validValues.includes(lower) ? lower : null;
}
export function handleConfigTaskMode(args) {
handleWorkflowField(args, {
fieldName: "task-mode",
validValues: VALID_TASK_MODES,
normalize: (raw) => normalizeFromList(raw, VALID_TASK_MODES),
getSnapshot: (phrenPath) => ({ taskMode: getWorkflowPolicy(phrenPath).taskMode }),
getProjectValue: (resolved) => resolved.taskMode,
formatProjectOutput: (proj, value) => ({ _project: proj, taskMode: value }),
workflowPatchKey: "taskMode",
projectOverrideKey: "taskMode",
});
}
export function handleConfigFindingSensitivity(args) {
handleWorkflowField(args, {
fieldName: "finding-sensitivity",
validValues: VALID_FINDING_SENSITIVITY,
normalize: (raw) => normalizeFromList(raw, VALID_FINDING_SENSITIVITY),
getSnapshot: (phrenPath) => {
const policy = getWorkflowPolicy(phrenPath);
const level = policy.findingSensitivity;
return { level, ...FINDING_SENSITIVITY_CONFIG[level] };
},
getProjectValue: (resolved) => resolved.findingSensitivity,
formatProjectOutput: (proj, value) => ({ _project: proj, level: value, ...FINDING_SENSITIVITY_CONFIG[value] }),
workflowPatchKey: "findingSensitivity",
projectOverrideKey: "findingSensitivity",
});
}
// ── Index policy ─────────────────────────────────────────────────────────────
export async function handleIndexPolicy(args) {
if (!args.length || args[0] === "get") {
console.log(JSON.stringify(getIndexPolicy(getPhrenPath()), null, 2));
return;
}
if (args[0] === "set") {
const patch = {};
for (const arg of args.slice(1)) {
if (!arg.startsWith("--"))
continue;
const [k, v] = arg.slice(2).split("=");
if (!k || v === undefined)
continue;
if (k === "include") {
patch.includeGlobs = v.split(",").map((s) => s.trim()).filter(Boolean);
}
else if (k === "exclude") {
patch.excludeGlobs = v.split(",").map((s) => s.trim()).filter(Boolean);
}
else if (k === "includeHidden") {
patch.includeHidden = /^(1|true|yes|on)$/i.test(v);
}
}
const result = updateIndexPolicy(getPhrenPath(), patch);
if (!result.ok) {
console.error(result.error);
if (result.code === "PERMISSION_DENIED")
process.exit(1);
return;
}
console.log(JSON.stringify(result.data, null, 2));
return;
}
console.error("Usage: phren index-policy [get|set --include=**/*.md,**/skills/**/*.md,.claude/skills/**/*.md --exclude=**/node_modules/**,**/.git/** --includeHidden=false]");
process.exit(1);
}
// ── Memory policy ────────────────────────────────────────────────────────────
export async function handleRetentionPolicy(args) {
const phrenPath = getPhrenPath();
const { project: projectArg, rest: filteredArgs } = parseProjectArg(args);
if (!filteredArgs.length || filteredArgs[0] === "get") {
if (projectArg) {
if (!isValidProjectName(projectArg)) {
console.error(`Invalid project name: "${projectArg}"`);
process.exit(1);
}
const resolved = mergeConfig(phrenPath, projectArg);
console.log(JSON.stringify({ _project: projectArg, ...resolved.retentionPolicy }, null, 2));
return;
}
console.log(JSON.stringify(getRetentionPolicy(phrenPath), null, 2));
const conflictOn = process.env.PHREN_FEATURE_SEMANTIC_CONFLICT === "1";
process.stderr.write(`\nDedup: free Jaccard similarity scan on every add_finding (no API key needed).\n`);
process.stderr.write(` Near-matches (30–55% overlap) are returned in the response for the agent to decide.\n`);
if (conflictOn) {
process.stderr.write(`\nConflict detection (PHREN_FEATURE_SEMANTIC_CONFLICT=1): active.\n`);
process.stderr.write(` Uses an LLM for batch conflict checks. See: phren config llm\n`);
}
else {
process.stderr.write(`\nConflict detection: disabled (set PHREN_FEATURE_SEMANTIC_CONFLICT=1 to enable for batch ops).\n`);
process.stderr.write(` LLM needed only for batch operations (phren maintain consolidate/extract).\n`);
}
return;
}
if (filteredArgs[0] === "set") {
if (projectArg) {
if (!isValidProjectName(projectArg)) {
console.error(`Invalid project name: "${projectArg}"`);
process.exit(1);
}
requireFlagPairs(filteredArgs.slice(1), "phren config policy set --project <name> --ttlDays=90 --retentionDays=365");
warnIfUnregistered(phrenPath, projectArg);
updateProjectConfigOverrides(phrenPath, projectArg, (current) => {
const existingRetention = current.retentionPolicy ?? {};
const retentionPatch = { ...existingRetention };
for (const arg of filteredArgs.slice(1)) {
if (!arg.startsWith("--"))
continue;
const [k, v] = arg.slice(2).split("=");
if (!k || v === undefined)
continue;
const num = Number(v);
const value = Number.isNaN(num) ? v : num;
if (k.startsWith("decay.")) {
retentionPatch.decay = { ...(retentionPatch.decay ?? {}) };
retentionPatch.decay[k.slice("decay.".length)] = value;
}
else {
retentionPatch[k] = value;
}
}
return { ...current, retentionPolicy: retentionPatch };
});
const resolved = mergeConfig(phrenPath, projectArg);
console.log(JSON.stringify({ _project: projectArg, ...resolved.retentionPolicy }, null, 2));
return;
}
requireFlagPairs(filteredArgs.slice(1), "phren config policy set --ttlDays=90 --retentionDays=365");
const patch = {};
for (const arg of filteredArgs.slice(1)) {
if (!arg.startsWith("--"))
continue;
const [k, v] = arg.slice(2).split("=");
if (!k || v === undefined)
continue;
const num = Number(v);
const value = Number.isNaN(num) ? v : num;
if (k.startsWith("decay.")) {
patch.decay = patch.decay || {};
patch.decay[k.slice("decay.".length)] = value;
}
else {
patch[k] = value;
}
}
const result = updateRetentionPolicy(phrenPath, patch);
if (!result.ok) {
console.error(result.error);
if (result.code === "PERMISSION_DENIED")
process.exit(1);
return;
}
console.log(JSON.stringify(result.data, null, 2));
return;
}
console.error("Usage: phren config policy [--project <name>] [get|set --ttlDays=120 --retentionDays=365 --autoAcceptThreshold=0.75 --minInjectConfidence=0.35 --decay.d30=1 --decay.d60=0.85 --decay.d90=0.65 --decay.d120=0.45]");
process.exit(1);
}
// ── Memory workflow ──────────────────────────────────────────────────────────
export async function handleWorkflowPolicy(args) {
const phrenPath = getPhrenPath();
const { project: projectArg, rest: filteredArgs } = parseProjectArg(args);
if (!filteredArgs.length || filteredArgs[0] === "get") {
if (projectArg) {
if (!isValidProjectName(projectArg)) {
console.error(`Invalid project name: "${projectArg}"`);
process.exit(1);
}
const resolved = mergeConfig(phrenPath, projectArg);
console.log(JSON.stringify({ _project: projectArg, ...resolved.workflowPolicy }, null, 2));
return;
}
console.log(JSON.stringify(getWorkflowPolicy(phrenPath), null, 2));
return;
}
if (filteredArgs[0] === "set") {
if (projectArg) {
if (!isValidProjectName(projectArg)) {
console.error(`Invalid project name: "${projectArg}"`);
process.exit(1);
}
requireFlagPairs(filteredArgs.slice(1), "phren config workflow set --project <name> --lowConfidenceThreshold=0.7");
warnIfUnregistered(phrenPath, projectArg);
updateProjectConfigOverrides(phrenPath, projectArg, (current) => {
const nextConfig = { ...current };
const existingWorkflow = current.workflowPolicy ?? {};
const workflowPatch = { ...existingWorkflow };
for (const arg of filteredArgs.slice(1)) {
if (!arg.startsWith("--"))
continue;
const [k, v] = arg.slice(2).split("=");
if (!k || v === undefined)
continue;
if (k === "riskySections") {
workflowPatch.riskySections = v.split(",").map((s) => s.trim()).filter(Boolean);
}
else if (k === "taskMode") {
nextConfig.taskMode = v;
}
else if (k === "findingSensitivity") {
nextConfig.findingSensitivity = v;
}
else {
const num = Number(v);
workflowPatch[k] = Number.isNaN(num) ? v : num;
}
}
if (Object.keys(workflowPatch).length > 0 || existingWorkflow !== current.workflowPolicy) {
nextConfig.workflowPolicy = workflowPatch;
}
return nextConfig;
});
const resolved = mergeConfig(phrenPath, projectArg);
console.log(JSON.stringify({ _project: projectArg, ...resolved.workflowPolicy }, null, 2));
return;
}
requireFlagPairs(filteredArgs.slice(1), "phren config workflow set --lowConfidenceThreshold=0.7 --riskySections=Stale,Conflicts");
const patch = {};
for (const arg of filteredArgs.slice(1)) {
if (!arg.startsWith("--"))
continue;
const [k, v] = arg.slice(2).split("=");
if (!k || v === undefined)
continue;
if (k === "riskySections") {
patch.riskySections = v.split(",").map((s) => s.trim()).filter(Boolean);
}
else {
const num = Number(v);
patch[k] = Number.isNaN(num) ? v : num;
}
}
const result = updateWorkflowPolicy(phrenPath, patch);
if (!result.ok) {
console.log(result.error);
if (result.code === "PERMISSION_DENIED")
process.exit(1);
return;
}
console.log(JSON.stringify(result.data, null, 2));
return;
}
console.error("Usage: phren config workflow [--project <name>] [get|set --lowConfidenceThreshold=0.7 --riskySections=Stale,Conflicts --taskMode=manual]");
process.exit(1);
}