feat(giellie): chat store and giellie_chats migration
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8500a08482
commit
b35159d859
4 changed files with 350 additions and 0 deletions
|
|
@ -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",
|
||||
|
|
|
|||
79
scripts/migrations/2026-09-30-create-giellie-chats.ts
Normal file
79
scripts/migrations/2026-09-30-create-giellie-chats.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
133
src/lib/server/giellie-chat-store.test.ts
Normal file
133
src/lib/server/giellie-chat-store.test.ts
Normal file
|
|
@ -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<string, unknown> = {}) => ({
|
||||
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();
|
||||
});
|
||||
});
|
||||
137
src/lib/server/giellie-chat-store.ts
Normal file
137
src/lib/server/giellie-chat-store.ts
Normal file
|
|
@ -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<GielieChat> {
|
||||
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<GielieChatSummary[]> {
|
||||
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<GielieChat> {
|
||||
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<void> {
|
||||
assertId(id);
|
||||
const body: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in a new issue