From b35159d8598c603275578e5e09d2e79901112d7f Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 30 Sep 2026 23:44:55 +0200 Subject: [PATCH] feat(giellie): chat store and giellie_chats migration Co-Authored-By: Claude Sonnet 5.5 --- package.json | 1 + .../2026-09-30-create-giellie-chats.ts | 79 ++++++++++ src/lib/server/giellie-chat-store.test.ts | 133 +++++++++++++++++ src/lib/server/giellie-chat-store.ts | 137 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 scripts/migrations/2026-09-30-create-giellie-chats.ts create mode 100644 src/lib/server/giellie-chat-store.test.ts create mode 100644 src/lib/server/giellie-chat-store.ts diff --git a/package.json b/package.json index 4f7278a..107de61 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "pb:migrate:pos-slip-layouts": "npx tsx scripts/migrations/2026-09-04-create-pos-slip-layouts.ts", "pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.ts", "pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts", + "pb:migrate:giellie-chats": "npx tsx scripts/migrations/2026-09-30-create-giellie-chats.ts", "pb:migrate:place-overrides": "npx tsx scripts/migrations/2026-09-28-create-place-overrides.ts", "pb:migrate:drop-orders-status": "npx tsx scripts/migrations/2026-06-27-drop-orders-status-and-wipe.ts", "pb:migrate:add-order-quote-match": "npx tsx scripts/migrations/2026-06-27-add-order-quote-match-fields.ts", diff --git a/scripts/migrations/2026-09-30-create-giellie-chats.ts b/scripts/migrations/2026-09-30-create-giellie-chats.ts new file mode 100644 index 0000000..6559962 --- /dev/null +++ b/scripts/migrations/2026-09-30-create-giellie-chats.ts @@ -0,0 +1,79 @@ +/** + * One-shot migration: create the `giellie_chats` collection (Giellie agent + * chat history — ONE collection; transcript lives in the `messages` json). + * + * Run with: + * + * npm run pb:migrate:giellie-chats + * + * Reads/writes go through the server's admin client; the API rules below + * only matter for direct PB access: a user can list/view their own chats, + * nobody can create/update/delete via the public API. + * + * Idempotent — exits early if the collection exists. + * Rollback: delete the `giellie_chats` collection in the PB admin UI. + */ +import "./_env-loader"; +import PocketBase from "pocketbase"; + +const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"; +const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; +const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; + +async function main() { + if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local)."); + } + const pb = new PocketBase(PB_URL); + await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + console.log(`[giellie-chats] Authenticated against ${PB_URL}`); + + const all = await pb.collections.getFullList(); + if (all.find((c) => c.name === "giellie_chats")) { + console.log("[giellie-chats] giellie_chats already exists — skipping."); + return; + } + const users = all.find((c) => c.name === "users"); + if (!users) throw new Error("users collection not found"); + + await pb.collections.create({ + name: "giellie_chats", + type: "base", + listRule: "user = @request.auth.id", + viewRule: "user = @request.auth.id", + createRule: null, + updateRule: null, + deleteRule: null, + fields: [ + { + name: "user", + type: "relation", + required: true, + collectionId: users.id, + maxSelect: 1, + cascadeDelete: true, + }, + // Explicit max: PB treats max 0 as a 5000-char cap, not unlimited. + { name: "title", type: "text", required: false, max: 200 }, + { name: "messages", type: "json", required: false, maxSize: 5000000 }, + { name: "pending_action", type: "json", required: false, maxSize: 1000000 }, + { + name: "attachments", + type: "file", + required: false, + maxSelect: 50, + maxSize: 10485760, + mimeTypes: [], + }, + { name: "created", type: "autodate", onCreate: true, onUpdate: false }, + { name: "updated", type: "autodate", onCreate: true, onUpdate: true }, + ], + indexes: ["CREATE INDEX idx_giellie_chats_user ON giellie_chats (user, updated)"], + }); + console.log("[giellie-chats] Created giellie_chats collection."); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/src/lib/server/giellie-chat-store.test.ts b/src/lib/server/giellie-chat-store.test.ts new file mode 100644 index 0000000..a8f1c80 --- /dev/null +++ b/src/lib/server/giellie-chat-store.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const getOne = vi.fn(); +const getFullList = vi.fn(); +const create = vi.fn(); +const update = vi.fn(); +vi.mock("./pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: (name: string) => { + if (name !== "giellie_chats") throw new Error(`unexpected collection ${name}`); + return { getOne, getFullList, create, update }; + }, + }), +})); + +import { + createChat, + listChats, + getChat, + saveChat, + appendAttachments, + compactMessages, +} from "./giellie-chat-store"; + +const ID = "abcde12345fghij"; // 15 chars + +beforeEach(() => { + getOne.mockReset(); + getFullList.mockReset(); + create.mockReset(); + update.mockReset(); +}); + +const row = (over: Record = {}) => ({ + id: ID, + user: "user1", + title: "T", + messages: [{ role: "user", content: [{ type: "text", text: "hi" }], ts: "t" }], + pending_action: null, + attachments: [], + updated: "2026-09-30 10:00:00.000Z", + ...over, +}); + +describe("createChat", () => { + it("creates an empty chat owned by the user", async () => { + create.mockResolvedValue(row({ title: "", messages: [] })); + const chat = await createChat("user1"); + expect(create).toHaveBeenCalledWith({ user: "user1", title: "", messages: [], pending_action: null }); + expect(chat.messages).toEqual([]); + }); +}); + +describe("listChats", () => { + it("filters by user id and sorts newest first", async () => { + getFullList.mockResolvedValue([row()]); + const out = await listChats("user1"); + expect(getFullList).toHaveBeenCalledWith( + expect.objectContaining({ filter: 'user = "user1"', sort: "-updated" }), + ); + expect(out).toEqual([{ id: ID, title: "T", updated: "2026-09-30 10:00:00.000Z" }]); + }); + it("rejects a malformed user id (filter injection)", async () => { + await expect(listChats('x" || 1=1 || "')).rejects.toMatchObject({ status: 404 }); + }); +}); + +describe("getChat", () => { + it("returns the chat for its owner", async () => { + getOne.mockResolvedValue(row()); + const chat = await getChat(ID, "user1"); + expect(chat.id).toBe(ID); + expect(chat.messages).toHaveLength(1); + }); + it("404s for a different user", async () => { + getOne.mockResolvedValue(row({ user: "someoneelse" })); + await expect(getChat(ID, "user1")).rejects.toMatchObject({ status: 404 }); + }); + it("404s when PB cannot find it or the id is malformed", async () => { + getOne.mockRejectedValue(new Error("not found")); + await expect(getChat(ID, "user1")).rejects.toMatchObject({ status: 404 }); + await expect(getChat("../x", "user1")).rejects.toMatchObject({ status: 404 }); + }); + it("tolerates null json fields", async () => { + getOne.mockResolvedValue(row({ messages: null, pending_action: "" })); + const chat = await getChat(ID, "user1"); + expect(chat.messages).toEqual([]); + expect(chat.pending_action).toBeNull(); + }); +}); + +describe("compactMessages", () => { + it("truncates oversized tool results without mutating the input", () => { + const big = "y".repeat(20_000); + const input = [ + { role: "user" as const, ts: "t", content: [{ type: "tool_result", tool_use_id: "a", content: big }] }, + ]; + const out = compactMessages(input); + expect((out[0].content[0].content as string).length).toBeLessThan(9_000); + expect(String(out[0].content[0].content)).toContain("truncated"); + expect(input[0].content[0].content).toBe(big); + }); +}); + +describe("saveChat", () => { + it("writes compacted messages and pending_action", async () => { + update.mockResolvedValue({}); + await saveChat(ID, { title: "New", messages: [], pending_action: null }); + expect(update).toHaveBeenCalledWith(ID, { title: "New", messages: [], pending_action: null }); + }); + it("omits undefined patch keys", async () => { + update.mockResolvedValue({}); + await saveChat(ID, { title: "Only" }); + expect(update).toHaveBeenCalledWith(ID, { title: "Only" }); + }); +}); + +describe("appendAttachments", () => { + it("uploads using the PB append modifier", async () => { + update.mockResolvedValue({}); + await appendAttachments(ID, [ + { filename: "a.jpg", contentType: "image/jpeg", bytes: Buffer.from("x") }, + ]); + const [id, body] = update.mock.calls[0]; + expect(id).toBe(ID); + expect(body).toBeInstanceOf(FormData); + expect((body as FormData).getAll("attachments+")).toHaveLength(1); + }); + it("does nothing for an empty list", async () => { + await appendAttachments(ID, []); + expect(update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/server/giellie-chat-store.ts b/src/lib/server/giellie-chat-store.ts new file mode 100644 index 0000000..66c55f5 --- /dev/null +++ b/src/lib/server/giellie-chat-store.ts @@ -0,0 +1,137 @@ +/** + * Persistence for Giellie chats — ONE PB collection, `giellie_chats` + * (see docs/superpowers/specs/2026-09-30-giellie-agent-design.md). + * + * All access goes through the admin client; ownership is enforced here + * (`user` must equal the verified session user) because the PB API rules + * are never exercised by these server calls. + */ +import { getAdminPocketBase } from "./pocketbase-admin"; +import type { UploadedFile } from "./giellie-attachments"; +import type { + GielieChat, + GielieChatSummary, + PendingAction, + StoredMessage, +} from "@/lib/giellie-types"; + +const COLLECTION = "giellie_chats"; +const ID_RE = /^[a-z0-9]{15}$/i; +// User ids are interpolated into a PB filter string, so restrict them to a +// safe alphabet (no quotes / operators). Looser than ID_RE on length only. +const USER_ID_RE = /^[a-z0-9_-]{1,64}$/i; +export const STORED_RESULT_MAX_CHARS = 8_000; + +function notFound(): Error & { status: number } { + return Object.assign(new Error("Chat not found."), { status: 404 }); +} +function assertId(id: string): void { + if (!ID_RE.test(id)) throw notFound(); +} +function assertUserId(id: string): void { + if (!USER_ID_RE.test(id)) throw notFound(); +} + +interface Row { + id: string; + user: string; + title?: string; + messages?: unknown; + pending_action?: unknown; + attachments?: unknown; + updated?: string; +} + +function toChat(row: Row): GielieChat { + return { + id: row.id, + title: row.title ?? "", + updated: row.updated ?? "", + messages: Array.isArray(row.messages) ? (row.messages as StoredMessage[]) : [], + pending_action: + row.pending_action && typeof row.pending_action === "object" + ? (row.pending_action as PendingAction) + : null, + attachments: Array.isArray(row.attachments) ? (row.attachments as string[]) : [], + }; +} + +/** Pure: shorten huge tool results for storage. Never mutates the input. */ +export function compactMessages(messages: StoredMessage[]): StoredMessage[] { + return messages.map((m) => ({ + ...m, + content: m.content.map((b) => { + if ( + b.type === "tool_result" && + typeof b.content === "string" && + b.content.length > STORED_RESULT_MAX_CHARS + ) { + return { + ...b, + content: + b.content.slice(0, STORED_RESULT_MAX_CHARS) + + `\n[... truncated for storage: ${b.content.length} characters; re-run the query if you need the rest ...]`, + }; + } + return b; + }), + })); +} + +export async function createChat(userId: string): Promise { + assertUserId(userId); + const pb = await getAdminPocketBase(); + const row = (await pb + .collection(COLLECTION) + .create({ user: userId, title: "", messages: [], pending_action: null })) as unknown as Row; + return toChat(row); +} + +export async function listChats(userId: string): Promise { + assertUserId(userId); + const pb = await getAdminPocketBase(); + const rows = (await pb.collection(COLLECTION).getFullList({ + filter: `user = "${userId}"`, + sort: "-updated", + fields: "id,title,updated", + })) as unknown as Row[]; + return rows.map((r) => ({ id: r.id, title: r.title ?? "", updated: r.updated ?? "" })); +} + +export async function getChat(id: string, userId: string): Promise { + assertId(id); + const pb = await getAdminPocketBase(); + let row: Row; + try { + row = (await pb.collection(COLLECTION).getOne(id)) as unknown as Row; + } catch { + throw notFound(); + } + if (row.user !== userId) throw notFound(); + return toChat(row); +} + +export async function saveChat( + id: string, + patch: { title?: string; messages?: StoredMessage[]; pending_action?: PendingAction | null }, +): Promise { + assertId(id); + const body: Record = {}; + if (patch.title !== undefined) body.title = patch.title; + if (patch.messages !== undefined) body.messages = compactMessages(patch.messages); + if (patch.pending_action !== undefined) body.pending_action = patch.pending_action; + const pb = await getAdminPocketBase(); + await pb.collection(COLLECTION).update(id, body); +} + +/** Uploads originals to the multi-file `attachments` field (append modifier). */ +export async function appendAttachments(id: string, files: UploadedFile[]): Promise { + assertId(id); + if (files.length === 0) return; + const fd = new FormData(); + for (const f of files) { + fd.append("attachments+", new Blob([new Uint8Array(f.bytes)], { type: f.contentType }), f.filename); + } + const pb = await getAdminPocketBase(); + await pb.collection(COLLECTION).update(id, fd); +}