docs(ask): agent actions plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af71b8534b
commit
7299f81c63
1 changed files with 386 additions and 0 deletions
386
docs/superpowers/plans/2026-09-26-ask-agent-actions.md
Normal file
386
docs/superpowers/plans/2026-09-26-ask-agent-actions.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
# Ask Agent — Threads, Memory, Files, Free Queries, Actions, PDF, Images — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Before touching `src/lib/chat/provider-anthropic.ts` (Task 12) read the `claude-api` skill for current model IDs. Before any chart work read the `dataviz` skill.
|
||||
|
||||
**Goal:** Turn Ask from a report reader into an agent that can **do anything** in the app. It remembers conversations and facts, reads any file you give it (Excel, CSV, PDF, photo, typed list), answers any "measure this" question with its own charts and layout, and changes data through confirm-first actions with undo. It can also export answers as PDF, generate images, and log change requests for the developer. All of this goes through one gateway, so logins and permissions can be added later in one place.
|
||||
|
||||
**Architecture:**
|
||||
- **Everything through one gateway.** Every write the agent makes goes through `src/lib/agent/gateway.ts`. Its `authorize(ctx, op)` returns "allowed" today; when logins land it becomes the single permission check.
|
||||
- **Write rules:**
|
||||
- Writes are **proposed**, not executed. The agent calls `propose_changes`, which stores a pending `ask_log` row (`type: "change"`) with a before/after preview and streams an `action` event.
|
||||
- You press **Confirm** in the chat, and only then does the server apply it, in PocketBase batches.
|
||||
- Every applied action keeps a `before` snapshot, so **Undo** can reverse it.
|
||||
- **Reads go through a generic `query_records` tool** over every business collection plus "virtual datasets" that expose the dashboard's own computed figures. Filters are bound safely with `pb.filter()`; grouping and totals are computed by the server, so the model never does arithmetic. Results are cached under a `resultId`, and `make_chart` turns a cached result into a chart. That lets the model design its own charts without handling their numbers.
|
||||
- **Three new PocketBase collections:**
|
||||
- `ask_threads`: one row per conversation — title and owner.
|
||||
- `ask_messages`: one row per message, attachment or generated image. It is **append-only**, so concurrent saves can never overwrite each other.
|
||||
- `ask_log`: one row per thing Ask keeps track of, by `type`: `change` (an action with before/after for Undo), `memory` (a lasting fact), `request` (an app change request for the developer).
|
||||
- A conversation survives leaving the page and continues until you press "New thread".
|
||||
- **Per user, from the existing `users` collection.** There are no logins yet, so Ask shows a **"Who's using Ask?"** picker listing PocketBase `users` that have a name. The browser remembers the choice (`localStorage`). Threads and personal memories are filtered to that user; shared memories are visible to everyone. Every row stores an `owner` **relation to `users`**, so when logins land the same field is simply enforced — no migration.
|
||||
|
||||
**Tech Stack:** Next.js 16.3, React 19, PocketBase 0.27 (`pb.filter`, `pb.createBatch`), Zod tool schemas, SheetJS + PapaParse (already installed), poppler `pdftotext`/`pdftoppm` (already in the Docker image), pdf-lib (installed), `html-to-image` (new, small), MiniMax `image-01`, Vitest.
|
||||
|
||||
**Spec:** This document. The user's words, 2026-09-26:
|
||||
- "make his memory persistent … continue with the thread until I say create a new thread"
|
||||
- "if I give him a list of names, a photo, or Excel spreadsheet or PDF … he can load those names for workers for a site … an agent that can actually do things"
|
||||
- "I would actually like to have it in a PDF format as well"
|
||||
- "generate charts, reports, PDFs, not something that's structured that we predefine … create something out of its own will"
|
||||
- "talk to him like I talk to you … can we change this, add this … give me a report … measure this for me"
|
||||
- "make the Ask agent be able to do any and everything, then later add users, login and permissions"
|
||||
|
||||
**Builds on:** `feat/ask-reports` (merged to `main` at 46e9e3d): `ChartSpec`, `extractCharts`, `[[chart:id]]`, report tools, slash commands.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **App phase:** on-site test app; production is months away. Testers share the PIN (`AUTH_MODE` does not exist yet). The **actor** is the user picked in Ask's "Who's using Ask?" picker: a real `users` record id sent as the `x-ask-user` header. The server checks it exists in `users` and rejects unknown ids with 400. This is **identification, not security** — anyone with the PIN can pick any name — until logins land.
|
||||
- **The PocketBase in `.env.local` is the only instance and holds real test data.** Schema scripts are dry-run by default, `--apply --confirm-host <exact host>` to write, additive only. Never delete collections or fields.
|
||||
- **Nothing the model writes is applied without a Confirm click** (Task 6). There is no auto-apply in this plan.
|
||||
- **No arithmetic by the model** stays absolute: sums, averages, counts, per-period buckets and derived figures come from `query_records` / virtual datasets / existing metrics.
|
||||
- **Generated images are never charts or figures.** Charts come from `make_chart` only.
|
||||
- Deletes are allowed but always snapshot the full record first; Undo recreates it with the same id.
|
||||
- Collections the agent may **never** write through `propose_changes`: `_superusers`, `users`, `ask_threads`, `ask_messages`, `ask_log` (memories and requests go through their own tools), `clock_correction_locks`, and DN-pipeline lock/state fields (`dn_batches.lock_*`, `dn_batches.stage`).
|
||||
- Every task ends with `npm run typecheck && npm run test:run` green and `npx eslint .` not above its baseline.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
| File | Responsibility |
|
||||
| --- | --- |
|
||||
| `scripts/pb-add-ask-collections.py` | Additive schema: `ask_threads`, `ask_messages`, `ask_log` |
|
||||
| `src/lib/agent/context.ts` | `AgentContext` (actor = picked user, threadId); `resolveActor(request)` |
|
||||
| `src/lib/agent/gateway.ts` | `authorize()`, `WRITABLE`, `planOps()`, `applyAction()`, `undoAction()` |
|
||||
| `src/lib/agent/schema-cache.ts` | Live PB collection schemas (5-min cache) + `validateOp()` |
|
||||
| `src/lib/agent/query.ts` | `QuerySpec` (zod), `runQuery()`, result cache |
|
||||
| `src/lib/agent/datasets.ts` | Virtual datasets built from `src/lib/metrics` |
|
||||
| `src/lib/agent/attachments.ts` | Parse xlsx/csv/pdf/image/text into `ParsedAttachment` |
|
||||
| `src/lib/agent/memory.ts` | Load/format memories for the prompt |
|
||||
| `src/lib/agent/tools-actions.ts` | `query_records`, `make_chart`, `propose_changes`, `read_attachment`, `remember`, `forget`, `generate_image`, `file_change_request`, `send_to_po_pipeline` |
|
||||
| `src/lib/chat/threads.ts` | Load a thread's rows; append a message row |
|
||||
| `src/app/api/ask/users/route.ts` | `GET` → `users` with a name, for the picker |
|
||||
| `src/app/api/chat/route.ts` | Accepts `{ threadId?, message, attachmentIds? }`; server-side history |
|
||||
| `src/app/api/chat/threads/**` | List / create / rename / delete threads |
|
||||
| `src/app/api/chat/attachments/route.ts` | Upload as an `ask_messages` row (`kind: "attachment"`), parse, store the preview on it |
|
||||
| `src/app/api/agent/actions/[id]/{apply,reject,undo}/route.ts` | Confirm-card endpoints |
|
||||
| `src/app/dashboard/chat/*` | Thread list, action card, attachment chips, PDF button, image blocks |
|
||||
| `src/app/dashboard/change-requests/page.tsx` | List of `ask_log` rows with `type: "request"` |
|
||||
| `src/mcp/server.ts` | `list_change_requests` so Claude Code can pick them up |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Three PocketBase collections for the agent
|
||||
|
||||
**Files:** Create `scripts/pb-add-ask-collections.py` (pattern: `scripts/pb-add-dn-staged-ingestion.py` — dry run unless `--apply`, `--confirm-host`, read-then-append, idempotent).
|
||||
|
||||
| Collection | Fields |
|
||||
| --- | --- |
|
||||
| `ask_threads` | `title` text · `owner` relation→users (required) · `archived` bool · `last_message_at` date |
|
||||
| `ask_messages` | `thread` relation→ask_threads (required, **cascade delete**) · `owner` relation→users · `kind` select(`user`, `assistant`, `attachment`, `image`) · `text` text (max 100 000; for attachments the file summary, for images the prompt) · `display` text (the slash command as typed) · `charts` json · `action_ids` json · `attachment_ids` json · `tools` json · `file` file (single, max 25 MB; xlsx, xls, csv, pdf, png, jpg, webp, txt) · `parsed` json (maxSize 5 MB; attachment preview) · `parse_error` text |
|
||||
| `ask_log` | `type` select(`change`, `memory`, `request`) · `owner` relation→users · `shared` bool (memories: visible to everyone) · `thread` relation→ask_threads (optional, **no** cascade — the log outlives a deleted thread) · `status` select(`pending`, `applied`, `rejected`, `failed`, `undone`, `open`, `planned`, `done`, `declined`) · `text` text (change summary / memory text / request title, max 2 000) · `detail` text (request detail) · `site` relation→sites (optional; memories about one site) · `ops` json · `before` json · `after` json · `error` text · `applied_at` date · `undone_at` date |
|
||||
|
||||
**Append-only messages.** A message, attachment or image is only ever **created** as its own `ask_messages` row, never rewritten. So two tabs, two phones or ten users can never overwrite each other. The only field on `ask_threads` that changes after creation is `last_message_at` (plus title/archived edits), and a lost race there is harmless. The one exception is an assistant row: it is created when the answer finishes, with its charts and action ids, and only an action card's status is updated afterwards — and that status lives on the `ask_log` row, not the message.
|
||||
|
||||
The attachment preview in `parsed` is capped at 2 000 sheet rows / 50 000 characters; beyond that, `read_attachment` re-parses the stored file on demand.
|
||||
|
||||
Status values used per type: `change` → pending, applied, rejected, failed, undone; `memory` → applied (active) or undone (forgotten); `request` → open, planned, done, declined.
|
||||
|
||||
All rules `null` (superuser only — the Next server is the only client). Indexes: `ask_threads(owner, archived, last_message_at)`, `ask_messages(thread, created)`, `ask_log(type, status)`, `ask_log(owner, type)`, `ask_log(thread)`.
|
||||
|
||||
- [ ] Write the script → `--dry-run` prints the 3 collections → commit. **Apply is a human step** (`--apply --confirm-host "$NEXT_PUBLIC_POCKETBASE_URL"`); note it in the task report.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: The user picker and threads that survive leaving the page
|
||||
|
||||
**Files:** Create `src/lib/agent/context.ts` (+ test), `src/app/api/ask/users/route.ts`, `src/lib/chat/threads.ts` (+ test), `src/app/api/chat/threads/route.ts` (GET the actor's threads, POST create), `src/app/api/chat/threads/[id]/route.ts` (GET rows, PATCH title/archived, DELETE); Modify `src/app/api/chat/route.ts`, `src/app/dashboard/chat/chat-view.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- `GET /api/ask/users` → `{ users: { id: string; name: string }[] }`: `users` records with a non-empty `name`, sorted by name. Only `id` and `name` are sent — never email.
|
||||
- `resolveActor(request: Request): Promise<{ id: string; name: string } | Response>` — reads `x-ask-user`, checks the id exists in `users` (cache 60 s), returns 400 `"Pick who is using Ask"` when missing or unknown. `AgentContext.actor` is its result.
|
||||
- `loadThreadEntries(threadId: string): Promise<ChatEntry[]>` — rebuilds `ChatEntry[]` from `ask_messages` rows ordered by `created` (user/assistant text for the model, as today; attachments become a one-line note "[attachment <id>: <summary>]"; charts, images and actions stay UI-side).
|
||||
- `appendMessage(threadId, ownerId, row: { kind; text; display?; charts?; actionIds?; attachmentIds?; tools? }): Promise<string>` — **creates** one `ask_messages` row, then sets `ask_threads.last_message_at`. It never updates an existing message row.
|
||||
- `autoTitle(firstUserText: string): string` — first 60 chars, trimmed at a word boundary, no trailing punctuation. Pure, tested.
|
||||
- `POST /api/chat` body becomes `{ threadId?: string, message: string, display?: string, attachmentIds?: string[], images?: ChatImage[] }` with the `x-ask-user` header. No `threadId` → create a thread owned by the actor, titled `autoTitle(message)`. A thread owned by someone else → 404. The response SSE starts with `event: thread` `{ id, title }`.
|
||||
|
||||
- [ ] **Step 1: Tests**
|
||||
- `autoTitle`; `resolveActor` accepts a real id and rejects missing/unknown ones.
|
||||
- `loadThreadEntries` maps rows in order.
|
||||
- The route **creates** the user row before calling the model and the assistant row (with collected charts) after `done`; an error mid-stream still creates the partial assistant row with `tools` marked failed.
|
||||
- Two concurrent `appendMessage` calls on one thread both land (the mock records two creates, zero updates).
|
||||
- Another user's thread → 404.
|
||||
- [ ] **Step 2: Implement.** Keep the old `{ history }` body working for one release (`history` present and no `threadId` → behave as before) so an open tab doesn't break during deploy.
|
||||
- [ ] **Step 3: UI.**
|
||||
- **Picker:** on first open (no `mega-ask-user` in `localStorage`), show a card: "Who's using Ask?" with a list of names from `/api/ask/users`. Picking one stores `{ id, name }` and every chat request sends `x-ask-user`. The header shows "Asking as {name} · Change". If `/api/ask/users` returns nobody, show "Add people with a name in PocketBase → users first".
|
||||
- A left panel (collapsible on mobile) lists **that user's** threads, newest first, with title and relative time. At the top: **New thread**.
|
||||
- The URL carries `?thread=<id>`, so reload and back/forward keep the conversation.
|
||||
- Opening a thread loads its rows: messages, attachment chips, image blocks, charts (from each row's `charts`) and action cards (by `action_ids` → `ask_log`).
|
||||
- Rename inline; archive from a ⋯ menu.
|
||||
- [ ] **Step 4:** Browser check: pick a user, ask something, go to Overview, come back → same thread; **New thread** → empty; old thread still listed; switch to another user → their (empty) list, not the first user's threads. Commit — `feat(ask): per-user persistent threads`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Long-term memory
|
||||
|
||||
**Files:** Create `src/lib/agent/memory.ts` (+ test); tools in `src/lib/agent/tools-actions.ts`; Modify `src/lib/chat/system-prompt.ts`, chat UI (a "Memory" drawer)
|
||||
|
||||
**Interfaces:**
|
||||
- Tools: `remember({ text, site?, shared? })` → creates an `ask_log` row `{ type: "memory", status: "applied", owner: actor, shared, text, site }`. Default `shared: false`; the prompt tells the model to set `shared: true` for facts about the business (site hours, rates, rules) and leave personal preferences private. Dedupe: an active memory with the same normalised text visible to this user → returns the existing id. `forget({ id })` → sets it to `undone` (kept, not deleted); only the owner can forget a personal memory; anyone can forget a shared one. `list_memories({ site? })` → the actor's active memories plus active shared ones.
|
||||
- `formatMemoriesForPrompt(memories, maxChars = 4000): string` — shared first, then personal, newest first within each, `- [shared|mine][site] text` lines, truncated at `maxChars` with a note of how many were omitted. Pure, tested.
|
||||
- `buildSystemPrompt(now, memoriesBlock)` appends a `## What you remember` section, plus the rule: "When the user tells you a lasting preference, fact or rule, call `remember`. Don't store one-off questions or figures that change."
|
||||
- Memory drawer: two sections, **Mine** and **Shared with everyone**, each with delete buttons, and an input to add one manually with a "Share with everyone" tick.
|
||||
|
||||
- [ ] Tests → implement → browser check ("remember for everyone that The Hills works 45-hour weeks" → shared; as a second user, new thread, "how many hours a week does The Hills work?" → answered from memory; "I prefer tables over charts" stays private to the first user) → commit `feat(ask): long-term memory`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Files — Excel, CSV, PDF, photos, text
|
||||
|
||||
**Files:** Create `src/lib/agent/attachments.ts` (+ tests with fixtures in `src/lib/agent/__fixtures__/`: `workers.xlsx`, `workers.csv`, `text.pdf`, `scanned.pdf`), `src/app/api/chat/attachments/route.ts`; tools `read_attachment`, `send_to_po_pipeline`; Modify `chat-view.tsx` (attachment chips; accept `.xlsx,.xls,.csv,.pdf,.png,.jpg,.jpeg,.webp,.txt`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
```ts
|
||||
export type ParsedAttachment =
|
||||
| { kind: "sheet"; sheets: { name: string; columns: string[]; rows: Record<string, string | number | null>[]; rowCount: number }[] }
|
||||
| { kind: "pdf"; pages: number; text: string; scanned: boolean } // scanned = no text layer
|
||||
| { kind: "image"; width?: number; height?: number }
|
||||
| { kind: "text"; text: string };
|
||||
|
||||
export async function parseAttachment(bytes: Uint8Array, fileName: string, mime: string): Promise<ParsedAttachment>;
|
||||
export function previewForModel(p: ParsedAttachment, maxRows = 200, maxChars = 20000): unknown; // what read_attachment returns
|
||||
```
|
||||
|
||||
- Sheets: SheetJS `read` → first row as header (trim, de-duplicate blank headers as `column_N`) → rows. CSV via PapaParse with `header: true`.
|
||||
- PDF: `pdftotext -layout`; if the text is under 50 characters per page, it's `scanned: true`. Then render pages with the existing splitter (`src/lib/dn-pipeline/split.ts`) and pass up to 5 page PNGs to the model as images on the next turn.
|
||||
- Images go to the model as images (vision already works, MiniMax-M3).
|
||||
- `read_attachment({ id, sheet?, offset? })` pages through large sheets 200 rows at a time.
|
||||
- `POST /api/chat/attachments` (multipart: `threadId?`, `file`; `x-ask-user` header) creates the thread if needed, then **creates** an `ask_messages` row `{ kind: "attachment", file, text: summary, parsed, parse_error? }`. It returns `{ threadId, attachmentId: <row id>, kind, summary }` (e.g. "Sheet 'Workers', 43 rows, columns: Name, ID, Phone").
|
||||
- **PDFs no longer go straight to the PO pipeline.** Ask reads them, and if one is a purchase order it calls `send_to_po_pipeline({ id })`, which posts the stored file to the existing `/api/purchase-orders/upload` logic and returns the batch id (the existing upload card keeps working). Add to the prompt: "If a PDF or photo is a purchase order or delivery note, offer to send it to the pipeline."
|
||||
|
||||
- [ ] Tests per kind (including a scanned PDF) → implement → browser check: drop `workers.xlsx` → "what's in this?" → Ask lists columns and row count → commit `feat(ask): read Excel, CSV, PDF, photos`.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `query_records` + virtual datasets + `make_chart` (measure anything)
|
||||
|
||||
**Files:** Create `src/lib/agent/query.ts`, `src/lib/agent/datasets.ts` (+ tests); tools in `tools-actions.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
```ts
|
||||
export const QuerySpec = z.object({
|
||||
source: z.string(), // a PB collection or a virtual dataset name
|
||||
where: z.array(z.object({
|
||||
field: z.string(),
|
||||
op: z.enum(["=", "!=", ">", ">=", "<", "<=", "~", "in", "empty", "not_empty"]),
|
||||
value: z.union([z.string(), z.number(), z.boolean(), z.array(z.union([z.string(), z.number()]))]).optional(),
|
||||
})).default([]),
|
||||
site: z.string().optional(), // resolved through resolveSite
|
||||
dateField: z.string().optional(),
|
||||
from: z.string().optional(), // YYYY-MM-DD
|
||||
to: z.string().optional(),
|
||||
bucket: z.enum(["day", "week", "month"]).optional(), // requires dateField
|
||||
groupBy: z.array(z.string()).default([]),
|
||||
aggregates: z.array(z.object({
|
||||
fn: z.enum(["count", "sum", "avg", "min", "max", "count_distinct"]),
|
||||
field: z.string().optional(), // not needed for count
|
||||
as: z.string().regex(/^[a-z][a-z0-9_]{0,40}$/),
|
||||
format: z.enum(["money", "number", "percent", "litres", "hours"]).default("number"),
|
||||
})).default([]),
|
||||
sort: z.array(z.object({ by: z.string(), dir: z.enum(["asc", "desc"]) })).default([]),
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
expand: z.array(z.string()).max(3).default([]),
|
||||
});
|
||||
|
||||
export interface QueryResult {
|
||||
resultId: string; // "q-<8 hex>"
|
||||
source: string;
|
||||
columns: { name: string; format?: string }[];
|
||||
rowCount: number; // rows matched before limit
|
||||
rows: Record<string, unknown>[]; // at most 50 go to the model; all (≤ limit) cached
|
||||
totals?: Record<string, { value: number; display: string }>; // aggregates across ALL matched rows
|
||||
truncated: boolean;
|
||||
links?: { label: string; href: string }[];
|
||||
}
|
||||
|
||||
export async function runQuery(spec: z.infer<typeof QuerySpec>): Promise<QueryResult>;
|
||||
export function getCachedResult(id: string): QueryResult | undefined; // in-memory, 2 h TTL, 200 entries LRU
|
||||
```
|
||||
|
||||
- PB collections: build the filter with `pb.filter("field >= {:p0} && ...", { p0: value })`. Field names must exist in the live schema (Task 6's `schema-cache.ts`; build that module first in this task and reuse it later). An unknown field returns an error listing the valid ones.
|
||||
- Load with `getFullList` capped at 20 000 rows (error above that: "narrow the date range"). Group, bucket and aggregate in JS. `display` strings use `formatMoney` / `formatNumber` / `formatPercent` / `formatDecimal`.
|
||||
- **Virtual datasets** (in `datasets.ts`, each a function returning rows built from existing metrics):
|
||||
|
||||
| name | rows | built from |
|
||||
| --- | --- | --- |
|
||||
| `site_summary` | one per site: contract, spent, burn %, elapsed %, projected total/profit, status | `computeSiteSummaries` |
|
||||
| `fuel_log_status` | one per fuel log: vehicle, site, date, litres, cost, status, variance, limit unit | `computeFuelLogStatuses` + fuel prices |
|
||||
| `plant_hours` | one per accepted reading pair: vehicle, site, date, hours, value, rejected | `hoursBetweenReadings` |
|
||||
| `payroll_hours` | one per timesheet entry with rate and cost | timesheets + employee rates |
|
||||
| `attendance` | one per worker per day: in, out, hours, needs attention | `src/lib/attendance.ts` |
|
||||
| `supplier_balance` | one per supplier: ordered, delivered, outstanding | `src/lib/metrics/supplier-balance.ts` |
|
||||
| `po_fulfilment` | one per PO line: ordered, received, % | the Purchase Orders page's helper |
|
||||
|
||||
- `make_chart({ resultId, kind: "bar"|"line"|"area"|"donut", x, series: [{ column, label }], title, subtitle?, stacked? })` → looks the result up, checks the columns exist, and returns `{ charts: [ChartSpec] }` (id `c-<resultId>-<n>`). `extractCharts` streams it; the model never passes numbers.
|
||||
- Prompt addition: "For any question the other tools don't answer directly, use `query_records` (prefer virtual datasets for money, fuel, plant hours and payroll). Chart with `make_chart`. Quote `totals[*].display`; never compute."
|
||||
|
||||
- [ ] **Tests** (with a mocked PB): the filter binding escapes quotes (`O'Brien` stays inside the parameter); an unknown field gets a helpful error; week buckets use Monday starts; `sum` / `avg` / `count_distinct` are correct; `limit` truncates rows but not totals; `make_chart` rejects unknown columns; virtual datasets match their dashboard page on a fixture (e.g. `plant_hours` totals equal the Equipment page aggregate).
|
||||
- [ ] **Live check:** "Show me litres per vehicle per week on The Hills since 1 August as a chart" → one `query_records` + one `make_chart`, and the chart renders. Commit — `feat(ask): free queries and charts`.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: The gateway — propose, confirm, apply, undo
|
||||
|
||||
**Files:** Create `src/lib/agent/context.ts`, `src/lib/agent/schema-cache.ts`, `src/lib/agent/gateway.ts` (+ tests), `src/app/api/agent/actions/[id]/{apply,reject,undo}/route.ts` (+ tests); tool `propose_changes`; Modify `src/app/api/chat/route.ts` (stream `action` events), chat UI (`action-card.tsx`)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
```ts
|
||||
// context.ts
|
||||
export interface AgentContext {
|
||||
actor: { id: string; name: string }; // a `users` record, from the picker (x-ask-user) until logins
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
// gateway.ts
|
||||
export type AgentOp =
|
||||
| { kind: "create"; collection: string; data: Record<string, unknown>; ref?: string } // ref lets later ops point at a new record: "$ref:w1"
|
||||
| { kind: "update"; collection: string; id: string; data: Record<string, unknown> }
|
||||
| { kind: "delete"; collection: string; id: string };
|
||||
|
||||
export const NEVER_WRITABLE: ReadonlySet<string>; // Global Constraints list
|
||||
|
||||
/** THE permission seam. Today: everything writable except NEVER_WRITABLE. */
|
||||
export function authorize(ctx: AgentContext, op: AgentOp): { ok: true } | { ok: false; reason: string };
|
||||
|
||||
export interface PlannedAction {
|
||||
summary: string; // model-written, one line
|
||||
ops: AgentOp[];
|
||||
preview: { collection: string; kind: AgentOp["kind"]; id?: string; before?: Record<string, unknown>; after: Record<string, unknown> }[];
|
||||
problems: string[]; // validation errors; non-empty blocks Confirm
|
||||
}
|
||||
export async function planOps(ctx: AgentContext, summary: string, ops: AgentOp[]): Promise<PlannedAction>;
|
||||
export async function createPendingAction(ctx: AgentContext, plan: PlannedAction): Promise<string>; // ask_log id (type "change")
|
||||
export async function applyAction(ctx: AgentContext, actionId: string): Promise<{ ok: true; created: Record<string, string> } | { ok: false; error: string }>;
|
||||
export async function undoAction(ctx: AgentContext, actionId: string): Promise<{ ok: true } | { ok: false; error: string }>;
|
||||
|
||||
// schema-cache.ts
|
||||
export async function getCollectionSchema(name: string): Promise<{ id: string; name: string; fields: PbField[] } | null>;
|
||||
export function validateOp(schema, op: AgentOp): string[]; // pure
|
||||
```
|
||||
|
||||
Rules (one test each):
|
||||
- `validateOp`: unknown fields are rejected; number, bool, date (YYYY-MM-DD or ISO) and select values are type-checked; creates enforce required fields; relation ids must exist; for `sites` and `employees`, a duplicate `project_code` / `employee_no` in the same batch or already in PB is a problem.
|
||||
- `planOps` loads the `before` for every update and delete. It resolves `$ref:` so a create can be linked by later ops in the same action. Ops per action are capped at 500.
|
||||
- `applyAction` runs only on `pending`, sends in batches of ≤ 50 (production PB `batch.maxRequests = 50`), and sets `applied`, `after` and `applied_at`. On failure it sets `failed` with the error. Ops already applied in earlier chunks are recorded in `after`, so Undo still works.
|
||||
- `undoAction` runs only on `applied`: it deletes created records, restores updated fields from `before`, and recreates deleted records with the same id. Then it marks the action `undone`. Undoing twice is a no-op error.
|
||||
- `propose_changes({ summary, ops })` → `planOps` → pending action. The tool result tells the model `{ actionId, status: "awaiting_confirmation", problems }` and says to **stop and wait**. The route streams `event: action` with the full plan.
|
||||
- **The next user turn** gets a system note injected before it: `Action <id> "<summary>" was applied|rejected|undone|failed: <error>`, so the model knows what happened.
|
||||
- **Action card UI:** the summary line, a table of rows (created in green, updated fields showing old → new, deleted in red), any problems, then **Confirm** / **Cancel**. After Confirm the card shows "Applied · Undo". It is persisted via `action_ids` on the assistant message, so it shows again when the thread is reopened.
|
||||
- Every `ask_log` change row stores `owner` = the actor who asked. Apply, reject and undo also require `x-ask-user` and record who pressed the button in `after.confirmed_by` / `after.undone_by` (in the test phase anyone may confirm any pending action; the card shows who asked).
|
||||
- Apply, reject and undo routes are **same-origin only** (`Origin` must equal the request's origin). No other auth yet — the actor is `pin`.
|
||||
|
||||
- [ ] **Step 1:** Write the tests listed above (mock PB `createBatch`, `getOne`, `getFullList`, collection schema).
|
||||
- [ ] **Step 2:** Implement `schema-cache` → `gateway` → routes → tool → UI.
|
||||
- [ ] **Step 3: Live check, on test records only.** "Add a fixed cost 'Ask test' of R 1 on Cedar Gate dated today" → card → Confirm → it shows on the site page → **Undo** → it's gone. Commit — `feat(ask): propose, confirm, apply and undo changes`.
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Load workers from any list (the headline use-case)
|
||||
|
||||
No new plumbing — this is prompt and tool polish on top of Tasks 4–6, plus one helper.
|
||||
|
||||
**Files:** Create `src/lib/agent/people-match.ts` (+ test); Modify the system prompt
|
||||
|
||||
- `matchPeople(incoming: { name: string; employeeNo?: string; idNumber?: string }[], existing: EmployeeRecord[])` returns `{ incoming; match: EmployeeRecord | null; confidence: "exact" | "likely" | "none" }[]`. It matches on employee number first, then on normalised full name (case, spacing and accents), then on surname plus first initial as "likely". Pure and tested, including Afrikaans/Zulu names with spaces or hyphens.
|
||||
- Tool `match_workers({ attachmentId? , names?: string[], site })` wraps it and returns the table.
|
||||
- Prompt recipe:
|
||||
1. `read_attachment`, or use the typed names.
|
||||
2. `match_workers`.
|
||||
3. Ask only for the missing required fields, and default `worker_type` from the user's words.
|
||||
4. `propose_changes` with creates for the new workers and updates (site change) for existing ones.
|
||||
5. Never create a worker who already matches exactly.
|
||||
- [ ] Tests → implement → live check with a 5-row test spreadsheet (names prefixed "TEST ") → Confirm → Undo. Commit — `feat(ask): load workers from lists, sheets, PDFs and photos`.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Download answer as PDF
|
||||
|
||||
**Files:** Create `src/app/dashboard/chat/answer-pdf.ts` (+ test for the page-slicing maths); Modify the answer toolbar in `chat-view.tsx`; `npm i html-to-image`
|
||||
|
||||
- `answerToPdf(el: HTMLElement, meta: { title: string; date: string }): Promise<Blob>`:
|
||||
- Clone the answer at a fixed 794 px width (A4 at 96 dpi), in light theme.
|
||||
- Render it with `html-to-image` `toPng` at pixelRatio 2.
|
||||
- Slice it into A4-height pages, **cutting only between blocks**: collect the `offsetTop` of top-level children and never cut through a chart or table row.
|
||||
- Embed each page with `pdf-lib`, then add a header ("MEGA INFRASTRUCTURE · Ask report · {date}") and a footer ("Page n of N").
|
||||
- `slicePages(blockTops: number[], blockHeights: number[], pageHeight: number): number[]` is the pure part — test it.
|
||||
- Button: **PDF**, next to Print and CSV. File name: `ask-<thread-title-slug>-<YYYY-MM-DD>.pdf`.
|
||||
- [ ] Browser check: `/site The Hills` → PDF → open: charts are crisp and none is cut in half. Commit — `feat(ask): download answers as PDF`.
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Images (MiniMax `image-01`)
|
||||
|
||||
**Files:** Create `src/lib/agent/images.ts` (+ test with mocked fetch); tool `generate_image`; token `[[image:<id>]]` rendered in `answer-markdown.tsx` (same splitting as charts)
|
||||
|
||||
- `POST ${MINIMAX_BASE_URL}/image_generation` with `{ model: "image-01", prompt, aspect_ratio: "16:9"|"1:1"|"4:3"|"3:4", n: 1, response_format: "base64" }`. **Check the response field names against the MiniMax docs or the installed `mmx-cli` SDK before coding** (`/Volumes/KIOXIA/.npm-global/lib/node_modules/mmx-cli/dist/sdk.mjs` builds this request). Store the image as a new `ask_messages` row `{ kind: "image", file, text: prompt }` and return `{ images: [{ id: <row id>, url: "/api/pb-files/ask_messages/<row id>/<file>" }] }`. The route streams `event: image` the same way charts are streamed.
|
||||
- The MiniMax CLI notes that `image-01` needs the **Plus plan or above**. If the key's plan doesn't include it, return a clear error ("Image generation isn't enabled on this MiniMax plan"), and do not retry.
|
||||
- Prompt rule: "Use generate_image only for illustrations (report covers, mock-ups, diagrams without figures). Never draw charts, numbers, or anything that looks like data."
|
||||
- [ ] Tests → implement → live check with one image → commit `feat(ask): generate illustrations`.
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Change requests ("can we add this to the app?")
|
||||
|
||||
**Files:** tool `file_change_request({ title, detail })` → `ask_log` `{ type: "request", status: "open", text: title, detail }`; Create `src/app/dashboard/change-requests/page.tsx` (list + status select); Modify `src/mcp/server.ts` (`list_change_requests`, read-only), sidebar (under Ask)
|
||||
|
||||
- Prompt: "When the user asks to change **the app itself** — a new page, column, button or report layout on the dashboard — you can't do that. File it with `file_change_request` and say it was logged for the developer. Changing **data** is different: use `propose_changes`."
|
||||
- [ ] Tests → implement → commit `feat(ask): log app change requests for the developer`.
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Talk to it like an assistant — prompt and loop
|
||||
|
||||
**Files:** Modify `src/lib/chat/system-prompt.ts`, `src/lib/chat/loop.ts`, `src/app/api/chat/route.ts`
|
||||
|
||||
- Raise `maxIterations` from 8 to 16 — "read file → match → propose" and free reports need more steps. Keep the "stopped on limit" notice.
|
||||
- Replace the prompt's opening. Ask is now "the operations assistant for MEGA INFRASTRUCTURE. You can read everything in the app, measure anything with query_records, draw charts with make_chart, read files the user attaches, remember lasting facts, and change data by proposing actions the user confirms."
|
||||
- Add a **"How to work"** section:
|
||||
1. Work out what's being asked.
|
||||
2. Gather data with the fewest tool calls.
|
||||
3. For "change this", propose the action.
|
||||
4. For "report on this", lead with the answer, then charts, then detail.
|
||||
5. Ask one short question when something is genuinely ambiguous (which site, which month), otherwise pick a sensible default and say which.
|
||||
- Keep the arithmetic ban, the chart grammar and the glossary unchanged.
|
||||
- [ ] Tests assert the new sections exist and the ban is intact. Commit — `feat(ask): assistant-style prompt and longer tool loop`.
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Model choice (optional, recommended)
|
||||
|
||||
**Files:** Modify `src/lib/chat/provider-anthropic.ts` (model id), `docs/operations.md`
|
||||
|
||||
- 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`.
|
||||
|
||||
---
|
||||
|
||||
### 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`.
|
||||
|
||||
---
|
||||
|
||||
## When logins land (not in this plan)
|
||||
|
||||
- `AgentContext.actor` = the signed-in user instead of the picker. The `owner` relations to `users` already exist; the server stops trusting `x-ask-user` and the picker is removed.
|
||||
- `authorize()` checks `can(user, …)` and `op` site ∈ user's sites. That's the single place to restrict it, as the user asked: "later on we can restrict his API calls".
|
||||
- `query_records` and datasets are filtered by the user's sites (same as the Ask reports plan, Task 11).
|
||||
- Optional per-role "auto-apply small changes" switch.
|
||||
|
||||
## Self-review
|
||||
|
||||
- Memory + threads → Tasks 2, 3. Files → 4. Load workers → 7. PDF → 8. "Its own will" charts/reports/measure anything → 5, 11. Images → 9. "Change this / add this" → 6 (data) + 10 (app). "Do anything now, restrict later" → gateway `authorize()` (Task 6) + "When logins land".
|
||||
- Types defined before use: `AgentContext`, `AgentOp`, `PlannedAction`, `QuerySpec`, `QueryResult`, `ParsedAttachment`, `ChartSpec` (existing), `matchPeople`.
|
||||
- Human steps are called out: Task 1 schema `--apply`; the MiniMax plan tier for images.
|
||||
Loading…
Reference in a new issue