Skip to content
Merged
10 changes: 9 additions & 1 deletion core/src/ooat_core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,21 @@ def app_routes() -> APIRouter:
def page() -> Response:
return Response(shell, media_type="text/html; charset=utf-8")

for path in ("/", "/usage", "/connectors", "/setup"):
for path in ("/", "/usage", "/connectors", "/setup", "/memory"):
router.add_api_route(path, page, methods=["GET"])

@router.get("/tasks/{task}")
def task_page(task: str) -> Response: # the page reads the id from its address
return page()

@router.get("/conversations/{conversation}")
def conversation_page(conversation: str) -> Response:
return page()

@router.get("/memory/{scope_name}")
def memory_page(scope_name: str) -> Response:
return page()

@router.get("/app/{path:path}")
def app_file(path: str) -> Response:
found = app_files.get(path)
Expand Down
41 changes: 41 additions & 0 deletions core/src/ooat_core/conversation_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from . import conversations, memory, views
from .api import ApiError, Principal, Server, principal, require_within, scope, server_of
from .endpoints import AttachmentIn
from .pii import raised_class

INTAKE_KEY = re.compile(r"^[!-~]{1,128}$")
SSE_POLL_S, SSE_MAX_S = 1.0, 900.0
Expand All @@ -45,6 +46,12 @@ class TapIn(_Body):
text: str | None = Field(default=None, max_length=20000)


class MemoryIn(_Body):
kind: str = Field(pattern=r"^(fact|glossary|preference|todo)$") # a summary is the reception's own
text: str = Field(min_length=1, max_length=memory.MAX_TEXT)
pinned: bool = False


class MemoryEditIn(_Body):
text: str | None = Field(default=None, min_length=1, max_length=memory.MAX_TEXT)
pinned: bool | None = None
Expand Down Expand Up @@ -153,6 +160,40 @@ def events(conversation: str, request: Request, after: str | None = None,
return StreamingResponse(_stream(server, conversation, cursor, lambda: scope("read")(principal(request))),
media_type="text/event-stream")

@api.get("/projects")
def list_projects(request: Request, who: Principal = Depends(scope("read"))) -> dict:
"""What a project picker offers (design 08 §9): the projects of ooat.toml within the reader's cap, then the
projects of the reader's own conversations and tasks that ooat.toml lacks. Names only, never a folder."""
server = server_of(request)
listed = {slug: {"project": slug, "data_class": p.data_class, "write": p.write, "folders": len(p.folders)}
for slug, p in server.config.projects.items() if views.within(p.data_class, who.max_data_class)}
for event in server.runtime().ledger.events(types=["TASK_SUBMITTED", "CONVERSATION_MESSAGE"]):
slug = event["body"].get("project")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (performance): every home page load reads all TASK_SUBMITTED and CONVERSATION_MESSAGE events in the ledger, from every operator. That is fine for F1 with SQLite, but it grows with the ledger. If it becomes a hotspot, filter by actor in the query, or move this to a view (db-dev).

if slug and slug not in listed and slug not in server.config.projects \
and event["actor"]["id"] == who.operator and views.within("internal", who.max_data_class):
listed[slug] = {"project": slug, "data_class": "internal", "write": False, "folders": 0}
return {"projects": [listed[slug] for slug in sorted(listed)]}

@api.post("/memory/{scope_name}", status_code=201)
def add_memory(scope_name: str, body: MemoryIn, request: Request,
who: Principal = Depends(scope("answer"))) -> dict:
server = server_of(request)
if who.channel != "web":
# An item written by hand steers every later task, as a pinned one: a named operator's session only.
raise ApiError(403, "SCOPE", "adding memory needs the web app; a token's message may ask the reception "
"to remember")
if scope_name == memory.OPERATOR_SCOPE and body.kind != "preference":
raise ApiError(400, "INVALID", "the operator's memory holds preferences only")
data_class = "internal" if scope_name == memory.OPERATOR_SCOPE else project_class(server, scope_name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: any slug passes the scope check here. A slug that isn't in ooat.toml and has never been used (e.g. a typo in /memory/dcos) quietly falls back to internal through project_class and creates a new memory scope. That scope can hold a pinned item that steers later tasks. Consider accepting only the slugs that GET /projects would offer this operator, or operator. The class check itself is correct: note() raises the class for personal data, and require_within checks the raised class.

# Personal data in the text raises the item's class: refused when that is above the author's cap, so an item
# is never stored that its author could no longer see.
require_within(who, raised_class(data_class, body.text))
if server.memory is None:
raise ApiError(404, "NOT_FOUND", "this ledger keeps no memory")
item = memory.add(server.runtime().ledger, server.memory, scope=scope_name, kind=body.kind, text=body.text,
data_class=data_class, operator=who.operator, channel=who.channel, pinned=body.pinned)
return views.memory_view(item)

@api.get("/memory/{scope_name}")
def list_memory(scope_name: str, request: Request, kind: str | None = None, done: bool = False,
who: Principal = Depends(scope("read"))) -> dict:
Expand Down
22 changes: 17 additions & 5 deletions core/src/ooat_core/memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,12 @@ def find(events: list[dict], mem_id: str) -> dict | None:
return noted


def note(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str, data_class: str, source: str,
actor: dict, conversation: str | None = None, pinned: bool = False, channel: str | None = None) -> MemoryItem:
def note(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str, data_class: str,
source: str | None, actor: dict, conversation: str | None = None, pinned: bool = False,
channel: str | None = None) -> MemoryItem:
"""Store a new item: its text in the store, then MEMORY_NOTED. A text the ledger refuses to record is deleted
again, so no text is kept without its event."""
again, so no text is kept without its event. An item written by hand (`source` None) has its own MEMORY_NOTED
as its source: the operator's act is where it came from."""
if kind not in KINDS:
raise ValueError(f"a memory kind is one of {', '.join(KINDS)}")
if not _SCOPE.match(scope):
Expand All @@ -158,12 +160,22 @@ def note(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str
body["pinned"] = True
if channel is not None:
body["channel"] = channel
event = new_event("MEMORY_NOTED", task=None, actor=actor, body=body)
body["source"] = source or event["id"]
try:
event = ledger.append(new_event("MEMORY_NOTED", task=None, actor=actor, body=body))
event = ledger.append(event)
except BaseException:
store.delete(mem_id)
raise
return MemoryItem(mem_id, scope, kind, data_class, source, text, pinned, False, event["ts"], conversation)
return MemoryItem(mem_id, scope, kind, data_class, body["source"], text, pinned, False, event["ts"],
conversation)


def add(ledger: Ledger, store: MemoryStore, *, scope: str, kind: str, text: str, data_class: str, operator: str,
channel: str, pinned: bool = False) -> MemoryItem:
"""The operator writes an item by hand (the web's Memory page); its source is its own MEMORY_NOTED."""
return note(ledger, store, scope=scope, kind=kind, text=text, data_class=data_class, source=None,
actor={"kind": "hil", "id": checked_operator(operator)}, pinned=pinned, channel=channel)


def edit(ledger: Ledger, store: MemoryStore, mem_id: str, *, operator: str, channel: str, text: str | None = None,
Expand Down
38 changes: 31 additions & 7 deletions core/src/ooat_core/web/app/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,44 @@ export function createApi(env) {
}

/**
* Follows a task's timeline until its TASK_CLOSED, or until `signal` aborts. The server ends a stream after
* 15 minutes, and a network can drop one; the next starts after the last event seen, so nothing is shown twice
* or missed. A dropped connection is tried again after 1 s, then twice as long per further drop, at most 30 s.
* Follows a task's timeline until its TASK_CLOSED, or until `signal` aborts.
* @param {string} task
* @param {string | null} after the last event already shown
* @param {(event: any) => void} onEvent
* @param {AbortSignal} signal
*/
async function follow(task, after, onEvent, signal) {
function follow(task, after, onEvent, signal) {
return stream(`/tasks/${encodeURIComponent(task)}/events`, "TASK_CLOSED", after, onEvent, signal);
}

/**
* Follows a conversation with the reception until its CONVERSATION_CLOSED, or until `signal` aborts.
* @param {string} conversation
* @param {string | null} after the last event already shown
* @param {(event: any) => void} onEvent
* @param {AbortSignal} signal
*/
function followConversation(conversation, after, onEvent, signal) {
return stream(`/conversations/${encodeURIComponent(conversation)}/events`, "CONVERSATION_CLOSED", after, onEvent,
signal);
}

/**
* The events of a stream until the event `end`. The server ends a stream after 15 minutes, and a network can drop
* one; the next starts after the last event seen, so nothing is shown twice or missed. A dropped connection is
* tried again after 1 s, then twice as long per further drop, at most 30 s.
* @param {string} path below /api/v1
* @param {string} end the event type that ends it
* @param {string | null} after
* @param {(event: any) => void} onEvent
* @param {AbortSignal} signal
*/
async function stream(path, end, after, onEvent, signal) {
let cursor = after;
let wait = RECONNECT_MS;
while (!signal.aborted) {
try {
const response = await env.fetch(`/api/v1/tasks/${encodeURIComponent(task)}/events`, {
const response = await env.fetch(`/api/v1${path}`, {
headers: headers({Accept: "text/event-stream", ...(cursor ? {"Last-Event-ID": cursor} : {})}), signal,
});
if (!response.ok || !response.body) throw await failure(response);
Expand All @@ -116,7 +140,7 @@ export function createApi(env) {
cursor = event.id;
wait = RECONNECT_MS;
onEvent(event);
if (event.type === "TASK_CLOSED") return;
if (event.type === end) return;
}
}
} catch (error) {
Expand All @@ -130,7 +154,7 @@ export function createApi(env) {
}
}

return {json, raw, follow};
return {json, raw, follow, followConversation};
}

/** @typedef {ReturnType<typeof createApi>} Api */
Expand Down
14 changes: 13 additions & 1 deletion core/src/ooat_core/web/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
--accent: #7aa2ff; --ok: #5cc27a; --fail: #ff8a80; --ask: #e0b25c; }
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.5 system-ui, sans-serif; }
#top { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; justify-content: space-between;
padding: 10px 16px; border-bottom: 1px solid var(--line); }
Expand Down Expand Up @@ -52,4 +53,15 @@ pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: 13px/1.45
.diff .add { color: var(--ok); }
.diff .del { color: var(--fail); }
.diff .hunk { color: var(--muted); }
@media (max-width: 720px) { .columns { grid-template-columns: 1fr; } }
.box textarea { resize: vertical; margin-bottom: 4px; }
details > summary { cursor: pointer; color: var(--muted); margin: 16px 0 4px; }
.thread { display: flex; flex-direction: column; gap: 8px; margin: 12px 0; }
.turn { max-width: min(85%, 760px); border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px;
background: var(--card); }
.turn.you { align-self: flex-end; background: var(--bg); }
.turn.report { border-color: var(--ok); }
.turn time { display: block; font-size: 0.8rem; color: var(--muted); }
.turn p, .turn ol { margin: 4px 0; }
.memory-item { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; }
.memory-item .text { flex: 1 1 20em; white-space: pre-wrap; }
@media (max-width: 720px) { .columns { grid-template-columns: 1fr; } .turn { max-width: 100%; } }
14 changes: 12 additions & 2 deletions core/src/ooat_core/web/app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { createApi, KEY_STORAGE } from "./api.js";
import { h, report } from "./dom.js";
import { money } from "./format.js";
import { currentLanguage, setLanguage, t } from "./i18n.js";
import { inbox } from "./views/inbox.js";
import { home, storedProject } from "./views/home.js";
import { conversationPage } from "./views/conversation.js";
import { memoryPage } from "./views/memory.js";
import { taskPage } from "./views/task.js";
import { connectors } from "./views/connectors.js";
import { usage } from "./views/usage.js";
Expand All @@ -18,15 +20,23 @@ const top = /** @type {HTMLElement} */ (document.getElementById("top"));
/** @typedef {(root: HTMLElement, ctx: import("./api.js").Context, match: RegExpMatchArray) => Promise<void>} Screen */
/** @type {[RegExp, Screen][]} */
const ROUTES = [
[/^\/$/, (root, ctx) => inbox(root, ctx)],
[/^\/$/, (root, ctx) => home(root, ctx)],
[/^\/conversations\/(cnv_[A-Za-z0-9]+)$/, (root, ctx, match) => conversationPage(root, ctx, match[1])],
[/^\/tasks\/([A-Za-z0-9_]+)$/, (root, ctx, match) => taskPage(root, ctx, match[1])],
[/^\/memory$/, (root, ctx) => { // the header's Memory: the project last shown on the home page
const scope = storedProject() ?? "operator";
history.replaceState(null, "", `/memory/${scope}`);
return memoryPage(root, ctx, scope);
}],
[/^\/memory\/([a-z0-9][a-z0-9_-]*)$/, (root, ctx, match) => memoryPage(root, ctx, match[1])],
[/^\/usage$/, (root, ctx) => usage(root, ctx)],
[/^\/connectors$/, (root, ctx) => connectors(root, ctx)],
[/^\/setup$/, (root, ctx) => setup(root, ctx)],
];
/** @type {[string, () => string][]} */
const NAV = [
["/", () => t("nav.inbox")],
["/memory", () => t("nav.memory")],
["/usage", () => t("nav.usage")],
["/connectors", () => t("nav.connectors")],
["/setup", () => t("nav.setup")],
Expand Down
85 changes: 85 additions & 0 deletions core/src/ooat_core/web/app/conversation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// What a conversation with the reception shows (design 08 §4, §9): one turn per event, in order. A decision that a
// later call replaced (escalated, retried) is not shown, and an event seen twice (a tap's response and the stream)
// is one turn. Text above the session's data class arrives as the server's stub and is shown as redacted.

export const REPLACED = ["escalated", "retried"];

/**
* @typedef {object} Turn
* @property {string} id
* @property {string} ts
* @property {"you" | "reception" | "report" | "closed"} who
* @property {string | null} text what was said, or the tap's label key with its text (`tap`)
* @property {string | null} tap a tap's kind (yes, differently, …), for its label
* @property {boolean} redacted
* @property {string[]} options numbered next steps
* @property {string[]} buttons the reception's taps: yes, differently, new_thing, make_task, todo, forget:<mem>, …
* @property {string | null} task a task the decision started, or the report's task
* @property {string} project
* @property {string} conversation
*/

/** @param {unknown} value */
const said = (value) => (typeof value === "string" ? value : null);

/**
* @param {any[]} events events of GET /conversations/{id}/events, oldest first
* @param {Set<string>} [seen] ids already shown; kept up to date, so a later call skips them
* @returns {Turn[]}
*/
export function shown(events, seen = new Set()) {
/** @type {Turn[]} */
const turns = [];
for (const event of events) {
if (seen.has(event.id)) continue;
seen.add(event.id);
const body = event.body ?? {};
const base = {id: event.id, ts: event.ts, project: body.project ?? "", conversation: body.conversation ?? "",
tap: null, redacted: false, options: [], buttons: [], task: null};
if (event.type === "CONVERSATION_MESSAGE") {
const tap = said(body.tap);
const text = tap ? said(body.tap_text) : said(body.text);
const redacted = (body.tap_text !== undefined || !tap) && text === null;
turns.push({...base, who: "you", tap: tap ? tapKind(tap) : null, text, redacted});
} else if (event.type === "RECEPTION_DECIDED" || event.type === "CONVERSATION_REPORTED") {
if (REPLACED.includes(body.decision)) continue;
const text = said(body.reply);
const visible = text !== null;
turns.push({...base, who: event.type === "RECEPTION_DECIDED" ? "reception" : "report", text,
redacted: !visible, options: visible && Array.isArray(body.options) ? body.options.filter(said) : [],
buttons: visible && Array.isArray(body.buttons) ? body.buttons.filter(said) : [], task: body.task ?? null});
} else if (event.type === "CONVERSATION_CLOSED") {
turns.push({...base, who: "closed", text: said(body.reason)});
}
}
return turns;
}

/** The kind of a tap, without its memory id: "forget:mem_…" is "forget". @param {string} tap */
export function tapKind(tap) {
return tap.split(":", 1)[0];
}

/** @type {Record<string, import("./i18n.js").Key>} */
const LABELS = {yes: "conv.yes", differently: "conv.differently", new_thing: "conv.newThing",
make_task: "conv.makeTask", todo: "conv.todo", forget: "conv.forget", done: "conv.done"};

/**
* The i18n key of a button or of a tap message's label; null for a tap this page does not know.
* @param {string} tap
* @returns {import("./i18n.js").Key | null}
*/
export function tapLabel(tap) {
return LABELS[tapKind(tap)] ?? null;
}

/**
* The conversation the home page continues: the newest open one of this project on the web. A conversation of a
* chat bridge is its own channel's, never continued here.
* @param {any[]} list conversations of GET /conversations, newest first
* @param {string} project
* @returns {string | null}
*/
export function openOf(list, project) {
return list.find((c) => c.project === project && c.channel === "web" && !c.closed)?.conversation ?? null;
}
Loading
Loading