logicsrc/packages/tui/src/openontology.ts
Anthony Ettinger da5f6f8381
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
feat(openontology): Phase 2 + Phase 3 — storage, REST/SSE, MCP, RDF/SHACL, adapters, TUI, explorer (#101)
Everything the two shipped PRD phases deferred, minus what is called out below.

Storage (Phase 2)
  @logicsrc/openontology gains a SQLite/Turso adapter. It hydrates the read
  model at open, serves reads synchronously — a query evaluator that awaits per
  triple pattern is unusable — and buffers mutations as SQL that flush() writes
  in one transaction. Versioned idempotent migrations; indexes over subject,
  predicate, entity-valued object, status, both time axes, aliases, and external
  ids; FTS5 for label/alias search. The append-only status log is replayed on
  open, so retractions, supersessions, and merge redirects survive a reopen.

REST + SSE + OpenAPI (Phase 2)
  16 paths under /api/ontologies in logicsrc-web, described at
  /api/ontologies/openapi and referencing the published JSON Schemas rather
  than restating them. No token is read-only; a curator token can apply; an
  agent token can propose and cannot apply. Idempotency-Key on mutations,
  revision ETags, 409 on a stale base revision, and an SSE stream that emits
  the same event objects as the JSON endpoint.

MCP (Phase 2)
  OpenOntology and OpenPRD surfaces on the standards server: spec/manifest/
  schema/queries and PRD spec/index as resources, 11 ontology tools and 6 PRD
  tools, 7 prompts. Read-only by default; OPENONTOLOGY_MCP_WRITABLE=1 buys
  proposals, never applies — the denial is the shared policy layer, not a
  second rule that could drift.

Interoperability (Phase 3)
  RDF/Turtle export and import of the reified profile, plus the plain triple
  for asserted relationships so a consumer wanting only the accepted graph gets
  one. SHACL for 5 of 7 constraint kinds; `unique` and `query` are reported as
  unmapped in both the return value and the generated Turtle, because a shape
  that quietly means something narrower is worse than no shape.

Source adapters (Phase 3)
  CSV, JSON, YAML, NDJSON, Markdown, generic JSON HTTP, and GitHub. All produce
  PROPOSED change-set operations with source, evidence selector, run id, and
  confidence attached; fetch is injected so ingestion is offline and testable.
  Each declares its capabilities, so "nothing was deleted upstream" is never
  confused with "this adapter cannot see deletions" — none of the seven can.

TUI + explorer
  Keyboard-first panels (types, entities, claims, sources, queries, change
  sets, validation, audit) as plain strings that survive SSH and 60 columns;
  status is a glyph and a word, never colour alone; the key bar wraps rather
  than truncating. Wired as `logicsrc ontology tui`. A read-only web explorer
  at /openontology/explore with entity and claim views showing status, both
  clocks, confidence, sources, evidence, and append-only history — plus an
  /openprd page for the companion standard.

Bugs found and fixed while testing
  - the API built a new engine per request, so `explain` could never find a
    resultId from a prior request; engines are now cached per role
  - the TUI status bar called engine.validateOntologyPackage(), appending a
    package.validated event on every repaint; it now uses the pure validator

Verification: 76 new tests (527 total across the monorepo, all passing); full
build green; the libSQL adapter is exercised against real files, the API
through its route handlers, and MCP over an in-memory transport.

Not included: PWA review/approval write flows (they need an auth story this
deployment does not have), OWL/RDFS mappings, SPARQL/Cypher/Datalog query
adapters, and Phase 4 governed actions. The compatibility matrix marks those
"planned", not "supported".

Refs: prd/0001-add-logicsrc-openontology-spec.md

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 05:10:33 -07:00

282 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { validateOntologyPackage, type OntologyEngine, type ValidationReport } from "@logicsrc/openontology";
/**
* Keyboard-first OpenOntology panels.
*
* Rendered as plain strings so they work over SSH, in tmux, in a narrow
* terminal, and in tests. Graph diagrams are optional in the standard; lists,
* trees, and paths are what this shows.
*/
export type OntologyPanel = "types" | "entities" | "claims" | "sources" | "queries" | "changesets" | "violations" | "audit";
export const PANEL_KEYS: Array<{ key: string; panel: OntologyPanel | "quit" | "search"; label: string }> = [
{ key: "/", panel: "search", label: "search" },
{ key: "t", panel: "types", label: "types" },
{ key: "e", panel: "entities", label: "entities" },
{ key: "c", panel: "claims", label: "claims" },
{ key: "s", panel: "sources", label: "sources" },
{ key: "r", panel: "queries", label: "queries" },
{ key: "g", panel: "changesets", label: "change sets" },
{ key: "v", panel: "violations", label: "validate" },
{ key: "a", panel: "audit", label: "audit" },
{ key: "q", panel: "quit", label: "quit" }
];
export interface OntologyTuiOptions {
panel?: OntologyPanel;
/** Terminal width. Clamped to a usable minimum. */
width?: number;
/** Rows of detail to show. */
rows?: number;
selected?: string;
}
const MIN_WIDTH = 60;
export function renderOntologyTui(engine: OntologyEngine, options: OntologyTuiOptions = {}): string {
const width = Math.max(options.width ?? 78, MIN_WIDTH);
const rows = options.rows ?? 8;
const panel = options.panel ?? "entities";
const manifest = engine.getOntologyManifest();
const schema = engine.getOntologySchema();
const lines: string[] = [];
const inner = width - 2;
const rule = (left: string, right: string, fill = "─") => `${left}${fill.repeat(inner)}${right}`;
const row = (text: string) => `${clip(` ${text}`, inner)}`;
const heading = (text: string) => `${clip(`${text} `, inner, "─")}`;
lines.push(rule("┌", "┐"));
lines.push(row(`OpenOntology: ${manifest.id}@${manifest.version} rev ${engine.store.revision()}`));
// The key bar wraps rather than truncating: a binding you cannot see is a
// binding you do not have.
for (const line of wrap(PANEL_KEYS.map((entry) => `${entry.key} ${entry.label}`), inner - 1)) {
lines.push(row(line));
}
lines.push(heading(panelTitle(panel)));
for (const line of panelBody(engine, panel, { rows, selected: options.selected, width: inner })) {
lines.push(row(line));
}
// Status bar: what needs a human, always visible.
const changeSets = engine.store.listChangeSets();
const proposed = changeSets.filter((entry) => entry.status === "proposed").length;
const conflicted = changeSets.filter((entry) => entry.status === "conflicted").length;
const report = validate(engine);
const disputed = engine.store.listClaims({ status: ["disputed"] }).length;
lines.push(heading("status"));
lines.push(
row(
`${proposed} proposed ${disputed} disputed ${conflicted} conflicts ` +
`${report.counts.error} errors ${report.counts.warning} warnings`
)
);
lines.push(row(`${schema.entityTypes.length} types ${engine.store.listEntities().length} entities ${engine.store.listClaims({ status: ["asserted"] }).length} asserted claims`));
lines.push(rule("└", "┘"));
return lines.join("\n");
}
/**
* Validate without emitting an event.
*
* `engine.validateOntologyPackage()` records a package.validated event, which
* is right for a CLI run and wrong for a panel that repaints on every keypress:
* rendering a read-only view must not write to the audit log.
*/
function validate(engine: OntologyEngine): ValidationReport {
return validateOntologyPackage({
manifest: engine.getOntologyManifest(),
schema: engine.getOntologySchema(),
data: {
entities: engine.store.listEntities(),
claims: engine.store.listClaims({
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"]
}),
sources: engine.store.listSources(),
evidence: engine.store.listEvidence()
},
files: []
});
}
function panelTitle(panel: OntologyPanel): string {
switch (panel) {
case "types":
return "Types";
case "entities":
return "Entities";
case "claims":
return "Claims";
case "sources":
return "Sources";
case "queries":
return "Saved queries";
case "changesets":
return "Change sets";
case "violations":
return "Validation";
default:
return "Audit";
}
}
function panelBody(
engine: OntologyEngine,
panel: OntologyPanel,
view: { rows: number; selected?: string; width: number }
): string[] {
const { rows } = view;
switch (panel) {
case "types": {
const counts = new Map<string, number>();
for (const entity of engine.store.listEntities()) {
counts.set(entity.type, (counts.get(entity.type) ?? 0) + 1);
}
return engine
.getOntologySchema()
.entityTypes.slice(0, rows)
.map((type) => `${pad(type.id, 20)} ${String(counts.get(type.id) ?? 0).padStart(5)} ${type.label}`);
}
case "entities": {
const entities = engine.store.listEntities({ limit: rows });
return entities.map(
(entity) =>
`${statusMark(entity.status ?? "active")} ${pad(entity.canonicalName, 26)} ${pad(entity.type, 14)} ${entity.id}`
);
}
case "claims": {
const claims = engine.store.listClaims({
status: ["asserted", "proposed", "disputed", "retracted", "superseded", "derived"],
limit: rows
});
return claims.map((claim) => {
const object = "entity" in claim.object ? claim.object.entity : JSON.stringify(claim.object.value);
const confidence = claim.confidence === undefined ? " — " : claim.confidence.toFixed(2);
const from = claim.validTime?.from?.slice(0, 10) ?? "—";
// Status is never conveyed by colour alone: a glyph and the word.
return `${statusMark(claim.status)} ${pad(claim.status, 10)} ${pad(shorten(claim.subject), 22)} ${pad(claim.predicate, 14)} ${pad(shorten(object), 22)} ${confidence} ${from} src:${claim.sources?.length ?? 0}`;
});
}
case "sources": {
return engine.store
.listSources()
.slice(0, rows)
.map(
(source) =>
`${source.stale ? "!" : "·"} ${pad(source.sourceType, 14)} ${pad(source.license ?? "unknown", 12)} ${source.uri}`
);
}
case "queries": {
return engine
.getOntologySchema()
.queries.slice(0, rows)
.map((query) => `${pad(query.id, 30)} ${query.description}`);
}
case "changesets": {
const changeSets = engine.store.listChangeSets().slice(0, rows);
if (changeSets.length === 0) return ["(no change sets in this session)"];
return changeSets.map((changeSet) => {
const approvals = engine.store.listApprovals(changeSet.id).length;
const required = changeSet.requiredApprovals ?? 0;
return `${statusMark(changeSet.status)} ${pad(changeSet.status, 10)} ${pad(changeSet.title, 34)} ops:${String(changeSet.operations.length).padStart(3)} appr:${approvals}/${required}`;
});
}
case "violations": {
const report = validate(engine);
const findings = report.findings.filter((finding) => finding.severity !== "info").slice(0, rows);
if (findings.length === 0) return ["✓ no errors, warnings, or policy findings"];
return findings.map(
(finding) => `${severityMark(finding.severity)} ${pad(finding.code, 24)} ${finding.message}`
);
}
default: {
const events = engine.listEvents({ limit: rows });
if (events.length === 0) return ["(no events in this session)"];
return events.map(
(event) => `${event.at.slice(0, 19)} ${pad(event.type, 20)} ${pad(event.actor, 20)} ${event.subject ?? ""}`
);
}
}
}
/** Glyphs, not colour, so the status survives a monochrome terminal. */
function statusMark(status: string): string {
switch (status) {
case "asserted":
case "active":
case "applied":
return "✓";
case "proposed":
return "?";
case "disputed":
case "conflicted":
return "!";
case "retracted":
case "rejected":
return "×";
case "superseded":
case "merged":
return "→";
case "derived":
return "ƒ";
case "archived":
return "▪";
default:
return "·";
}
}
function severityMark(severity: string): string {
return severity === "error" ? "✗" : severity === "warning" ? "!" : "·";
}
/** Pack items onto as many lines as the width needs. */
function wrap(items: string[], width: number): string[] {
const lines: string[] = [];
let current = "";
for (const item of items) {
const candidate = current ? `${current} ${item}` : item;
if (candidate.length > width && current) {
lines.push(current);
current = item;
} else {
current = candidate;
}
}
if (current) lines.push(current);
return lines;
}
function pad(value: string, width: number): string {
const text = String(value ?? "");
return text.length >= width ? `${text.slice(0, Math.max(width - 1, 1))}` : text.padEnd(width);
}
function clip(value: string, width: number, fill = " "): string {
const text = value.length > width ? `${value.slice(0, width - 1)}` : value;
return text.padEnd(width, fill);
}
/** Compact ids read better in a narrow column without their prefix. */
function shorten(id: string): string {
const parts = String(id).split(":");
return parts.length > 2 ? parts.slice(1).join(":") : String(id);
}
/** One-line help for the panel keys, for a footer or `--help`. */
export function renderOntologyKeyHelp(): string {
return PANEL_KEYS.map((entry) => `${entry.key}: ${entry.label}`).join(" ");
}