diff --git a/README.md b/README.md index 58c64fa..18557cd 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,11 @@ South African civil construction company, backed by **PocketBase**. Tracks, per construction site: contracts, employees & timesheets, vehicles (fuel + plant), fuel logs, plant hour meters, fixed costs, income claims, purchase orders + line items, and delivery notes with -OCR-driven receipting. Includes an LLM chat ("Ask") with a read-only -tool layer over the same data, and a read-only MCP server. +OCR-driven receipting. Includes an LLM agent ("Ask") that reads the same +data the dashboard uses, measures anything with free queries and its own +charts, reads any file you attach, remembers lasting facts, changes data +through confirm-first proposed actions with undo, and files its own PDF +reports and app change requests — plus a read-only MCP server. > **Default login PIN is `2468`.** Change `NEXT_PUBLIC_APP_PIN` in > production. There is no PIN bypass — the old `?demo=1` shortcut was @@ -43,7 +46,7 @@ npm run mcp # uses the PocketBase creds in .env.local ## Modules -The sidebar (`src/components/layout/sidebar.tsx`) has four top-level +The sidebar (`src/components/layout/sidebar.tsx`) has five top-level pages and three groups. Each is documented in [`docs/modules.md`](./docs/modules.md). @@ -64,7 +67,8 @@ pages and three groups. Each is documented in | `/dashboard/financial/purchase-orders[/[poId]]` | Purchase Orders | Every PO, fulfilment %, line editing, verify | | `/dashboard/financial/delivery-notes` (+ `/inbox`, `/work-queue`, `/document/[id]`) | Delivery Notes | Upload → OCR → match → post pipeline and its review queues | | `/dashboard/financial/suppliers[/[supplierId]]` | Suppliers | Ordered / delivered / outstanding per supplier | -| `/dashboard/chat` | Ask | Read-only LLM chat with the same metrics the dashboard uses | +| `/dashboard/chat` | Ask | Agent: free queries + charts, files, memory, proposed data changes with undo, PDF reports, images | +| `/dashboard/change-requests` | Change requests | App changes ("can we add this page/column/button?") Ask has logged for the developer | | `/clock` | MEGA Clock | Installable phone PWA: worker badge scan in/out + vehicle hour-meter scan | Old paths (`/dashboard/employees`, `/dashboard/fuel[/id]`, @@ -131,7 +135,10 @@ historical, not authoritative. | `POST /api/vehicles/import-summary` | server-only | Sync the Buildsite Vehicle Fuel Summary CSV into `vehicles` | | `POST /api/vehicles/[id]/merge` | server-only | Merge a duplicate vehicle into another (dry run unless `confirm`) | | `GET /api/pb-files/[collection]/[recordId]/[...filename]` | server-only | Same-origin proxy for PocketBase files (PDF slips in iframes) | -| `POST /api/chat` | `ENABLE_CHAT=true` (no user auth) | SSE chat stream | +| `POST /api/chat` | `ENABLE_CHAT=true`, `x-ask-user` identifies (no real auth yet) | SSE chat stream — `thread`/`action`/`chart`/`image`/`pdf` events | +| `GET /api/ask/users`, `GET/POST /api/chat/threads[/[id]]`, `POST /api/chat/attachments`, `GET/POST /api/ask/memories[/[id]]` | `ENABLE_CHAT=true` + `x-ask-user` | Ask's picker, threads, attachments, memory | +| `GET /api/agent/actions/[id]`, `POST …/{apply,reject,undo}` | `ENABLE_CHAT=true` + `x-ask-user`; confirm routes are same-origin only | Propose → confirm → apply/undo, with a before/after snapshot | +| `GET/PATCH /api/ask/change-requests[/[id]]` | server-only | App change requests Ask filed for the developer | | `GET /api/clock/employee-lookup`, `POST /api/clock/scan`, `POST /api/clock/vehicle-lookup`, `POST /api/clock/vehicle-scan` | server-only | MEGA Clock PWA: badge clock-in/out + vehicle hour-meter scan | | `POST /api/clock/events/[id]/correct` | same-origin + `x-app-pin` = `APP_PIN` | Record the real leaving time for a missed scan-out | diff --git a/docs/api-reference.md b/docs/api-reference.md index fb3cbfb..eb5779a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -596,20 +596,41 @@ but valid "last scanned at site X" is more useful than nulling it. **File:** `src/app/api/chat/route.ts` **Caching:** `dynamic = "force-dynamic"` -**Auth:** none — gated by `ENABLE_CHAT=true`. **No user authentication.** +**Auth:** none — gated by `ENABLE_CHAT=true`. Identification only, via +`x-ask-user` (a `users` record id from the picker — see +[modules.md §7.1](./modules.md#ask-agent-actions-modules)). **No real user +authentication yet.** **Protocol:** Server-Sent Events +**Body — new:** `{ threadId?, message, display?, attachmentIds?, images? }` +with `x-ask-user`. No `threadId` creates one, titled from `message`, owned +by the actor; a thread owned by someone else 404s. +**Body — legacy** (kept working for one release, unpersisted): `{ history }` +with no `threadId`. + `Content-Type: text/event-stream; charset=utf-8`. Events emitted: - `meta` — `{ provider, model }` (first) +- `thread` — `{ id, title }` — only when a new thread was just created - `delta` — `{ text }` (repeated, as the model streams) - `tool` — `{ name }` (when a tool is called) +- `action` — the full `PlannedAction` preview from `propose_changes` + (`{ actionId, summary, preview[{ collection, kind, id?, before?, after }], problems, status: "pending" }`). + Sent **instead of** handing the plan to the model: the tool result the + model sees is `{ actionId, status, problems }` only. - `chart` — a `ChartSpec` (`src/lib/reports/chart-spec.ts`: `{ id, kind, title, subtitle?, xKey, xLabel?, series[{ key, label, format }], stacked?, rows }`), - once per chart a report tool returned. Sent **instead of** handing the - chart to the model: the tool result the model sees has `charts` replaced - by `chartsAvailable: { id, title }[]`, and the model places a chart by - writing `[[chart:]]` on its own line. `id` matches + once per chart a report tool or `make_chart` returned. Sent **instead + of** handing the chart to the model: the tool result the model sees has + `charts` replaced by `chartsAvailable: { id, title }[]`, and the model + places a chart by writing `[[chart:]]` on its own line. `id` matches `/^[a-z0-9-]{1,64}$/`; specs with any other id are dropped. +- `image` — `{ id, url }`, once per `generate_image` call. Same split as + `chart`: the model keeps `imagesAvailable: [{ id }]` and places it with + `[[image:]]`. +- `pdf` — `{ title, subtitle?, filename? }`, once when `make_pdf_report` + fires (the model calls it last, after building the report itself). The + browser renders a report cover and a Download PDF button for that turn; + the model's own tool result is just `{ ok: true }`. - `done` — `{ text, iterations, stoppedOnLimit }` (final) - `error` — `{ message }` (on failure) @@ -623,6 +644,32 @@ tool layer (`src/lib/agent/`). --- +## 12b. Ask agent actions + +Detailed field-by-field contracts live in each route's own doc comment +(same convention as §12a); see +[modules.md §7.1](./modules.md#ask-agent-actions-modules) for how these +fit together and [data-model.md §4.17](./data-model.md#ask-agent-actions) +for the collections. All gated the same way as §12 (`ENABLE_CHAT=true`); +all identify the actor via `x-ask-user` except where noted. + +| Method + path | Purpose | +| --- | --- | +| `GET /api/ask/users` | `{ users: { id, name }[] }` — `users` records with a non-empty `name`, for the "Who's using Ask?" picker. No `x-ask-user` needed. | +| `GET` / `POST /api/chat/threads` | List the actor's threads (newest first) / create an empty one. | +| `GET` / `PATCH` / `DELETE /api/chat/threads/[id]` | A thread's rows / rename+archive / delete. 404 if not owned by the actor. | +| `POST /api/chat/attachments` | Multipart `file` (+ optional `threadId`). Parses and stores as an `ask_messages` row (`kind: "attachment"`); returns `{ threadId, attachmentId, kind, summary, images? }` (`images` only for a scanned PDF's rasterised pages). | +| `GET /api/agent/actions/[id]` | An action's current state, reconstructed as `{ actionId, summary, preview, problems: [], status, error }` — used to re-render a card on thread reopen or refresh. | +| `POST /api/agent/actions/[id]/apply` | Confirm: applies a pending action. Same-origin only (`Origin` must match). | +| `POST /api/agent/actions/[id]/reject` | Cancel: marks a pending action rejected without applying anything. Same-origin only. | +| `POST /api/agent/actions/[id]/undo` | Reverses an applied action once. Same-origin only. | +| `GET` / `POST /api/ask/memories` | List the actor's active memories + active shared ones / add one manually (`{ text, site?, shared? }`). | +| `DELETE /api/ask/memories/[id]` | Forget one (kept as `status: "undone"`, not deleted). 400 if someone else tries to forget a personal memory. | +| `GET /api/ask/change-requests` | `{ requests: [...] }`, optional `?status=`. No `x-ask-user` needed — a plain dashboard route, not actor-scoped. | +| `PATCH /api/ask/change-requests/[id]` | `{ status }` — `open`/`planned`/`done`/`declined`. No `x-ask-user` needed. | + +--- + ## 12a. Routes added after the 2026-09-06 pass These are summarised rather than documented field-by-field; each route @@ -668,7 +715,7 @@ the queue item (see [`n8n/purchase-order-ingest-implementation.md`](./n8n/purcha | `PATCH /api/suppliers/[id]` | Rename, same normalisation + uniqueness. | | `POST /api/vehicles/[id]/merge` | Body `{ intoId, confirm? }`. Without `confirm: true` returns the plan (dry run). With it: moves the duplicate `[id]`'s fuel logs and plant-hours rows onto `intoId`, fills blank fields, deletes `[id]`. 409 on conflicts (different site/type, same plant-hours week). Batches of ≤ 50; safe to re-run if interrupted. | | `POST /api/vehicles/import-summary` | Sync the Buildsite "Vehicle Fuel Summary" CSV: which vehicles exist per site and whether each is fuel (km/L) or plant (L/h). | -| `GET /api/pb-files/[collection]/[recordId]/[...filename]` | Same-origin proxy for PB files, so PDF slips render in an iframe without PB's sandbox CSP. | +| `GET /api/pb-files/[collection]/[recordId]/[...filename]` | Same-origin proxy for PB files, so PDF slips render in an iframe without PB's sandbox CSP. Collections: `delivery_notes`/`purchase_orders` (public, forwarded as-is) and `ask_messages` (its rules are all null by design — this route mints a short-lived admin file token server-side rather than exposing that rule). | ### MEGA Clock additions @@ -722,7 +769,11 @@ searched for: | `GET /api/data-import-batches/[id]/status` | server-only | UI poll | | `POST /api/data-import-batches/[id]/retry` | server-only | | | `POST /api/records/[collection]/[id]/verify` | server-only | Allowlist: POs + DNs | -| `POST /api/chat` | gated by `ENABLE_CHAT=true` | **No user auth** — the gate is the env flag, not identity | +| `POST /api/chat` | gated by `ENABLE_CHAT=true` | `x-ask-user` **identifies**, doesn't authenticate — anyone with the PIN can pick any name | +| `GET /api/ask/users` · `GET/POST /api/chat/threads` · `.../[id]` · `POST /api/chat/attachments` · `GET/POST /api/ask/memories` · `DELETE …/[id]` | gated by `ENABLE_CHAT=true` | `x-ask-user` identifies the actor; threads/memories 404/400 if not owned | +| `GET /api/agent/actions/[id]` | gated by `ENABLE_CHAT=true` | `x-ask-user` only | +| `POST /api/agent/actions/[id]/{apply,reject,undo}` | same-origin only + `x-ask-user` | Confirm-card buttons; in the test phase anyone may confirm any pending action | +| `GET/PATCH /api/ask/change-requests` · `.../[id]` | server-only | Plain dashboard route, not actor-scoped | | `POST /api/clock/scan` | server-only | Behind the /clock PIN gate | | `POST /api/clock/vehicle-lookup` | server-only | Behind the /clock PIN gate | | `POST /api/clock/vehicle-scan` | server-only | Behind the /clock PIN gate | @@ -740,7 +791,7 @@ searched for: | `POST /api/suppliers` · `PATCH /api/suppliers/[id]` | server-only | | | `POST /api/vehicles/import-summary` | server-only | Buildsite CSV sync | | `POST /api/vehicles/[id]/merge` | server-only | Dry run unless `confirm: true`; deletes the duplicate | -| `GET /api/pb-files/[collection]/[recordId]/[...filename]` | server-only | Same-origin PB file proxy | +| `GET /api/pb-files/[collection]/[recordId]/[...filename]` | server-only | Same-origin PB file proxy; mints an admin file token for `ask_messages` | **Routes with no protection at all:** the chat route (when enabled) and any route that runs purely server-side. The browser-facing routes diff --git a/docs/data-model.md b/docs/data-model.md index 406c5fc..4abb182 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -370,6 +370,62 @@ Joins DNs to PO line items. `cascadeDelete: true` on the parent DN. | `qty_received` | number | | | `condition_notes` | text | Free-text audit trail (e.g. `"OCR 92% · desc: Cement 50kg · unit: bag"`) | + + +### 4.17 `ask_threads`, `ask_messages`, `ask_log` (Ask agent actions) + +Additive, created by `scripts/pb-add-ask-collections.py` (`--apply` is a +human step). All rules `null` — superuser only; the Next.js server is the +only client. See [modules.md §7.1](./modules.md#ask-agent-actions-modules) +for how these back threads, memory, actions and change requests. + +**`ask_threads`** — one row per conversation. + +| Field | Type | Notes | +| --- | --- | --- | +| `title` | text | `autoTitle()` of the first message, or renamed by the user | +| `owner` | relation → `users`, required | The picked actor (`x-ask-user`) | +| `archived` | bool | | +| `last_message_at` | date | Bumped on every `appendMessage`; a lost race here is harmless | + +**`ask_messages`** — one row per message, attachment or generated image. +**Append-only**: created once, never rewritten, so concurrent tabs/users +can't overwrite each other. + +| Field | Type | Notes | +| --- | --- | --- | +| `thread` | relation → `ask_threads`, required, **cascade delete** | | +| `owner` | relation → `users` | | +| `kind` | select | `user` / `assistant` / `attachment` / `image` | +| `text` | text (max 100 000) | The message; for an attachment, its summary; for an image, the prompt | +| `display` | text | The slash command as typed, when applicable | +| `charts` | json | `ChartSpec[]` the answer placed or left unplaced | +| `action_ids`, `attachment_ids`, `tools` | json | Arrays of ids / tool names | +| `file` | file (≤25 MB; xlsx/xls/csv/pdf/png/jpg/webp/txt) | Attachment or generated image | +| `parsed` | json (≤5 MB) | Attachment preview (capped 2 000 rows / 50 000 chars — `read_attachment` re-parses the stored file beyond that), or `{ pdf: {...} }` for a report turn | +| `parse_error` | text | | + +**`ask_log`** — one row per thing Ask keeps track of, by `type`. + +| Field | Type | Notes | +| --- | --- | --- | +| `type` | select | `change` (proposed data edit, for Undo) / `memory` (lasting fact) / `request` (app change request) | +| `owner` | relation → `users` | | +| `shared` | bool | Memories only — visible to everyone when `true` | +| `thread` | relation → `ask_threads` (optional, **no** cascade) | The log outlives a deleted thread | +| `status` | select | `change`: `pending`/`applied`/`rejected`/`failed`/`undone`. `memory`: `applied` (active) / `undone` (forgotten). `request`: `open`/`planned`/`done`/`declined` | +| `text` | text (max 2 000) | Change summary / memory text / request title | +| `detail` | text | Request detail | +| `site` | relation → `sites` (optional) | A memory about one site | +| `ops` | json | The planned `AgentOp[]`, `$ref`s intact | +| `before`, `after` | json | Snapshots for Undo. `before`: one entry per op. `after`: while `pending`, the proposed values per op; once `applied`/`failed`, `{ results, confirmed_by, undone_by? }` | +| `error` | text | Set on `applied_action` → `failed` | +| `applied_at`, `undone_at` | date | | + +Indexes: `ask_threads(owner, archived, last_message_at)`, +`ask_messages(thread, created)`, `ask_log(type, status)`, +`ask_log(owner, type)`, `ask_log(thread)`. + --- ## 5. Indexes and constraints summary diff --git a/docs/modules.md b/docs/modules.md index 916c358..26ba789 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -437,6 +437,121 @@ real authentication exists — the chat reads every site's financials and spends LLM credits for anyone who can reach it." See [multi-site-assessment §7](./multi-site-assessment.md#7-the-chatagent-layer). + + +### 7.1 Agent actions — threads, memory, files, free queries, proposed changes, PDF reports, images, change requests + +Ask went from a report reader to an agent that reads any file, measures +anything with its own charts, changes data through confirm-first actions +with undo, and files its own PDF reports and app-change requests. Three +PocketBase collections back all of it (`scripts/pb-add-ask-collections.py`, +additive, `--apply` is a human step): `ask_threads`, `ask_messages` +(append-only), `ask_log` (`type`: `change` | `memory` | `request`). Schema +details: [data-model.md](./data-model.md#ask-agent-actions). + +**Per-user, no logins yet.** `src/lib/agent/context.ts`'s `resolveActor` +trusts an `x-ask-user` header — a `users` record id the "Who's using +Ask?" picker (`GET /api/ask/users`, only records with a `name`) writes to +`localStorage`. This is identification, not security, until real logins +land — see "When logins land" in the plan doc. `AgentContext { actor, +threadId }` is threaded through every tool and the gateway. + +**Threads.** `src/lib/chat/threads.ts` loads/appends `ask_messages` rows +(`autoTitle`, `loadThreadEntries`, `appendMessage` — creates only, never +rewrites, so concurrent tabs/users can't clobber each other) under +`/api/chat/threads` (list/create) and `/api/chat/threads/[id]` +(get/rename/archive/delete). `/api/chat` accepts +`{ threadId?, message, display?, attachmentIds?, images? }` with +`x-ask-user`; no `threadId` creates one titled from the first message. The +old `{ history }` body (no `threadId`) still works, unpersisted, for one +release. + +**Memory.** `src/lib/agent/memory.ts`: `remember`/`forget`/`listMemories` +against `ask_log` (`type: "memory"`), deduped by normalised text. Personal +memories are private to their owner; `shared: true` ones are visible to +everyone. `formatMemoriesForPrompt` feeds the prompt's "What you remember" +section. A Memory drawer in `chat-view.tsx` lists Mine / Shared with +delete buttons and a manual add. + +**Files.** `src/lib/agent/attachments.ts`'s `parseAttachment` reads +xlsx/xls/csv (SheetJS/PapaParse), PDF (`pdftotext -layout`; under ~50 +chars/page marks it `scanned`), images and plain text. +`POST /api/chat/attachments` stores the result as an `ask_messages` row +(`kind: "attachment"`); a scanned PDF's first pages are rasterised and +returned as vision images the same way a photographed slip is. The +`read_attachment` tool re-parses on demand and pages a sheet 200 rows at a +time. `send_to_po_pipeline` posts a stored attachment into the existing +purchase-order pipeline (hash-dedupe, same as `/api/purchase-orders/upload`) +once the user confirms it's a PO/DN. `src/lib/agent/people-match.ts`'s +`matchPeople` (employee number → normalised full name → surname+initial +"likely") backs the "load workers from a list" recipe: `match_workers` → +`propose_changes`, never re-creating an exact match. + +**Free queries and charts.** `src/lib/agent/query.ts`'s `runQuery` reads +either a live PocketBase collection (fields validated against +`schema-cache.ts`, filters bound with `pb.filter()`) or one of the seven +virtual datasets in `src/lib/agent/datasets.ts` (`site_summary`, +`fuel_log_status`, `plant_hours`, `payroll_hours`, `attendance`, +`supplier_balance`, `po_fulfilment` — each built from the same +`src/lib/metrics` functions the dashboard pages use, never re-derived). +Grouping, Monday-start week/day/month bucketing, and +sum/avg/min/max/count/count_distinct aggregation happen in JS on rows +already fetched — the model never computes. Results are cached under a +`resultId` (2h TTL); `make_chart` turns one into a `ChartSpec` by column +name only, so the model designs its own chart without ever holding a +number. + +**Proposed changes (the gateway).** `src/lib/agent/gateway.ts` is the one +seam every write goes through: `authorize()` (a stub today — rejects +`NEVER_WRITABLE` collections and `dn_batches` lock/stage fields; the seam +where logins will add real permission checks) → `planOps` (schema +validation, `before` snapshots, relation-id existence, the +sites/employees duplicate rule, capped at 500 ops) → `createPendingAction` +(a pending `ask_log` row) → `applyAction`/`undoAction`. `propose_changes` +is the tool; `/api/agent/actions/[id]/{apply,reject,undo}` are the +same-origin-only confirm-card routes. The full plan streams to the +browser as an `action` SSE event; the model itself only ever sees +`{ actionId, status, problems }` — never the preview — the same split +`extractCharts` already does for chart numbers. `action-card.tsx` renders +it (created rows in green, updated fields old→new, deleted in red) with +Confirm/Cancel, then Undo once applied. + +**PDF export.** Two independent paths. (1) Any answer can be exported +as-is: the toolbar's **PDF** button (`src/app/dashboard/chat/answer-pdf.ts`) +clones the rendered answer at a fixed A4 width, renders it with +`html-to-image`, and slices it into pages that never cut through a chart +or table row (`slicePages`), embedding each with `pdf-lib`. (2) Ask can +build a report on request ("give me a PDF on fuel at The Hills for +September"): it writes the report itself (headings, narrative, KPI +tables quoting tool display strings, charts via `make_chart`), then calls +`make_pdf_report({ title, subtitle? })` **once, last** — a pure metadata +signal (`src/lib/agent/pdf-report.ts`), split out of the tool result and +streamed as a `pdf` SSE event the same way an action card is. The turn +then renders a report cover (wordmark, title, date, "Prepared by Ask for +{name}") and reuses path (1)'s `answerToPdf`/`slicePages` for a Download +button that also fires once automatically +(`src/app/dashboard/chat/pdf-auto-download.ts`'s `nextPdfTurnToDownload`, +a pure "once per turn" decision). The report meta persists in the +assistant row's existing `parsed` json field, so reopening the thread +still shows the cover and button. + +**Images.** `generate_image` (`src/lib/agent/images.ts`) calls MiniMax +`image-01` and stores the result as its own `ask_messages` row +(`kind: "image"`), streamed as an `image` SSE event and placed with +`[[image:]]` — the same split/placement mechanism as a chart, but an +illustration only: never a chart, a number, or anything that looks like +data. A `base_resp.status_code` in `{1028, 1030, 2061}` (the MiniMax Token +Plan tier gate) surfaces as "Image generation isn't enabled on this +MiniMax plan" rather than retrying. + +**Change requests.** "Can we add this to the app?" can't be done by Ask +itself — `file_change_request` logs an `ask_log` row (`type: "request"`, +`status: "open"`) instead of `propose_changes`, which is for data. +`/dashboard/change-requests` lists and re-statuses them +(open/planned/done/declined); `list_change_requests` (read-only) is also +served by the MCP server (`src/mcp/server.ts`) so Claude Code can pick +them up. + --- ## 8. Financial landing, Delivery Notes, Suppliers diff --git a/docs/superpowers/plans/2026-09-26-ask-agent-actions.md b/docs/superpowers/plans/2026-09-26-ask-agent-actions.md index 7c20e39..079be8c 100644 --- a/docs/superpowers/plans/2026-09-26-ask-agent-actions.md +++ b/docs/superpowers/plans/2026-09-26-ask-agent-actions.md @@ -379,13 +379,13 @@ Added mid-plan: the user wants to ask for any custom report and get a finished P - The Claude provider still points at `claude-opus-5`. Read the `claude-api` skill and update it to the current model (a configurable `ANTHROPIC_MODEL` env with a current default). - Add `docs/operations.md` guidance: `CHAT_PROVIDER=anthropic` + `ANTHROPIC_API_KEY` switches Ask to Claude, which is more reliable at long multi-step tool use (reading a sheet → matching → proposing 40 creates). Run the same 5 test prompts on both providers and compare. -- [ ] Commit — `chore(ask): configurable Claude model`. +- [x] Commit — `chore(ask): configurable Claude model`. --- ### Task 13: Docs -- [ ] `docs/modules.md` §7 (threads, memory, files, query/datasets, actions + undo, PDF, images, change requests), `docs/data-model.md` (`ask_threads`, `ask_messages`, `ask_log`), `docs/api-reference.md` (new routes + `thread`/`action`/`image` SSE events), `README.md` summary. Commit — `docs(ask): agent actions`. +- [x] `docs/modules.md` §7 (threads, memory, files, query/datasets, actions + undo, PDF, images, change requests), `docs/data-model.md` (`ask_threads`, `ask_messages`, `ask_log`), `docs/api-reference.md` (new routes + `thread`/`action`/`image`/`pdf` SSE events), `README.md` summary. Commit — `docs(ask): agent actions`. ---