Something went wrong. Try again.
A local-first event pipeline for independent agents, built on Jazz.
Something went wrong. Try again.
10 kB · 259 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260import { z } from "zod";import { sha256, type JsonObject } from "../core/json.js";
/** * Working-document workbench contracts. * * A working document is an operator-owned editable document that lives in the * shared `documentVersions`/`documents` tables under its own source namespace. * It is a separate kind from observed filesystem versions: editing it never * touches a file on disk. See `spec/documents.md`. */
export const WORKBENCH_DOCUMENT_SOURCE = "workbench:documents";export const WORKBENCH_CONTEXT_SOURCE = "context:workbench";export const WORKBENCH_DOCUMENT_CONTENT_TYPE = "text/markdown";export const WORKBENCH_SCHEMA_VERSION = 1;export const WORKBENCH_RUNTIME = "thoughtstream-workbench-v1";
export const DOCUMENT_CREATED_EVENT_TYPE = "stream.thought.workbench.document.created";export const DOCUMENT_VERSION_EVENT_TYPE = "stream.thought.workbench.document.version";export const CONTEXT_SELECTED_EVENT_TYPE = "stream.thought.workbench.context.selected";export const PROPOSAL_REQUESTED_EVENT_TYPE = "stream.thought.workbench.proposal.requested";export const PROPOSAL_PROPOSED_EVENT_TYPE = "stream.thought.workbench.proposal.proposed";export const PROPOSAL_DECISION_EVENT_TYPE = "stream.thought.workbench.proposal.decision";
export const WORKBENCH_EVENT_TYPES = [ DOCUMENT_CREATED_EVENT_TYPE, DOCUMENT_VERSION_EVENT_TYPE, CONTEXT_SELECTED_EVENT_TYPE, PROPOSAL_REQUESTED_EVENT_TYPE, PROPOSAL_PROPOSED_EVENT_TYPE, PROPOSAL_DECISION_EVENT_TYPE,] as const;
export const WORKBENCH_JUDGMENT_CRITERION = "workbench-document-proposal";export const WORKBENCH_JUDGMENT_CRITERION_VERSION = 1;
export const MAX_DOCUMENT_TITLE_CHARS = 200;export const MAX_DOCUMENT_BODY_CHARS = 64_000;export const MAX_PROPOSED_TEXT_CHARS = MAX_DOCUMENT_BODY_CHARS;export const MAX_SELECTED_EVENTS = 32;export const MAX_SELECTED_VERSIONS = 16;export const MAX_EXCERPT_CHARS = 2_000;export const MAX_EVIDENCE_IDS = 16;
const idSchema = z.string().min(1).max(500);const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);const requestIdSchema = z.string().regex(/^[A-Za-z0-9_-]{8,128}$/);const uniqueIds = (max: number) => z.array(idSchema).max(max).superRefine((ids, context) => { if (new Set(ids).size !== ids.length) context.addIssue({ code: "custom", message: "Ids must be unique" });});
export const VERSION_REASONS = ["created", "operator-edit", "proposal-accepted"] as const;export type VersionReason = typeof VERSION_REASONS[number];
export const documentCreatedPayloadSchema = z.object({ documentId: idSchema, title: z.string().min(1).max(MAX_DOCUMENT_TITLE_CHARS), originEventId: idSchema, versionId: idSchema, sha256: sha256Schema, requestId: requestIdSchema,}).strict();
export const documentVersionPayloadSchema = z.object({ documentId: idSchema, versionId: idSchema, baseVersionId: idSchema.nullable(), sha256: sha256Schema, sizeBytes: z.number().int().nonnegative(), reason: z.enum(VERSION_REASONS), proposalEventId: idSchema.optional(), decisionEventId: idSchema.optional(), requestId: requestIdSchema,}).strict().superRefine((value, context) => { if ((value.reason === "proposal-accepted") !== (value.proposalEventId !== undefined && value.decisionEventId !== undefined)) { context.addIssue({ code: "custom", path: ["reason"], message: "Only proposal-accepted versions carry proposal and decision references" }); }});
export const contextSelectedPayloadSchema = z.object({ documentId: idSchema, selectionId: idSchema, snapshotVersionId: idSchema, selectedEventIds: uniqueIds(MAX_SELECTED_EVENTS), selectedVersionIds: uniqueIds(MAX_SELECTED_VERSIONS), requestId: requestIdSchema,}).strict();
export const proposalRequestedPayloadSchema = z.object({ documentId: idSchema, baseVersionId: idSchema, baseSha256: sha256Schema, selectionId: idSchema, snapshotVersionId: idSchema, runnerId: z.string().min(1).max(200), requestId: requestIdSchema,}).strict();
const proposerSchema = z.object({ runnerId: z.string().min(1).max(200), runnerRevision: z.string().min(1).max(200), requestId: requestIdSchema, runId: idSchema.optional(), contextSnapshotId: idSchema,}).strict();
const proposalTargetSchema = z.object({ documentId: idSchema, baseVersionId: idSchema, baseSha256: sha256Schema,}).strict();
export const proposalProposedPayloadSchema = z.object({ proposalState: z.literal("runner-proposed"), proposer: proposerSchema, target: proposalTargetSchema, operation: z.literal("replace-document"), proposedText: z.string().min(1).max(MAX_PROPOSED_TEXT_CHARS), proposedTextChars: z.number().int().positive().max(MAX_PROPOSED_TEXT_CHARS), proposedTextSha256: sha256Schema, reason: z.string().min(1).max(1_000), evidenceEventIds: uniqueIds(MAX_EVIDENCE_IDS), publicationEligible: z.literal(false),}).strict().superRefine((value, context) => { if (value.proposedText.length !== value.proposedTextChars) { context.addIssue({ code: "custom", path: ["proposedTextChars"], message: "Proposal text length does not match" }); } if (sha256(value.proposedText) !== value.proposedTextSha256) { context.addIssue({ code: "custom", path: ["proposedTextSha256"], message: "Proposal text hash does not match" }); }});
export const proposalDecisionPayloadSchema = z.object({ proposalEventId: idSchema, disposition: z.enum(["accept", "reject"]), submissionId: requestIdSchema, authority: z.literal("human"), resultVersionId: idSchema.optional(),}).strict().superRefine((value, context) => { if ((value.disposition === "accept") !== (value.resultVersionId !== undefined)) { context.addIssue({ code: "custom", path: ["resultVersionId"], message: "Only accepted decisions name a result version" }); }});
export type DocumentCreatedPayload = z.infer<typeof documentCreatedPayloadSchema>;export type DocumentVersionPayload = z.infer<typeof documentVersionPayloadSchema>;export type ContextSelectedPayload = z.infer<typeof contextSelectedPayloadSchema>;export type ProposalRequestedPayload = z.infer<typeof proposalRequestedPayloadSchema>;export type ProposalProposedPayload = z.infer<typeof proposalProposedPayloadSchema>;export type WorkbenchProposalDecisionPayload = z.infer<typeof proposalDecisionPayloadSchema>;
/** Browser/API request bodies. Every mutation carries a client-generated request or submission id. */export const createDocumentRequestSchema = z.object({ originEventId: idSchema, title: z.string().min(1).max(MAX_DOCUMENT_TITLE_CHARS), body: z.string().max(MAX_DOCUMENT_BODY_CHARS), requestId: requestIdSchema,}).strict();
export const saveEditRequestSchema = z.object({ baseVersionId: idSchema, title: z.string().min(1).max(MAX_DOCUMENT_TITLE_CHARS), body: z.string().max(MAX_DOCUMENT_BODY_CHARS), requestId: requestIdSchema,}).strict();
export const selectContextRequestSchema = z.object({ eventIds: uniqueIds(MAX_SELECTED_EVENTS), versionIds: uniqueIds(MAX_SELECTED_VERSIONS), requestId: requestIdSchema,}).strict();
export const requestProposalRequestSchema = z.object({ selectionId: idSchema, runnerId: z.string().min(1).max(200), requestId: requestIdSchema,}).strict();
export const decideProposalRequestSchema = z.object({ disposition: z.enum(["accept", "reject"]), submissionId: requestIdSchema,}).strict();
/** Snapshot content stored as one immutable JSON document version under `context:workbench`. */export interface WorkbenchContextSnapshot { documentId: string; baseVersionId: string; selectedEvents: Array<{ eventId: string; type: string; source: string; occurredAt: string; payloadHash: string; excerpt: string; }>; selectedVersions: Array<{ documentId: string; versionId: string; sha256: string; path: string; }>;}
export const workbenchContextSnapshotSchema = z.object({ documentId: idSchema, baseVersionId: idSchema, selectedEvents: z.array(z.object({ eventId: idSchema, type: z.string().min(1), source: z.string().min(1), occurredAt: z.string(), payloadHash: sha256Schema, excerpt: z.string().max(MAX_EXCERPT_CHARS), }).strict()).max(MAX_SELECTED_EVENTS), selectedVersions: z.array(z.object({ documentId: idSchema, versionId: idSchema, sha256: sha256Schema, path: z.string(), }).strict()).max(MAX_SELECTED_VERSIONS),}).strict();
/** * A version's content is self-describing: the title travels in frontmatter so * a version can be read without the mutable head projection. */export function encodeDocumentContent(title: string, body: string): string { return `---\ntitle: ${JSON.stringify(title)}\n---\n${body}`;}
export function decodeDocumentContent(content: string): { title: string; body: string } { const match = content.match(/^---\ntitle: (.*)\n---\n([\s\S]*)$/); if (!match) return { title: "Untitled", body: content }; let title: string; try { const parsed: unknown = JSON.parse(match[1]!); title = typeof parsed === "string" && parsed.length > 0 ? parsed : "Untitled"; } catch { title = "Untitled"; } return { title, body: match[2]! };}
/** Bounded plain-text rendering of an event payload for context snapshots. Never HTML. */export function plainExcerpt(payload: JsonObject, maxChars = MAX_EXCERPT_CHARS): string { const parts: string[] = []; for (const key of ["subject", "title", "text", "body", "summary", "preview", "path"]) { const value = payload[key]; if (typeof value === "string" && value.trim()) parts.push(value.trim()); } const record = payload.record; if (record && typeof record === "object" && !Array.isArray(record) && typeof record.text === "string" && record.text.trim()) { parts.push(record.text.trim()); } const text = parts.length > 0 ? parts.join("\n") : JSON.stringify(payload); const normalized = text.replace(/\r\n?/g, "\n").replace(/[ --]/g, ""); return normalized.length > maxChars ? `${normalized.slice(0, maxChars - 1)}…` : normalized;}