@hachej/boring-agent 0.1.110 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +262 -51
  3. package/dist/agents.d.ts +75 -0
  4. package/dist/agents.js +91 -0
  5. package/dist/approval.d.ts +71 -0
  6. package/dist/approval.js +126 -0
  7. package/dist/artifacts.d.ts +53 -0
  8. package/dist/artifacts.js +129 -0
  9. package/dist/ask-user.d.ts +40 -0
  10. package/dist/ask-user.js +134 -0
  11. package/dist/attachment.d.ts +5 -0
  12. package/dist/attachment.js +4 -0
  13. package/dist/browser-task.d.ts +32 -0
  14. package/dist/browser-task.js +119 -0
  15. package/dist/chat-transport.d.ts +80 -0
  16. package/dist/chat-transport.js +306 -0
  17. package/dist/contracts.d.ts +40 -0
  18. package/dist/contracts.js +1 -0
  19. package/dist/conversations.d.ts +136 -0
  20. package/dist/conversations.js +366 -0
  21. package/dist/definitions.d.ts +37 -0
  22. package/dist/definitions.js +99 -0
  23. package/dist/delivery-input.d.ts +35 -0
  24. package/dist/delivery-input.js +56 -0
  25. package/dist/delivery.d.ts +33 -0
  26. package/dist/delivery.js +154 -0
  27. package/dist/durable-wait.d.ts +23 -0
  28. package/dist/durable-wait.js +79 -0
  29. package/dist/file-convert.d.ts +46 -0
  30. package/dist/file-convert.js +159 -0
  31. package/dist/file-guard.d.ts +50 -0
  32. package/dist/file-guard.js +171 -0
  33. package/dist/file-types.d.ts +17 -0
  34. package/dist/file-types.js +95 -0
  35. package/dist/gateway-provider.d.ts +24 -0
  36. package/dist/gateway-provider.js +42 -0
  37. package/dist/git.d.ts +34 -0
  38. package/dist/git.js +79 -0
  39. package/dist/harness-pool.d.ts +38 -0
  40. package/dist/harness-pool.js +134 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.js +1 -0
  43. package/dist/json-schema-tool.d.ts +25 -0
  44. package/dist/json-schema-tool.js +19 -0
  45. package/dist/memory/optchat-core.d.ts +118 -0
  46. package/dist/memory/optchat-core.js +317 -0
  47. package/dist/memory/optchat.d.ts +156 -0
  48. package/dist/memory/optchat.js +432 -0
  49. package/dist/mentions.d.ts +56 -0
  50. package/dist/mentions.js +140 -0
  51. package/dist/metering.d.ts +183 -0
  52. package/dist/metering.js +443 -0
  53. package/dist/model-gateway.d.ts +86 -0
  54. package/dist/model-gateway.js +241 -0
  55. package/dist/native-version.d.ts +9 -0
  56. package/dist/native-version.js +7 -0
  57. package/dist/native.d.ts +4 -0
  58. package/dist/native.js +1 -0
  59. package/dist/presentation.d.ts +17 -0
  60. package/dist/presentation.js +39 -0
  61. package/dist/projection-format.d.ts +68 -0
  62. package/dist/projection-format.js +118 -0
  63. package/dist/projection-window.d.ts +9 -0
  64. package/dist/projection-window.js +93 -0
  65. package/dist/projection.d.ts +22 -0
  66. package/dist/projection.js +153 -0
  67. package/dist/question-response.d.ts +20 -0
  68. package/dist/question-response.js +75 -0
  69. package/dist/questions.d.ts +74 -0
  70. package/dist/questions.js +199 -0
  71. package/dist/self-evolving.d.ts +55 -0
  72. package/dist/self-evolving.js +239 -0
  73. package/dist/skills.d.ts +15 -0
  74. package/dist/skills.js +51 -0
  75. package/dist/tool-evidence.d.ts +14 -0
  76. package/dist/tool-evidence.js +54 -0
  77. package/dist/validation.d.ts +38 -0
  78. package/dist/validation.js +68 -0
  79. package/dist/workspaces.d.ts +107 -0
  80. package/dist/workspaces.js +212 -0
  81. package/package.json +145 -150
  82. package/dist/DebugDrawer-JNA43SVR.js +0 -8
  83. package/dist/admin-DORVQfLn.d.ts +0 -340
  84. package/dist/chunk-2BGLZZ7T.js +0 -10
  85. package/dist/chunk-42D6KAQV.js +0 -2882
  86. package/dist/chunk-4XOW57KJ.js +0 -343
  87. package/dist/chunk-72KT6VWV.js +0 -1338
  88. package/dist/chunk-7XAOVFSN.js +0 -28
  89. package/dist/chunk-KJIEMSQP.js +0 -198
  90. package/dist/chunk-KN72XWP7.js +0 -394
  91. package/dist/chunk-N6THTH7P.js +0 -110
  92. package/dist/chunk-V3H5A3BR.js +0 -22501
  93. package/dist/chunk-YGRQ4Q5P.js +0 -20
  94. package/dist/chunk-YTUZFKB3.js +0 -78
  95. package/dist/core/index.d.ts +0 -28
  96. package/dist/core/index.js +0 -617
  97. package/dist/createHarness-DOLOHA62.js +0 -18
  98. package/dist/eval/index.d.ts +0 -247
  99. package/dist/eval/index.js +0 -541
  100. package/dist/file-search-BUgMSKZE.d.ts +0 -289
  101. package/dist/front/artifacts.d.ts +0 -34
  102. package/dist/front/artifacts.js +0 -21
  103. package/dist/front/index.d.ts +0 -1082
  104. package/dist/front/index.js +0 -12912
  105. package/dist/front/styles.css +0 -5380
  106. package/dist/harness-qHlMJhZo.d.ts +0 -276
  107. package/dist/middleware-CaAEXpi5.d.ts +0 -115
  108. package/dist/piChatEvent-DpE0lIb9.d.ts +0 -450
  109. package/dist/piChatSessionService-CBct-hmz.d.ts +0 -86
  110. package/dist/sandbox-BMhut3kh.d.ts +0 -317
  111. package/dist/server/agent-host/testing/compositionRouteProof.d.ts +0 -8
  112. package/dist/server/agent-host/testing/compositionRouteProof.js +0 -78
  113. package/dist/server/agent-host/testing/gatewayConformance.d.ts +0 -43
  114. package/dist/server/agent-host/testing/gatewayConformance.js +0 -605
  115. package/dist/server/index.d.ts +0 -2278
  116. package/dist/server/index.js +0 -274
  117. package/dist/server/pi-session-readability.d.ts +0 -12
  118. package/dist/server/pi-session-readability.js +0 -23
  119. package/dist/server/worker/index.d.ts +0 -68
  120. package/dist/server/worker/index.js +0 -377
  121. package/dist/share-entry-CxS6UJ_L.d.ts +0 -330
  122. package/dist/shared/index.d.ts +0 -2111
  123. package/dist/shared/index.js +0 -262
  124. package/dist/types-DcWoQMn8.d.ts +0 -1377
  125. package/docs/ACCESSIBILITY.md +0 -52
  126. package/docs/AGENT_GATEWAY_V0.md +0 -212
  127. package/docs/AGENT_PACKAGES.md +0 -90
  128. package/docs/API.md +0 -123
  129. package/docs/AUTHORING.md +0 -68
  130. package/docs/CSP.md +0 -41
  131. package/docs/ERROR_CODES.md +0 -229
  132. package/docs/KNOWN_LIMITATIONS.md +0 -100
  133. package/docs/MIGRATION.md +0 -50
  134. package/docs/PERFORMANCE.md +0 -68
  135. package/docs/PLUGINS.md +0 -138
  136. package/docs/README.md +0 -98
  137. package/docs/RISKS-MULTI-TAB.md +0 -46
  138. package/docs/SESSION_IDENTITY.md +0 -91
  139. package/docs/STYLING.md +0 -73
  140. package/docs/UI-SHADCN.md +0 -56
  141. package/docs/VERCEL_COSTS.md +0 -103
  142. package/docs/plans/archive/AGENT_EVAL_FRAMEWORK.md +0 -466
  143. package/docs/plans/archive/agent-package-spec.md +0 -1777
  144. package/docs/plans/archive/harness-followup-capabilities.md +0 -440
  145. package/docs/plans/archive/harness-tool-ui-capabilities.md +0 -144
  146. package/docs/plans/archive/pi-followup-history-projection.md +0 -229
  147. package/docs/plans/archive/pi-tools-migration.md +0 -1061
  148. package/docs/plans/archive/reviews/pi-followup-history-codex-review.md +0 -11
  149. package/docs/plans/archive/reviews/pi-followup-history-gpt-review.md +0 -64
  150. package/docs/plans/archive/reviews/pi-followup-history-opus-review.md +0 -43
  151. package/docs/plans/archive/reviews/pi-followup-history-xai-review.md +0 -43
  152. package/docs/plans/archive/vercel-base-snapshot-template-plan.md +0 -527
  153. package/docs/plans/archive/vercel-persistent-sandbox-adapter.md +0 -553
  154. package/docs/runtime-provisioning.md +0 -157
  155. package/docs/runtime.md +0 -296
  156. package/docs/tools.md +0 -102
@@ -0,0 +1,136 @@
1
+ import type { Conversation, ConversationId, ConversationInit, EntryId, Harness } from '@earendil-works/pi-durable';
2
+ import type { Message } from '@earendil-works/pi-ai';
3
+ import type { Context } from '@earendil-works/chord';
4
+ /**
5
+ * What a host's conversation list needs that Pi does not keep: a title, when it was created and last active, a preview of the
6
+ * last message, whether it is archived or deleted, and which host owner it belongs to. One native conversation document per
7
+ * conversation, written through the public document API; there is no second store. A conversation without this document (or
8
+ * with an empty owner, such as a native fork made outside this module) is not managed and never listed.
9
+ */
10
+ export type ConversationMetadata = {
11
+ /** The host's owner key (a person, a team, an agent). Empty: not managed by this module. */
12
+ owner: string;
13
+ /** Set explicitly, or derived once from the first user message; durable afterwards. */
14
+ title: string | null;
15
+ createdAt: number;
16
+ /** Last activity: the last user or assistant message (or creation, rename is not activity). */
17
+ updatedAt: number;
18
+ /** A bounded preview of the last user or assistant message. */
19
+ lastMessage: string | null;
20
+ archived: boolean;
21
+ /** Pi cannot delete a conversation: a deleted one is hidden by this durable mark; its records stay in the session storage. */
22
+ deletedAt: number | null;
23
+ };
24
+ /** The native document. `fork: 'initial'`: a fork made by `fork()` gets its own metadata in the creating commit; any other fork starts unmanaged. */
25
+ export declare const conversationMetadata: import("@earendil-works/pi-durable").ConversationDocToken<ConversationMetadata>;
26
+ /** One row of a conversation list. */
27
+ export interface ConversationSummary {
28
+ readonly id: ConversationId;
29
+ readonly title: string | null;
30
+ readonly lastMessage: string | null;
31
+ readonly createdAt: number;
32
+ readonly updatedAt: number;
33
+ readonly archived: boolean;
34
+ }
35
+ export interface ConversationListQuery {
36
+ readonly owner: string;
37
+ /** `false` (default): active ones; `true`: archived ones; `'all'`: both. Deleted ones never. */
38
+ readonly archived?: boolean | 'all';
39
+ /** Case-insensitive substring of the title or the last message. */
40
+ readonly query?: string;
41
+ /** Default 50, at most 200. */
42
+ readonly limit?: number;
43
+ /** The `next` of the previous page. */
44
+ readonly cursor?: string;
45
+ }
46
+ export interface ConversationsOptions {
47
+ readonly harness: Harness;
48
+ /** Long-lived context for the metadata writes that follow turns. */
49
+ readonly context: Context;
50
+ readonly now?: () => number;
51
+ /** Longest derived title (default 80 characters). */
52
+ readonly titleLength?: number;
53
+ /** Longest last-message preview (default 160 characters). */
54
+ readonly previewLength?: number;
55
+ /** A metadata write after a turn failed (the turn itself is unaffected). */
56
+ readonly onError?: (error: unknown) => void;
57
+ }
58
+ export interface Conversations {
59
+ /**
60
+ * Create a managed conversation; its metadata is written in the creating commit. `start` creates the native conversation with
61
+ * the host's agent (pass `init` on, for example `definition.createConversation(harness, context, { init })`); the default is
62
+ * an ownerless `harness.createConversation`.
63
+ */
64
+ readonly create: (owner: string, options?: {
65
+ readonly title?: string;
66
+ readonly start?: (init: ConversationInit) => Promise<Conversation>;
67
+ }) => Promise<Conversation>;
68
+ /**
69
+ * Manage an existing conversation (migration). Title and preview are derived once from its history (bounded scan) unless
70
+ * given. Returns `undefined` when the conversation does not exist or another owner already manages it.
71
+ */
72
+ readonly adopt: (id: ConversationId, owner: string, options?: {
73
+ readonly title?: string;
74
+ readonly createdAt?: number;
75
+ readonly updatedAt?: number;
76
+ }) => Promise<ConversationSummary | undefined>;
77
+ /** The summary when `owner` manages the conversation and it is not deleted. */
78
+ readonly get: (owner: string, id: ConversationId) => Promise<ConversationSummary | undefined>;
79
+ /** The native handle under the same condition as `get`. */
80
+ readonly open: (owner: string, id: ConversationId) => Promise<Conversation | undefined>;
81
+ /** Newest activity first. One `scanConversations` pass plus one metadata read per conversation; no transcript is read. */
82
+ readonly list: (query: ConversationListQuery) => Promise<{
83
+ readonly items: readonly ConversationSummary[];
84
+ readonly next?: string;
85
+ }>;
86
+ readonly rename: (owner: string, id: ConversationId, title: string) => Promise<ConversationSummary | undefined>;
87
+ readonly archive: (owner: string, id: ConversationId, archived?: boolean) => Promise<ConversationSummary | undefined>;
88
+ /** Stops the conversation's ordinary work, then marks it deleted (Pi has no delete). `false` when not found. */
89
+ readonly delete: (owner: string, id: ConversationId) => Promise<boolean>;
90
+ /** Native `Conversation.fork(at)`: a new managed conversation with the history up to and including entry `at`, titled "<title> (fork)". */
91
+ readonly fork: (owner: string, id: ConversationId, at: EntryId, options?: {
92
+ readonly title?: string;
93
+ }) => Promise<Conversation | undefined>;
94
+ /** Wait for the metadata writes of turns already committed. */
95
+ readonly flush: () => Promise<void>;
96
+ /** Stop observing commits (after `flush`). Never closes the Harness. */
97
+ readonly dispose: () => Promise<void>;
98
+ }
99
+ /** The visible text of a user or assistant message: text parts only, never the `<file>` blocks mentions add. */
100
+ export declare function messageText(message: Message): string | undefined;
101
+ /** A conversation's title as the list shows it: the first user message, clipped to `maxLength` characters. Undefined: no user message yet. */
102
+ export type ConversationTitleCache = {
103
+ get(key: string): string | undefined;
104
+ set(key: string, title: string): unknown;
105
+ };
106
+ /**
107
+ * The title of a conversation that has no stored one: its first user message, clipped to 80 characters. The first message of a
108
+ * conversation never changes, so a non-null title is cached (`cache`, keyed by the conversation id) and the history is read once;
109
+ * `null` (no user message yet) is not cached. A `Map<string, string>` is a valid cache.
110
+ */
111
+ export declare function conversationTitle(conversation: Conversation, context: Context, cache?: ConversationTitleCache): Promise<string | null>;
112
+ export declare function createConversations(options: ConversationsOptions): Conversations;
113
+ /** What the host's authentication decides for one request: whose conversations, and how to start one. */
114
+ export interface ConversationsAccess {
115
+ readonly owner: string;
116
+ /** How `create` starts the native conversation (the host's agent). Default: an ownerless conversation. */
117
+ readonly start?: (init: ConversationInit) => Promise<Conversation>;
118
+ /** Called with a conversation this request created or forked (for example to register its environment). */
119
+ readonly opened?: (conversation: Conversation) => void | Promise<void>;
120
+ /** Called after this request deleted a conversation. */
121
+ readonly deleted?: (id: ConversationId) => void | Promise<void>;
122
+ /** Refuse an operation; listing included. */
123
+ readonly allow?: (operation: 'list' | 'create' | 'rename' | 'archive' | 'delete' | 'fork') => boolean | Promise<boolean>;
124
+ }
125
+ export interface ConversationsHandlerOptions {
126
+ readonly conversations: Conversations;
127
+ /** Authenticate in trusted host code and name the owner. Null refuses. The host also enforces its Origin/CSRF policy here. */
128
+ readonly authenticate: (request: Request) => Promise<ConversationsAccess | null>;
129
+ }
130
+ /**
131
+ * Fetch handler for a conversation list: `GET` (`?archived=1|all&q=&limit=&cursor=`) lists, `POST` with `?op=create` (the
132
+ * default), `rename` `{ conversationId, title }`, `archive` `{ conversationId, archived? }`, `delete` `{ conversationId }` or
133
+ * `fork` `{ conversationId, at, title? }`. Every POST needs a JSON body.
134
+ */
135
+ export declare function createConversationsHandler(options: ConversationsHandlerOptions): (request: Request) => Promise<Response>;
136
+ //# sourceMappingURL=conversations.d.ts.map
@@ -0,0 +1,366 @@
1
+ import { defineDoc } from '@earendil-works/pi-durable';
2
+ import { RequestGuardError, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
3
+ import { MENTION_FILE_PREFIX } from './mentions.js';
4
+ /** The native document. `fork: 'initial'`: a fork made by `fork()` gets its own metadata in the creating commit; any other fork starts unmanaged. */
5
+ export const conversationMetadata = defineDoc({
6
+ kind: 'boring.conversation.metadata', version: 1, scope: 'conversation', history: 'latest', fork: 'initial',
7
+ initial: () => ({ owner: '', title: null, createdAt: 0, updatedAt: 0, lastMessage: null, archived: false, deletedAt: null }),
8
+ });
9
+ const managed = (meta, owner) => meta !== undefined && meta.owner !== '' && meta.deletedAt === null && (owner === undefined || meta.owner === owner);
10
+ const clip = (text, max) => { const flat = text.replace(/\s+/g, ' ').trim(); return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat; };
11
+ /** The visible text of a user or assistant message: text parts only, never the `<file>` blocks mentions add. */
12
+ export function messageText(message) {
13
+ if (message.role === 'user') {
14
+ const text = typeof message.content === 'string' ? message.content
15
+ : message.content.map(part => part.type === 'text' && !part.text.startsWith(MENTION_FILE_PREFIX) ? part.text : '').join(' ');
16
+ return text.trim() ? text : undefined;
17
+ }
18
+ if (message.role === 'assistant') {
19
+ const text = message.content.map(part => part.type === 'text' ? part.text : '').join(' ');
20
+ return text.trim() ? text : undefined;
21
+ }
22
+ return undefined;
23
+ }
24
+ function summary(id, meta) {
25
+ return { id, title: meta.title, lastMessage: meta.lastMessage, createdAt: meta.createdAt, updatedAt: meta.updatedAt, archived: meta.archived };
26
+ }
27
+ /**
28
+ * The first user message of a conversation, as `messageText` shows it. Pi's ascending entry scan (`order: 'ascending'`) stops at
29
+ * the first user message, so a long transcript is never read. `maxPages` (100 entries each) bounds the scan.
30
+ */
31
+ async function firstUserText(conversation, context, maxPages) {
32
+ let cursor;
33
+ for (let pages = 0; pages < maxPages; pages++) {
34
+ const page = await conversation.entries({ order: 'ascending' }, 100, cursor, context);
35
+ for (const entry of page.items)
36
+ for (const message of entry.model ?? []) {
37
+ if (message.role !== 'user')
38
+ continue;
39
+ const text = messageText(message);
40
+ if (text !== undefined)
41
+ return text;
42
+ }
43
+ if (!page.next)
44
+ break;
45
+ cursor = page.next;
46
+ }
47
+ return undefined;
48
+ }
49
+ /** The last visible message of a conversation: a newest-first scan that stops at the first one found. */
50
+ async function lastVisibleText(conversation, context, maxPages) {
51
+ let cursor;
52
+ for (let pages = 0; pages < maxPages; pages++) {
53
+ const page = await conversation.entries({}, 100, cursor, context);
54
+ for (const entry of page.items)
55
+ for (const message of [...(entry.model ?? [])].reverse()) {
56
+ const text = messageText(message);
57
+ if (text !== undefined)
58
+ return text;
59
+ }
60
+ if (!page.next)
61
+ break;
62
+ cursor = page.next;
63
+ }
64
+ return undefined;
65
+ }
66
+ /**
67
+ * The title of a conversation that has no stored one: its first user message, clipped to 80 characters. The first message of a
68
+ * conversation never changes, so a non-null title is cached (`cache`, keyed by the conversation id) and the history is read once;
69
+ * `null` (no user message yet) is not cached. A `Map<string, string>` is a valid cache.
70
+ */
71
+ export async function conversationTitle(conversation, context, cache) {
72
+ const key = String(conversation.id);
73
+ const known = cache?.get(key);
74
+ if (known !== undefined)
75
+ return known;
76
+ const title = await firstUserText(conversation, context, Infinity);
77
+ if (title === undefined)
78
+ return null;
79
+ const clipped = clip(title, 80);
80
+ cache?.set(key, clipped);
81
+ return clipped;
82
+ }
83
+ export function createConversations(options) {
84
+ const { harness, context } = options;
85
+ const now = options.now ?? Date.now;
86
+ const titleLength = options.titleLength ?? 80, previewLength = options.previewLength ?? 160;
87
+ const read = (id) => harness.snapshot(conversationMetadata, id, context);
88
+ // Checked before the commit (so no document is created for a conversation this module does not manage) and again inside it.
89
+ const write = async (id, owner, change) => {
90
+ if (!managed(await read(id), owner))
91
+ return undefined;
92
+ return harness.commit(async (tx) => {
93
+ const meta = await tx.doc(conversationMetadata, id);
94
+ return managed(meta, owner) ? change(meta) : undefined;
95
+ }, context);
96
+ };
97
+ const pending = new Map();
98
+ let chain = Promise.resolve(), scheduled = false;
99
+ const apply = async (id, item) => {
100
+ const meta = await read(id);
101
+ if (!managed(meta))
102
+ return;
103
+ await harness.commit(async (tx) => {
104
+ const draft = await tx.doc(conversationMetadata, id);
105
+ if (!managed(draft))
106
+ return;
107
+ draft.updatedAt = Math.max(draft.updatedAt, item.at);
108
+ if (item.last !== undefined)
109
+ draft.lastMessage = clip(item.last, previewLength);
110
+ if (draft.title === null && item.firstUser !== undefined)
111
+ draft.title = clip(item.firstUser, titleLength);
112
+ }, context);
113
+ };
114
+ const drain = async () => {
115
+ scheduled = false;
116
+ const batch = [...pending];
117
+ pending.clear();
118
+ for (const [id, item] of batch) {
119
+ try {
120
+ await apply(id, item);
121
+ }
122
+ catch (error) {
123
+ options.onError?.(error);
124
+ }
125
+ }
126
+ };
127
+ const observe = (publication) => {
128
+ let found = false;
129
+ for (const change of publication.changes) {
130
+ if (change.type !== 'entry')
131
+ continue;
132
+ const entry = change.value;
133
+ for (const message of entry.model ?? []) {
134
+ const text = messageText(message);
135
+ if (text === undefined)
136
+ continue;
137
+ const item = pending.get(entry.conversationId) ?? { at: now() };
138
+ if (message.role === 'user' && item.firstUser === undefined)
139
+ item.firstUser = text;
140
+ item.last = text;
141
+ item.at = now();
142
+ pending.set(entry.conversationId, item);
143
+ found = true;
144
+ }
145
+ }
146
+ if (found && !scheduled) {
147
+ scheduled = true;
148
+ chain = chain.then(drain);
149
+ }
150
+ };
151
+ const unsubscribe = harness.subscribeCommits(observe);
152
+ const unsubscribeClose = harness.subscribeClose(() => { unsubscribe(); });
153
+ async function derive(conversation) {
154
+ const [title, last] = await Promise.all([firstUserText(conversation, context, 20), lastVisibleText(conversation, context, 20)]);
155
+ return { ...(title === undefined ? {} : { title }), ...(last === undefined ? {} : { last }) };
156
+ }
157
+ const initial = (owner, fields) => async (tx, id) => {
158
+ const meta = await tx.doc(conversationMetadata, id);
159
+ const at = now();
160
+ Object.assign(meta, { owner, title: null, createdAt: at, updatedAt: at, lastMessage: null, archived: false, deletedAt: null }, fields);
161
+ };
162
+ const ownerKey = (owner) => { if (typeof owner !== 'string' || owner === '')
163
+ throw new TypeError('An owner is required'); return owner; };
164
+ const titleOf = (title) => { const value = clip(String(title), 200); if (!value)
165
+ throw new TypeError('A title is required'); return value; };
166
+ return {
167
+ async create(owner, createOptions = {}) {
168
+ const init = initial(ownerKey(owner), createOptions.title === undefined ? {} : { title: titleOf(createOptions.title) });
169
+ return createOptions.start ? createOptions.start(init) : harness.createConversation({ ownership: { kind: 'ownerless' }, init }, context);
170
+ },
171
+ async adopt(id, owner, adoptOptions = {}) {
172
+ ownerKey(owner);
173
+ const conversation = await harness.conversation(id, context);
174
+ if (!conversation)
175
+ return undefined;
176
+ const existing = await read(id);
177
+ if (managed(existing))
178
+ return existing.owner === owner ? summary(id, existing) : undefined;
179
+ const derived = await derive(conversation);
180
+ const at = now();
181
+ const meta = await harness.commit(async (tx) => {
182
+ const draft = await tx.doc(conversationMetadata, id);
183
+ Object.assign(draft, { owner, createdAt: adoptOptions.createdAt ?? adoptOptions.updatedAt ?? at, updatedAt: adoptOptions.updatedAt ?? adoptOptions.createdAt ?? at,
184
+ title: adoptOptions.title !== undefined ? titleOf(adoptOptions.title) : derived.title !== undefined ? clip(derived.title, titleLength) : null,
185
+ lastMessage: derived.last === undefined ? null : clip(derived.last, previewLength), archived: false, deletedAt: null });
186
+ return { ...draft };
187
+ }, context);
188
+ return summary(id, meta);
189
+ },
190
+ async get(owner, id) { const meta = await read(id); return managed(meta, owner) ? summary(id, meta) : undefined; },
191
+ async open(owner, id) { return managed(await read(id), owner) ? harness.conversation(id, context) : undefined; },
192
+ async list(query) {
193
+ const owner = ownerKey(query.owner);
194
+ const archived = query.archived ?? false;
195
+ const needle = query.query?.trim().toLowerCase() ?? '';
196
+ const limit = Math.min(Math.max(1, Math.floor(query.limit ?? 50)), 200);
197
+ const found = [];
198
+ let cursor;
199
+ do {
200
+ const page = await harness.commit(tx => tx.scanConversations({}, 500, cursor), context);
201
+ for (const record of page.items) {
202
+ if (record.owner)
203
+ continue; // conversations owned by a task (subagents) are never listed
204
+ const meta = await read(record.id);
205
+ if (!managed(meta, owner) || (archived !== 'all' && meta.archived !== archived))
206
+ continue;
207
+ if (needle && !(meta.title ?? '').toLowerCase().includes(needle) && !(meta.lastMessage ?? '').toLowerCase().includes(needle))
208
+ continue;
209
+ found.push(summary(record.id, meta));
210
+ }
211
+ cursor = page.next;
212
+ } while (cursor);
213
+ found.sort((a, b) => b.updatedAt - a.updatedAt || Number(b.id) - Number(a.id));
214
+ let start = 0;
215
+ if (query.cursor) {
216
+ const [at, id] = query.cursor.split(':').map(Number);
217
+ start = found.findIndex(item => item.updatedAt < at || (item.updatedAt === at && Number(item.id) < id));
218
+ if (start < 0)
219
+ start = found.length;
220
+ }
221
+ const items = found.slice(start, start + limit);
222
+ const tail = items.at(-1);
223
+ return start + limit < found.length && tail ? { items, next: `${tail.updatedAt}:${Number(tail.id)}` } : { items };
224
+ },
225
+ async rename(owner, id, title) {
226
+ const value = titleOf(title);
227
+ const meta = await write(id, ownerKey(owner), meta => { meta.title = value; return { ...meta }; });
228
+ return meta && summary(id, meta);
229
+ },
230
+ async archive(owner, id, archived = true) {
231
+ const meta = await write(id, ownerKey(owner), meta => { meta.archived = archived; return { ...meta }; });
232
+ return meta && summary(id, meta);
233
+ },
234
+ async delete(owner, id) {
235
+ if (!managed(await read(id), ownerKey(owner)))
236
+ return false;
237
+ await (await harness.conversation(id, context))?.abort(context);
238
+ return (await write(id, owner, meta => { meta.deletedAt = now(); return true; })) === true;
239
+ },
240
+ async fork(owner, id, at, forkOptions = {}) {
241
+ const source = await read(id);
242
+ if (!managed(source, ownerKey(owner)))
243
+ return undefined;
244
+ const conversation = await harness.conversation(id, context);
245
+ if (!conversation)
246
+ return undefined;
247
+ // The preview of the fork is the last message it inherits: one bounded page of its history up to `at`.
248
+ let last;
249
+ const page = await conversation.entries({ maxEntryId: at }, 50, undefined, context);
250
+ for (const entry of page.items) {
251
+ for (const message of [...entry.model ?? []].reverse()) {
252
+ last = messageText(message);
253
+ if (last !== undefined)
254
+ break;
255
+ }
256
+ if (last !== undefined)
257
+ break;
258
+ }
259
+ const title = forkOptions.title !== undefined ? titleOf(forkOptions.title) : clip(`${source.title ?? 'New conversation'} (fork)`, 200);
260
+ return conversation.fork(at, { ownership: { kind: 'ownerless' }, init: initial(owner, { title, lastMessage: last === undefined ? null : clip(last, previewLength) }) }, context);
261
+ },
262
+ async flush() { while (scheduled || pending.size)
263
+ await chain; await chain; },
264
+ async dispose() { unsubscribe(); unsubscribeClose(); await chain; },
265
+ };
266
+ }
267
+ const headers = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' };
268
+ const failure = (status, reason) => Response.json({ reason }, { status, headers });
269
+ const idOf = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value
270
+ : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : undefined;
271
+ /**
272
+ * Fetch handler for a conversation list: `GET` (`?archived=1|all&q=&limit=&cursor=`) lists, `POST` with `?op=create` (the
273
+ * default), `rename` `{ conversationId, title }`, `archive` `{ conversationId, archived? }`, `delete` `{ conversationId }` or
274
+ * `fork` `{ conversationId, at, title? }`. Every POST needs a JSON body.
275
+ */
276
+ export function createConversationsHandler(options) {
277
+ const { conversations } = options;
278
+ return async (request) => {
279
+ const params = new URL(request.url).searchParams;
280
+ const operation = params.get('op') ?? (request.method === 'GET' ? 'list' : 'create');
281
+ if (request.method === 'POST' && !hasJsonContentType(request.headers))
282
+ return failure(415, 'unsupported-media-type');
283
+ let access;
284
+ try {
285
+ access = await options.authenticate(request);
286
+ }
287
+ catch {
288
+ return failure(503, 'authentication-unavailable');
289
+ }
290
+ if (!access)
291
+ return failure(401, 'authentication-required');
292
+ const { owner } = access;
293
+ const allowed = async (name) => access.allow === undefined || await access.allow(name) === true;
294
+ try {
295
+ if (request.method === 'GET' && operation === 'list') {
296
+ if (!await allowed('list'))
297
+ return failure(403, 'not-authorized');
298
+ const archived = params.get('archived');
299
+ const limit = params.get('limit');
300
+ const page = await conversations.list({ owner, archived: archived === 'all' ? 'all' : archived === '1' || archived === 'true',
301
+ ...(params.get('q') ? { query: params.get('q') } : {}), ...(limit ? { limit: Number(limit) || 50 } : {}), ...(params.get('cursor') ? { cursor: params.get('cursor') } : {}) });
302
+ return Response.json({ conversations: page.items, ...(page.next ? { next: page.next } : {}) }, { headers });
303
+ }
304
+ if (request.method !== 'POST')
305
+ return failure(404, 'unknown-operation');
306
+ const input = await readJsonBody(request, 65_536, request.signal).catch(error => { if (error instanceof RequestGuardError && error.status !== 400)
307
+ throw error; return {}; });
308
+ const body = (input !== null && typeof input === 'object' && !Array.isArray(input) ? input : {});
309
+ const title = typeof body['title'] === 'string' ? body['title'] : undefined;
310
+ if (operation === 'create') {
311
+ if (!await allowed('create'))
312
+ return failure(403, 'not-authorized');
313
+ const created = await conversations.create(owner, { ...(title === undefined ? {} : { title }), ...(access.start ? { start: access.start } : {}) });
314
+ await access.opened?.(created);
315
+ return Response.json({ conversationId: created.id }, { headers });
316
+ }
317
+ const id = idOf(body['conversationId']);
318
+ if (id === undefined)
319
+ return failure(400, 'invalid-request');
320
+ if (operation === 'rename') {
321
+ if (title === undefined || !title.trim())
322
+ return failure(400, 'invalid-request');
323
+ if (!await allowed('rename'))
324
+ return failure(403, 'not-authorized');
325
+ const renamed = await conversations.rename(owner, id, title);
326
+ return renamed ? Response.json({ conversation: renamed }, { headers }) : failure(404, 'unknown-conversation');
327
+ }
328
+ if (operation === 'archive') {
329
+ if (body['archived'] !== undefined && typeof body['archived'] !== 'boolean')
330
+ return failure(400, 'invalid-request');
331
+ if (!await allowed('archive'))
332
+ return failure(403, 'not-authorized');
333
+ const changed = await conversations.archive(owner, id, body['archived'] !== false);
334
+ return changed ? Response.json({ conversation: changed }, { headers }) : failure(404, 'unknown-conversation');
335
+ }
336
+ if (operation === 'delete') {
337
+ if (!await allowed('delete'))
338
+ return failure(403, 'not-authorized');
339
+ if (!await conversations.delete(owner, id))
340
+ return failure(404, 'unknown-conversation');
341
+ await access.deleted?.(id);
342
+ return Response.json({ deleted: true }, { headers });
343
+ }
344
+ if (operation === 'fork') {
345
+ const at = idOf(body['at']);
346
+ if (at === undefined)
347
+ return failure(400, 'invalid-request');
348
+ if (!await allowed('fork'))
349
+ return failure(403, 'not-authorized');
350
+ const forked = await conversations.fork(owner, id, at, title === undefined ? {} : { title });
351
+ if (!forked)
352
+ return failure(404, 'unknown-conversation');
353
+ await access.opened?.(forked);
354
+ return Response.json({ conversationId: forked.id }, { headers });
355
+ }
356
+ }
357
+ catch (error) {
358
+ if (error instanceof RequestGuardError)
359
+ return failure(error.status, error.status === 413 ? 'request-too-large' : 'invalid-request');
360
+ if (error instanceof TypeError)
361
+ return failure(400, 'invalid-request');
362
+ return failure(503, error instanceof Error && error.message ? `source-unavailable: ${error.message.slice(0, 200)}` : 'source-unavailable');
363
+ }
364
+ return failure(404, 'unknown-operation');
365
+ };
366
+ }
@@ -0,0 +1,37 @@
1
+ import type { ResourceAccess, ResourceReader, ResourceRef } from '@hachej/boring-files';
2
+ import type { AgentChange, ToolRegistration } from '@earendil-works/pi-durable';
3
+ import type { NativeVersion } from './native-version.js';
4
+ export interface ResolvedDefinitionTool {
5
+ readonly tool: ToolRegistration;
6
+ /** Host identity for the effective implementation, including native wrappers. */
7
+ readonly implementationVersion: string;
8
+ }
9
+ export interface AgentDefinitionBinding {
10
+ readonly ref: ResourceRef;
11
+ readonly scopeId: string;
12
+ readonly digest: string;
13
+ readonly formatVersion: 1;
14
+ readonly nativeVersion: NativeVersion;
15
+ readonly implementationVersion: string;
16
+ readonly tools: readonly {
17
+ readonly name: string;
18
+ readonly implementationVersion: string;
19
+ }[];
20
+ }
21
+ export interface AgentDefinitionOptions {
22
+ readonly reader: ResourceReader;
23
+ readonly ref: ResourceRef;
24
+ readonly access: ResourceAccess;
25
+ readonly implementationVersion: string;
26
+ /** Resolve currently installed and permitted tools; this does not grant later execution. */
27
+ readonly resolveTool: (name: string) => ResolvedDefinitionTool | undefined | Promise<ResolvedDefinitionTool | undefined>;
28
+ readonly expectedBinding?: AgentDefinitionBinding;
29
+ readonly maxBytes?: number;
30
+ }
31
+ export interface LoadedAgentDefinition {
32
+ readonly change: AgentChange;
33
+ readonly binding: AgentDefinitionBinding;
34
+ }
35
+ /** Load data only. Native configuration stores tool names, so the host must recheck implementations at admission. */
36
+ export declare function loadAgentDefinition(options: AgentDefinitionOptions): Promise<LoadedAgentDefinition>;
37
+ //# sourceMappingURL=definitions.d.ts.map
@@ -0,0 +1,99 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { accessSnapshot, identifier, reference } from '@hachej/boring-files/publication';
3
+ import { NATIVE_VERSION, isNativeVersion } from './native-version.js';
4
+ function object(value, keys) {
5
+ if (!value || typeof value !== 'object' || Array.isArray(value)
6
+ || Object.keys(value).length !== keys.length || keys.some(key => !Object.hasOwn(value, key)))
7
+ throw new TypeError('Invalid definition object');
8
+ return Object.fromEntries(Object.entries(value));
9
+ }
10
+ function names(value) {
11
+ if (!Array.isArray(value) || value.length > 64)
12
+ throw new TypeError('Invalid definition tool list');
13
+ const result = value.map(item => identifier(item));
14
+ if (new Set(result).size !== result.length)
15
+ throw new TypeError('Duplicate definition tool');
16
+ return result;
17
+ }
18
+ function binding(value) {
19
+ const input = object(value, ['ref', 'scopeId', 'digest', 'formatVersion', 'nativeVersion', 'implementationVersion', 'tools']);
20
+ if (input.formatVersion !== 1 || !isNativeVersion(input.nativeVersion)
21
+ || typeof input.digest !== 'string' || !/^[a-f0-9]{64}$/.test(input.digest)
22
+ || !Array.isArray(input.tools) || input.tools.length > 64)
23
+ throw new TypeError('Invalid definition binding');
24
+ const tools = input.tools.map(value => {
25
+ const tool = object(value, ['name', 'implementationVersion']);
26
+ return { name: identifier(tool.name), implementationVersion: identifier(tool.implementationVersion) };
27
+ });
28
+ if (new Set(tools.map(tool => tool.name)).size !== tools.length)
29
+ throw new TypeError('Duplicate bound tool');
30
+ return { ref: reference(input.ref), scopeId: identifier(input.scopeId), digest: input.digest, formatVersion: 1,
31
+ nativeVersion: input.nativeVersion, implementationVersion: identifier(input.implementationVersion), tools };
32
+ }
33
+ /** Load data only. Native configuration stores tool names, so the host must recheck implementations at admission. */
34
+ export async function loadAgentDefinition(options) {
35
+ const ref = reference(options.ref), access = accessSnapshot(options.access);
36
+ const implementationVersion = identifier(options.implementationVersion), maxBytes = options.maxBytes === undefined ? 65536 : options.maxBytes;
37
+ const expected = options.expectedBinding === undefined ? undefined : binding(options.expectedBinding);
38
+ const read = options.reader.read.bind(options.reader), resolveTool = options.resolveTool;
39
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > 1048576 || typeof resolveTool !== 'function') {
40
+ throw new TypeError('Invalid definition loader options');
41
+ }
42
+ const resolve = resolveTool.bind(options);
43
+ access.signal?.throwIfAborted();
44
+ let result;
45
+ try {
46
+ result = await read({ target: { resource: { ...ref.resource }, view: { ...ref.view } }, revision: { kind: 'exact', value: ref.revision } }, { ...access });
47
+ }
48
+ catch {
49
+ access.signal?.throwIfAborted();
50
+ throw new Error('Definition read failed');
51
+ }
52
+ access.signal?.throwIfAborted();
53
+ if (result?.kind !== 'available')
54
+ throw new Error('Definition is not available');
55
+ const snapshot = result.snapshot;
56
+ if (!snapshot || JSON.stringify(reference(snapshot.ref)) !== JSON.stringify(ref)
57
+ || !(snapshot.bytes instanceof Uint8Array) || snapshot.bytes.byteLength > maxBytes
58
+ || typeof snapshot.mediaType !== 'string' || !/^application\/json\s*(?:;\s*charset\s*=\s*utf-8\s*)?$/i.test(snapshot.mediaType.trim())) {
59
+ throw new TypeError('Definition snapshot does not match the selected JSON resource');
60
+ }
61
+ const bytes = Uint8Array.from(snapshot.bytes), digest = createHash('sha256').update(bytes).digest('hex');
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
65
+ }
66
+ catch {
67
+ throw new TypeError('Definition must contain UTF-8 JSON');
68
+ }
69
+ const data = object(parsed, ['format', 'version', 'instructions', 'tools']);
70
+ if (data.format !== 'boring.agent' || data.version !== 1 || typeof data.instructions !== 'string'
71
+ || new TextDecoder('utf-8', { ignoreBOM: true }).decode(new TextEncoder().encode(data.instructions)) !== data.instructions)
72
+ throw new TypeError('Unsupported agent definition');
73
+ const selected = names(data.tools), tools = [], versions = [];
74
+ for (const name of selected) {
75
+ access.signal?.throwIfAborted();
76
+ let resolved;
77
+ try {
78
+ resolved = await resolve(name);
79
+ }
80
+ catch {
81
+ throw new Error('Definition tool resolution failed');
82
+ }
83
+ access.signal?.throwIfAborted();
84
+ if (!resolved || !resolved.tool || resolved.tool.name !== name || typeof resolved.tool.execute !== 'function'
85
+ || typeof resolved.tool.description !== 'string' || !resolved.tool.parameters || typeof resolved.tool.parameters !== 'object') {
86
+ throw new TypeError('Definition tool is not installed and permitted');
87
+ }
88
+ versions.push({ name, implementationVersion: identifier(resolved.implementationVersion) });
89
+ tools.push(resolved.tool);
90
+ }
91
+ access.signal?.throwIfAborted();
92
+ if (tools.some((tool, index) => tool.name !== selected[index]))
93
+ throw new TypeError('Definition tool changed while loading');
94
+ const loaded = { ref, scopeId: access.scopeId, digest, formatVersion: 1,
95
+ nativeVersion: NATIVE_VERSION, implementationVersion, tools: versions };
96
+ if (expected && JSON.stringify(expected) !== JSON.stringify(loaded))
97
+ throw new TypeError('Definition compatibility binding changed');
98
+ return { change: { instructions: data.instructions, tools }, binding: loaded };
99
+ }