src / ops.ts
src / ops.ts
// Changes to a ledger, shared by the model's tools and the user's commands. Each returns a
// short description of what changed, or throws an OpError with a message for the caller.
import { Actor, commit, currentStep, Finding, Ledger, LedgerEvent, listVersions, loadMeta, progress, readEvents, saveMeta, Step, StepStatus, Verification } from "./ledger";
import { clip, oneLine } from "./text";
export class OpError extends Error {}
const MAX_TEXT = 1200;
function clean(text: string | undefined, what: string): string {
const t = (text ?? "").trim();
if (!t) throw new OpError(`${what} is empty.`);
if (t.length > MAX_TEXT) throw new OpError(`${what} is longer than ${MAX_TEXT} characters; record the essential part.`);
return t;
}
// Project state, not facts about the user: those belong in the user's memory.
const ABOUT_USER = /\b(the user|user's|the user’s)\b/i;
const PERSONAL = /\b(lives?|living|born|birthday|age|girlfriend|boyfriend|wife|husband|partner|family|health|diagnos\w*|medication|salary|income|religio\w*|address|phone number|likes|loves|hates|favou?rite)\b/i;
export function looksPersonal(text: string): boolean {
return ABOUT_USER.test(text) && PERSONAL.test(text);
}
export function initialVerification(source: string): Verification {
return /^user$/i.test(source.trim()) ? "verified" : "unverified";
}
export function addSteps(l: Ledger, texts: string[], status: StepStatus, actor: Actor, chat?: string): string {
if (!texts.length) throw new OpError("No step text given.");
const added: Step[] = [];
for (const raw of texts) {
const text = clean(raw, "Step text");
const step: Step = { id: (l.plan.at(-1)?.id ?? 0) + 1, text, status: added.length === 0 ? status : "todo", note: "" };
l.plan.push(step);
added.push(step);
}
commit(l, added.map((s) => ({ actor, op: "step_added", id: String(s.id), text: s.text, chat })));
return added.length === 1 ? `step ${added[0].id} added` : `steps ${added[0].id} to ${added.at(-1)!.id} added`;
}
export function updateStep(
l: Ledger,
id: number,
change: { status?: StepStatus; note?: string; text?: string },
actor: Actor,
chat?: string,
): string {
const step = l.plan.find((s) => s.id === id);
if (!step) throw new OpError(`There is no step ${id}. Steps are numbered 1 to ${l.plan.length}.`);
if (change.status === undefined && change.note === undefined && change.text === undefined) {
throw new OpError("Nothing to change: give a status, a note or new text.");
}
const parts: string[] = [];
if (change.text !== undefined) { step.text = clean(change.text, "Step text"); parts.push("reworded"); }
if (change.note !== undefined) { step.note = oneLine(change.note).slice(0, 300); parts.push("note set"); }
if (change.status !== undefined && change.status !== step.status) { step.status = change.status; parts.push(`now ${change.status}`); }
commit(l, [{ actor, op: "step_updated", id: String(id), text: parts.join(", ") || "unchanged", chat }]);
return `step ${id} ${parts.join(", ") || "unchanged"}`;
}
export function addFinding(l: Ledger, text: string, source: string | undefined, actor: Actor, chat?: string): string {
const t = clean(text, "Finding");
if (looksPersonal(t)) throw new OpError("This reads like a personal fact about the user. Facts about the user belong in the user's memory, not in a project ledger.");
const src = oneLine(source ?? "").slice(0, 300) || "none";
const f: Finding = {
id: `F${++l.counters.F}`,
text: t,
source: src,
verification: src === "none" ? "unverified" : initialVerification(src),
step: currentStep(l)?.id,
chat,
at: new Date().toISOString(),
};
l.findings.push(f);
commit(l, [{ actor, op: "finding_added", id: f.id, text: t, chat }]);
return `${f.id} added: ${clip(t, 160)} (source: ${clip(src, 80)})`;
}
export function addDecision(l: Ledger, text: string, reason: string | undefined, actor: Actor, chat?: string): string {
const t = clean(text, "Decision");
if (looksPersonal(t)) throw new OpError("This reads like a personal fact about the user. Facts about the user belong in the user's memory, not in a project ledger.");
const id = `D${++l.counters.D}`;
l.decisions.push({ id, text: t, reason: oneLine(reason ?? "").slice(0, 400), by: actor, step: currentStep(l)?.id, at: new Date().toISOString() });
commit(l, [{ actor, op: "decision_added", id, text: t, chat }]);
return `${id} added: ${clip(t, 160)}`;
}
export function addQuestion(l: Ledger, text: string, actor: Actor, chat?: string): string {
const t = clean(text, "Question");
const id = `Q${++l.counters.Q}`;
l.questions.push({ id, text: t, status: "open", turn: loadMeta(l.name).turns, at: new Date().toISOString() });
commit(l, [{ actor, op: "question_added", id, text: t, chat }]);
return `${id} opened: ${clip(t, 160)}`;
}
export function resolveQuestion(
l: Ledger,
ref: string,
answer: string,
asFinding: { source?: string } | null,
actor: Actor,
chat?: string,
): string {
const id = /^\d+$/.test(ref.trim()) ? `Q${ref.trim()}` : ref.trim().toUpperCase();
const q = l.questions.find((x) => x.id === id);
if (!q) throw new OpError(`There is no question ${id}.`);
if (q.status === "resolved") throw new OpError(`${id} is already resolved.`);
q.status = "resolved";
q.answer = clean(answer, "Answer");
q.resolvedAt = new Date().toISOString();
commit(l, [{ actor, op: "question_resolved", id, text: q.answer, chat }]);
let msg = `${id} resolved`;
if (asFinding) msg += `; ${addFinding(l, `${q.text} ${q.answer}`, asFinding.source, actor, chat)}`;
return msg;
}
export function setGoal(l: Ledger, goal: string, chat?: string): string {
l.goal = clean(goal, "Goal");
l.goalSource = "command";
commit(l, [{ actor: "user", op: "goal_set", text: l.goal, chat }]);
return "goal set";
}
export function addConstraint(l: Ledger, text: string, chat?: string): string {
const t = clean(text, "Constraint");
l.constraints.push(t);
commit(l, [{ actor: "user", op: "constraint_added", text: t, chat }]);
return `constraint ${l.constraints.length} added`;
}
// The guidance line every tool result ends with.
export function nextLine(l: Ledger): string {
const p = progress(l);
if (!p.total) return "next: Break the goal into a few steps with ledger_plan, then mark the first one doing.";
const cur = currentStep(l);
if (cur) return "next: Continue with the current step. Record findings and decisions as you establish them, and mark the step done when it is.";
if (p.done === p.total) return "next: All steps are done. Give the user the result and stop. Add steps beyond the goal only if the user asks.";
return "next: Mark the next step doing with ledger_plan before working on it.";
}
// Changes that are part of the project's structure rather than its content, and are not undone.
const NOT_UNDOABLE = new Set(["project_created", "chat_bound", "chat_unbound", "chat_deleted", "project_renamed", "sources_checked", "undo"]);
function describeEvent(e: LedgerEvent): string {
switch (e.op) {
case "finding_added": case "decision_added": return `${e.id} added`;
case "question_added": return `${e.id} opened`;
case "question_resolved": return `${e.id} resolved`;
case "step_added": return `step ${e.id} added`;
case "step_updated": return `step ${e.id} ${e.text ?? "updated"}`;
case "goal_set": return "the goal change";
case "constraint_added": return "the added constraint";
case "hand_edit": return `the hand edit (${e.text ?? "changes"})`;
default: return e.op.replace(/_/g, " ");
}
}
// Steps the ledger back one change at a time: each call restores the state from before the
// most recent change by the user or the model that has not been undone yet. The project's name
// and chat bindings stay as they are, and ids already used are not handed out again.
export function undoLast(l: Ledger, chat?: string): string {
const meta = loadMeta(l.name);
const limit = meta.undoneTo ?? l.seq;
const events = readEvents(l.name, 0);
for (const v of listVersions(l.name)) {
if (v.seq >= limit) continue;
const first = events.find((e) => e.seq === v.seq + 1);
if (!first || first.actor === "plugin" || NOT_UNDOABLE.has(first.op)) continue;
const reverted = events.filter((e) => e.seq > v.seq && e.seq <= limit && !NOT_UNDOABLE.has(e.op) && e.actor !== "plugin");
const what = reverted.length ? reverted.map(describeEvent).join(", ") : describeEvent(first);
const restored: Ledger = {
...v,
name: l.name,
chats: l.chats,
seq: l.seq,
counters: { F: Math.max(v.counters.F, l.counters.F), D: Math.max(v.counters.D, l.counters.D), Q: Math.max(v.counters.Q, l.counters.Q) },
};
commit(restored, [{ actor: "user", op: "undo", text: `undid ${what}`, chat }]);
const after = loadMeta(l.name);
after.undoneTo = v.seq;
saveMeta(l.name, after);
return `Undid ${what}.`;
}
throw new OpError("There is nothing earlier to undo.");
}
// Changes to a ledger, shared by the model's tools and the user's commands. Each returns a
// short description of what changed, or throws an OpError with a message for the caller.
import { Actor, commit, currentStep, Finding, Ledger, LedgerEvent, listVersions, loadMeta, progress, readEvents, saveMeta, Step, StepStatus, Verification } from "./ledger";
import { clip, oneLine } from "./text";
export class OpError extends Error {}
const MAX_TEXT = 1200;
function clean(text: string | undefined, what: string): string {
const t = (text ?? "").trim();
if (!t) throw new OpError(`${what} is empty.`);
if (t.length > MAX_TEXT) throw new OpError(`${what} is longer than ${MAX_TEXT} characters; record the essential part.`);
return t;
}
// Project state, not facts about the user: those belong in the user's memory.
const ABOUT_USER = /\b(the user|user's|the user’s)\b/i;
const PERSONAL = /\b(lives?|living|born|birthday|age|girlfriend|boyfriend|wife|husband|partner|family|health|diagnos\w*|medication|salary|income|religio\w*|address|phone number|likes|loves|hates|favou?rite)\b/i;
export function looksPersonal(text: string): boolean {
return ABOUT_USER.test(text) && PERSONAL.test(text);
}
export function initialVerification(source: string): Verification {
return /^user$/i.test(source.trim()) ? "verified" : "unverified";
}
export function addSteps(l: Ledger, texts: string[], status: StepStatus, actor: Actor, chat?: string): string {
if (!texts.length) throw new OpError("No step text given.");
const added: Step[] = [];
for (const raw of texts) {
const text = clean(raw, "Step text");
const step: Step = { id: (l.plan.at(-1)?.id ?? 0) + 1, text, status: added.length === 0 ? status : "todo", note: "" };
l.plan.push(step);
added.push(step);
}
commit(l, added.map((s) => ({ actor, op: "step_added", id: String(s.id), text: s.text, chat })));
return added.length === 1 ? `step ${added[0].id} added` : `steps ${added[0].id} to ${added.at(-1)!.id} added`;
}
export function updateStep(
l: Ledger,
id: number,
change: { status?: StepStatus; note?: string; text?: string },
actor: Actor,
chat?: string,
): string {
const step = l.plan.find((s) => s.id === id);
if (!step) throw new OpError(`There is no step ${id}. Steps are numbered 1 to ${l.plan.length}.`);
if (change.status === undefined && change.note === undefined && change.text === undefined) {
throw new OpError("Nothing to change: give a status, a note or new text.");
}
const parts: string[] = [];
if (change.text !== undefined) { step.text = clean(change.text, "Step text"); parts.push("reworded"); }
if (change.note !== undefined) { step.note = oneLine(change.note).slice(0, 300); parts.push("note set"); }
if (change.status !== undefined && change.status !== step.status) { step.status = change.status; parts.push(`now ${change.status}`); }
commit(l, [{ actor, op: "step_updated", id: String(id), text: parts.join(", ") || "unchanged", chat }]);
return `step ${id} ${parts.join(", ") || "unchanged"}`;
}
export function addFinding(l: Ledger, text: string, source: string | undefined, actor: Actor, chat?: string): string {
const t = clean(text, "Finding");
if (looksPersonal(t)) throw new OpError("This reads like a personal fact about the user. Facts about the user belong in the user's memory, not in a project ledger.");
const src = oneLine(source ?? "").slice(0, 300) || "none";
const f: Finding = {
id: `F${++l.counters.F}`,
text: t,
source: src,
verification: src === "none" ? "unverified" : initialVerification(src),
step: currentStep(l)?.id,
chat,
at: new Date().toISOString(),
};
l.findings.push(f);
commit(l, [{ actor, op: "finding_added", id: f.id, text: t, chat }]);
return `${f.id} added: ${clip(t, 160)} (source: ${clip(src, 80)})`;
}
export function addDecision(l: Ledger, text: string, reason: string | undefined, actor: Actor, chat?: string): string {
const t = clean(text, "Decision");
if (looksPersonal(t)) throw new OpError("This reads like a personal fact about the user. Facts about the user belong in the user's memory, not in a project ledger.");
const id = `D${++l.counters.D}`;
l.decisions.push({ id, text: t, reason: oneLine(reason ?? "").slice(0, 400), by: actor, step: currentStep(l)?.id, at: new Date().toISOString() });
commit(l, [{ actor, op: "decision_added", id, text: t, chat }]);
return `${id} added: ${clip(t, 160)}`;
}
export function addQuestion(l: Ledger, text: string, actor: Actor, chat?: string): string {
const t = clean(text, "Question");
const id = `Q${++l.counters.Q}`;
l.questions.push({ id, text: t, status: "open", turn: loadMeta(l.name).turns, at: new Date().toISOString() });
commit(l, [{ actor, op: "question_added", id, text: t, chat }]);
return `${id} opened: ${clip(t, 160)}`;
}
export function resolveQuestion(
l: Ledger,
ref: string,
answer: string,
asFinding: { source?: string } | null,
actor: Actor,
chat?: string,
): string {
const id = /^\d+$/.test(ref.trim()) ? `Q${ref.trim()}` : ref.trim().toUpperCase();
const q = l.questions.find((x) => x.id === id);
if (!q) throw new OpError(`There is no question ${id}.`);
if (q.status === "resolved") throw new OpError(`${id} is already resolved.`);
q.status = "resolved";
q.answer = clean(answer, "Answer");
q.resolvedAt = new Date().toISOString();
commit(l, [{ actor, op: "question_resolved", id, text: q.answer, chat }]);
let msg = `${id} resolved`;
if (asFinding) msg += `; ${addFinding(l, `${q.text} ${q.answer}`, asFinding.source, actor, chat)}`;
return msg;
}
export function setGoal(l: Ledger, goal: string, chat?: string): string {
l.goal = clean(goal, "Goal");
l.goalSource = "command";
commit(l, [{ actor: "user", op: "goal_set", text: l.goal, chat }]);
return "goal set";
}
export function addConstraint(l: Ledger, text: string, chat?: string): string {
const t = clean(text, "Constraint");
l.constraints.push(t);
commit(l, [{ actor: "user", op: "constraint_added", text: t, chat }]);
return `constraint ${l.constraints.length} added`;
}
// The guidance line every tool result ends with.
export function nextLine(l: Ledger): string {
const p = progress(l);
if (!p.total) return "next: Break the goal into a few steps with ledger_plan, then mark the first one doing.";
const cur = currentStep(l);
if (cur) return "next: Continue with the current step. Record findings and decisions as you establish them, and mark the step done when it is.";
if (p.done === p.total) return "next: All steps are done. Give the user the result and stop. Add steps beyond the goal only if the user asks.";
return "next: Mark the next step doing with ledger_plan before working on it.";
}
// Changes that are part of the project's structure rather than its content, and are not undone.
const NOT_UNDOABLE = new Set(["project_created", "chat_bound", "chat_unbound", "chat_deleted", "project_renamed", "sources_checked", "undo"]);
function describeEvent(e: LedgerEvent): string {
switch (e.op) {
case "finding_added": case "decision_added": return `${e.id} added`;
case "question_added": return `${e.id} opened`;
case "question_resolved": return `${e.id} resolved`;
case "step_added": return `step ${e.id} added`;
case "step_updated": return `step ${e.id} ${e.text ?? "updated"}`;
case "goal_set": return "the goal change";
case "constraint_added": return "the added constraint";
case "hand_edit": return `the hand edit (${e.text ?? "changes"})`;
default: return e.op.replace(/_/g, " ");
}
}
// Steps the ledger back one change at a time: each call restores the state from before the
// most recent change by the user or the model that has not been undone yet. The project's name
// and chat bindings stay as they are, and ids already used are not handed out again.
export function undoLast(l: Ledger, chat?: string): string {
const meta = loadMeta(l.name);
const limit = meta.undoneTo ?? l.seq;
const events = readEvents(l.name, 0);
for (const v of listVersions(l.name)) {
if (v.seq >= limit) continue;
const first = events.find((e) => e.seq === v.seq + 1);
if (!first || first.actor === "plugin" || NOT_UNDOABLE.has(first.op)) continue;
const reverted = events.filter((e) => e.seq > v.seq && e.seq <= limit && !NOT_UNDOABLE.has(e.op) && e.actor !== "plugin");
const what = reverted.length ? reverted.map(describeEvent).join(", ") : describeEvent(first);
const restored: Ledger = {
...v,
name: l.name,
chats: l.chats,
seq: l.seq,
counters: { F: Math.max(v.counters.F, l.counters.F), D: Math.max(v.counters.D, l.counters.D), Q: Math.max(v.counters.Q, l.counters.Q) },
};
commit(restored, [{ actor: "user", op: "undo", text: `undid ${what}`, chat }]);
const after = loadMeta(l.name);
after.undoneTo = v.seq;
saveMeta(l.name, after);
return `Undid ${what}.`;
}
throw new OpError("There is nothing earlier to undo.");
}