CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m34s
CI & Build / Build & push image (push) Skipped
CI & Build / integration (push) Successful in 2m7s
Everything an agent can do with moments, a person can now see and change in the app. - Rule editor: a moment picker beside the trigger. Catalog moments are ticked; a named procedure's `skill.<name>` is typed and checked as the server checks it. `moments` is always sent, so unticking the last moment unmounts the rule. - Settings, Moments section (General tab): for each moment, what it means, the actions that reach it on this install (shipped ones can be switched off, the install's own removed), how many rules are mounted on it, and deliveries and agent opens over the window. Below that: named procedures with mounts, switched-off defaults with Restore, and a form to add an action. - retrieval_telemetry.moment_usage: per moment, `delivered`, `rules`, `opened` (agent pulls after the first delivery there; an upper bound, as by_source is) and `last_delivered_at`. No ratio, because a mount is a person's statement, not a ranker's guess. Guarded on its own, and also reported in retrieval_summary as `moment_usage`. - rulebooks.mount_counts; mounted_moments now derives from it. - GET /api/retrieval/moments carries `mounted` and `usage` (?days=). DELETE /moments/mappings also reads the mapping from query parameters, since the browser's DELETE sends no body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
15 KiB
TypeScript
410 lines
15 KiB
TypeScript
import type { RecordUsage } from "@/types/usage";
|
|
|
|
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
|
|
import type { LinkState } from "@/api/lessons";
|
|
|
|
/** How a rule reaches a session (milestone 307). */
|
|
|
|
/**
|
|
* A typed edge between two rules. Each kind exists because its absence forced
|
|
* a workaround: merging two rules into one row, writing an override as a
|
|
* near-copy, or leaving a local addendum with nothing to say it is one.
|
|
*/
|
|
export type RuleRelationKind = "co_surfaces" | "overrides" | "elaborates";
|
|
|
|
export interface RuleRelation {
|
|
id: number;
|
|
kind: RuleRelationKind;
|
|
/** The rule at the OTHER end. */
|
|
rule_id: number;
|
|
direction: "outgoing" | "incoming";
|
|
note: string;
|
|
}
|
|
|
|
export interface Rulebook {
|
|
id: number;
|
|
owner_user_id: number;
|
|
title: string;
|
|
description: string;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
export interface RulebookTopic {
|
|
id: number;
|
|
rulebook_id: number;
|
|
title: string;
|
|
description: string;
|
|
order_index: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
}
|
|
|
|
/**
|
|
* What kind of instruction this is, and it is about FORCE, not importance.
|
|
*
|
|
* A `rule` must be FOLLOWED — ignoring it breaks something. It is the
|
|
* operator's decision, so it changes when they change it. A `preference` is
|
|
* how they want work DONE — ignoring it costs consistency, not correctness —
|
|
* and the agent rewrites it in the ordinary course of working, which is what
|
|
* makes the drift surface necessary.
|
|
*
|
|
* Always sent by the server, never inferred from an absent key: "no kind
|
|
* field" and "kind is rule" must not be the same payload.
|
|
*/
|
|
export type RuleKind = "rule" | "preference";
|
|
|
|
export interface Rule {
|
|
id: number;
|
|
topic_id: number | null;
|
|
project_id: number | null;
|
|
title: string;
|
|
statement: string;
|
|
kind: RuleKind;
|
|
/** WHEN this rule fires — the trigger, not the instruction. */
|
|
when_to_apply: string;
|
|
why: string;
|
|
how_to_apply: string;
|
|
/**
|
|
* How to check the rule is still true, and the state that ends it. Set
|
|
* only on a rule that asserts a fact about something outside the
|
|
* operator's control; empty on a rule that is a decision, which is most
|
|
* of them. Empty is meaningful, not missing.
|
|
*/
|
|
verify_with: string;
|
|
expires_when: string;
|
|
/** When the check last passed. Null means never checked. */
|
|
verified_at: string | null;
|
|
/** The note or task that caused this rule, if one was recorded. */
|
|
arose_from_id: number | null;
|
|
order_index: number;
|
|
created_at: string | null;
|
|
updated_at: string | null;
|
|
/** Present only when the rule has them (the server omits empty keys). */
|
|
systems?: { id: number; name: string }[];
|
|
/** The moments this rule arrives at whenever they happen (milestone 458),
|
|
* in catalog order. Present only when the rule is mounted. */
|
|
moments?: string[];
|
|
relations?: RuleRelation[];
|
|
/** The lessons that point at this rule (milestone 440) — the concrete
|
|
* situations judged instances of it, plus any suggested and awaiting a
|
|
* judgment. Readable lessons only; omitted when there are none. */
|
|
lessons?: { id: number; title: string; state: LinkState; note: string }[];
|
|
}
|
|
|
|
/**
|
|
* A rule as a LIST ROW — services.rulebooks.rule_brief's output. Carries the
|
|
* age deliberately: a rule written before the capability it duplicates is
|
|
* otherwise indistinguishable, at a glance, from one still doing work.
|
|
*/
|
|
export interface RuleHeader {
|
|
id: number;
|
|
title: string;
|
|
statement: string;
|
|
topic_id: number | null;
|
|
/** Unconditional on the wire (services.rulebooks.rule_brief). */
|
|
kind: RuleKind;
|
|
/** A date (YYYY-MM-DD), not a timestamp. */
|
|
updated_at: string | null;
|
|
when_to_apply?: string;
|
|
arose_from_id?: number;
|
|
/**
|
|
* Present ONLY on a rule that carries a check — the presence of the key
|
|
* is itself the signal that this rule asserts a fact that can go false.
|
|
* A date (YYYY-MM-DD), or the literal "never".
|
|
*/
|
|
last_verified?: string;
|
|
/**
|
|
* Surfaced-vs-opened counts from `rule_usage_events` (milestone 333).
|
|
* Zero-filled by the list route, so a rule predating the table reads as
|
|
* "never surfaced" rather than as a missing field — which for a while is
|
|
* every rule on every install.
|
|
*/
|
|
usage?: RecordUsage;
|
|
}
|
|
|
|
export interface ApplicableRules {
|
|
// Both lists are rule_brief's output — the SAME builder, so they are
|
|
// described the same way here rather than as two hand-written shapes that
|
|
// drift from it and from each other (which is what the server side had).
|
|
rules: (RuleHeader & {
|
|
topic_title: string;
|
|
rulebook_id: number;
|
|
rulebook_title: string;
|
|
})[];
|
|
project_rules: RuleHeader[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
// ── Rulebooks ───────────────────────────────────────────────────────
|
|
|
|
export async function listRulebooks(): Promise<Rulebook[]> {
|
|
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
|
|
return data.rulebooks;
|
|
}
|
|
|
|
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
|
|
return apiGet(`/api/rulebooks/${id}`);
|
|
}
|
|
|
|
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
|
|
return apiPost("/api/rulebooks", data);
|
|
}
|
|
|
|
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise<Rulebook> {
|
|
return apiPatch(`/api/rulebooks/${id}`, data);
|
|
}
|
|
|
|
export async function deleteRulebook(id: number): Promise<void> {
|
|
return apiDelete(`/api/rulebooks/${id}`);
|
|
}
|
|
|
|
// ── Topics ─────────────────────────────────────────────────────────
|
|
|
|
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
|
|
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
|
|
return data.topics;
|
|
}
|
|
|
|
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
|
|
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
|
|
}
|
|
|
|
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
|
|
return apiPatch(`/api/rulebook-topics/${id}`, data);
|
|
}
|
|
|
|
export async function deleteTopic(id: number): Promise<void> {
|
|
return apiDelete(`/api/rulebook-topics/${id}`);
|
|
}
|
|
|
|
// ── Rules ──────────────────────────────────────────────────────────
|
|
|
|
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
|
|
const params = new URLSearchParams();
|
|
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
|
|
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
|
|
if (filters.project_id) params.set("project_id", String(filters.project_id));
|
|
const qs = params.toString();
|
|
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
|
|
return data.rules;
|
|
}
|
|
|
|
export async function getRule(id: number): Promise<Rule> {
|
|
return apiGet(`/api/rules/${id}`);
|
|
}
|
|
|
|
/**
|
|
* The fields both write paths accept. `system_ids` REPLACES a rule's areas.
|
|
*
|
|
* Sending "" for a nullable text field CLEARS it here — the server maps an
|
|
* empty string to NULL, so an emptied form input does what it looks like it
|
|
* does. (The MCP door reads "" as "leave unchanged" and needs an explicit
|
|
* clear_fields list instead; the two idioms reach the same state.)
|
|
*/
|
|
export interface RuleWrite {
|
|
title: string;
|
|
statement: string;
|
|
/** Writable from the editor: a preference is not a lesser rule, it is a
|
|
* different force, and the person writing it is the one who knows which. */
|
|
kind: RuleKind;
|
|
when_to_apply: string;
|
|
why: string;
|
|
how_to_apply: string;
|
|
order_index: number;
|
|
system_ids: number[];
|
|
/** Replaces the set; [] unmounts the rule from every moment. */
|
|
moments: string[];
|
|
arose_from_id: number | null;
|
|
verify_with: string;
|
|
expires_when: string;
|
|
}
|
|
|
|
export async function createRule(topicId: number, data: Partial<RuleWrite> & { title: string; statement: string }): Promise<Rule> {
|
|
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
|
|
}
|
|
|
|
export async function updateRule(id: number, data: Partial<RuleWrite>): Promise<Rule> {
|
|
return apiPatch(`/api/rules/${id}`, data);
|
|
}
|
|
|
|
/** Give a rule a new home: a topic makes it global, a project makes it that
|
|
* project's. Keeps its id, history, areas and relations (milestone 414). */
|
|
export async function moveRule(
|
|
id: number, to: { topic_id: number } | { project_id: number },
|
|
): Promise<Rule> {
|
|
return apiPost(`/api/rules/${id}/move`, to);
|
|
}
|
|
|
|
/** Draw a typed edge from one rule to another. Idempotent. */
|
|
export async function relateRules(
|
|
fromRuleId: number,
|
|
data: { to_rule_id: number; kind: RuleRelationKind; note?: string },
|
|
): Promise<{ id: number }> {
|
|
return apiPost(`/api/rules/${fromRuleId}/relations`, data);
|
|
}
|
|
|
|
export async function unrelateRules(relationId: number): Promise<void> {
|
|
return apiDelete(`/api/rule-relations/${relationId}`);
|
|
}
|
|
|
|
/**
|
|
* One entry in a rule's edit history.
|
|
*
|
|
* Each entry holds the text the edit REPLACED, not the text it introduced —
|
|
* so the newest entry is what the rule said before its most recent change,
|
|
* and what that change produced is the rule as it stands now. Read the other
|
|
* way round, every diff comes out backwards.
|
|
*
|
|
* The listing form omits the long fields; open one to get them.
|
|
*/
|
|
export interface RuleVersion {
|
|
id: number;
|
|
rule_id: number;
|
|
/** Who made the edit. Null when that account has since been deleted. */
|
|
user_id: number | null;
|
|
title: string;
|
|
created_at: string;
|
|
statement?: string;
|
|
why?: string;
|
|
how_to_apply?: string;
|
|
when_to_apply?: string;
|
|
verify_with?: string;
|
|
expires_when?: string;
|
|
}
|
|
|
|
export async function listRuleVersions(ruleId: number): Promise<RuleVersion[]> {
|
|
const data = await apiGet<{ versions: RuleVersion[] }>(
|
|
`/api/rules/${ruleId}/versions`,
|
|
);
|
|
return data.versions;
|
|
}
|
|
|
|
export async function getRuleVersion(
|
|
ruleId: number, versionId: number,
|
|
): Promise<RuleVersion> {
|
|
return apiGet<RuleVersion>(`/api/rules/${ruleId}/versions/${versionId}`);
|
|
}
|
|
|
|
// No restore FOR A RULE, deliberately (milestone 323). Putting an old wording
|
|
// back goes through updateRule, which snapshots what it replaces — so the
|
|
// undo stays visible in the history like any other edit.
|
|
//
|
|
// A preference is the exception and the server refuses anything else (409).
|
|
// 323's reasoning is that a rewrite is the operator's own decision; a
|
|
// preference's rewrite is the agent's, made mid-work without asking, so
|
|
// putting it back is a veto rather than an undo — and a veto that costs more
|
|
// than shrugging is not really supervision. Nothing is erased either way:
|
|
// the restore snapshots too, so the history GAINS the revert.
|
|
export async function restoreRuleVersion(
|
|
ruleId: number, versionId: number,
|
|
): Promise<Rule> {
|
|
return apiPost<Rule>(`/api/rules/${ruleId}/versions/${versionId}/restore`, {});
|
|
}
|
|
|
|
/**
|
|
* One preference that has been rewritten: what it said, what it says now, and
|
|
* what taught the change.
|
|
*
|
|
* Both texts ride along so the list can show the diff without a follow-up call
|
|
* per row — a listing that needs N round-trips to say what it means is one
|
|
* nobody scrolls, which would leave the drift as unsupervised as before.
|
|
*/
|
|
export interface PreferenceDrift {
|
|
rule: RuleHeader;
|
|
/** What it said BEFORE the latest rewrite. */
|
|
previous: {
|
|
id: number;
|
|
created_at: string | null;
|
|
title: string;
|
|
statement: string;
|
|
when_to_apply: string;
|
|
};
|
|
current: { title: string; statement: string; when_to_apply: string };
|
|
/**
|
|
* The record named by `arose_from_id` — what the change was learned from.
|
|
* Absent when the preference carries none, which is every one written
|
|
* before that field was required.
|
|
*/
|
|
taught_by?: { id: number; title: string };
|
|
}
|
|
|
|
export async function listPreferenceDrift(
|
|
limit?: number,
|
|
): Promise<PreferenceDrift[]> {
|
|
const qs = limit ? `?limit=${limit}` : "";
|
|
const data = await apiGet<{ drift: PreferenceDrift[] }>(`/api/rules/drift${qs}`);
|
|
return data.drift;
|
|
}
|
|
|
|
export async function deleteRule(id: number): Promise<void> {
|
|
return apiDelete(`/api/rules/${id}`);
|
|
}
|
|
|
|
// ── A project's rules ──────────────────────────────────────────────
|
|
|
|
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
|
|
return apiGet(`/api/projects/${projectId}/rules`);
|
|
}
|
|
|
|
export async function createProjectRule(
|
|
projectId: number,
|
|
data: Partial<RuleWrite> & { statement: string },
|
|
): Promise<Rule> {
|
|
return apiPost(`/api/projects/${projectId}/rules`, data);
|
|
}
|
|
|
|
|
|
/**
|
|
* One row of the staleness sweep. Unlike RuleHeader this carries the CHECK
|
|
* in full — the reader is about to go and run it, so the text is the point
|
|
* of the payload rather than the bloat a listing avoids.
|
|
*/
|
|
export interface RuleVerificationRow {
|
|
id: number;
|
|
title: string;
|
|
statement: string;
|
|
topic_id: number | null;
|
|
project_id: number | null;
|
|
when_to_apply: string;
|
|
verify_with: string;
|
|
expires_when: string;
|
|
/** A date (YYYY-MM-DD), or the literal "never". */
|
|
last_verified: string | null;
|
|
/** Null when never verified — "never" is not zero days ago. */
|
|
days_since_verified: number | null;
|
|
}
|
|
|
|
/**
|
|
* Rules asserting a fact that may have gone false, oldest verification
|
|
* first, never-checked at the top. Rules without a check never appear:
|
|
* they are decisions, and there is nothing to go and check.
|
|
*
|
|
* Not filterable by project — a project is bound by its own rules and by
|
|
* every global rule, and a filter that dropped the global ones would
|
|
* under-report.
|
|
*/
|
|
export async function listRulesDueForVerification(opts: {
|
|
olderThanDays?: number;
|
|
neverOnly?: boolean;
|
|
} = {}): Promise<{ rules: RuleVerificationRow[]; total: number }> {
|
|
const q = new URLSearchParams();
|
|
if (opts.olderThanDays) q.set("older_than_days", String(opts.olderThanDays));
|
|
if (opts.neverOnly) q.set("never_only", "true");
|
|
const qs = q.toString();
|
|
return apiGet(`/api/rules-due-for-verification${qs ? `?${qs}` : ""}`);
|
|
}
|
|
|
|
/**
|
|
* Record that a rule's check was RUN, and what it said.
|
|
*
|
|
* `stillTrue: false` writes nothing on purpose — a rule whose check failed
|
|
* is not in a recordable state, it is wrong — so it stays at the top of the
|
|
* sweep until someone corrects or retires it.
|
|
*/
|
|
export async function markRuleVerified(
|
|
id: number, stillTrue = true,
|
|
): Promise<Rule & { verified: boolean }> {
|
|
return apiPost(`/api/rules/${id}/verify`, { still_true: stillTrue });
|
|
}
|