@orkestrel/agent 0.0.1
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.
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/dist/src/core/index.cjs +4406 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +5328 -0
- package/dist/src/core/index.d.ts +5328 -0
- package/dist/src/core/index.js +4314 -0
- package/dist/src/core/index.js.map +1 -0
- package/package.json +88 -0
|
@@ -0,0 +1,4406 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _orkestrel_contract = require("@orkestrel/contract");
|
|
3
|
+
let _orkestrel_database = require("@orkestrel/database");
|
|
4
|
+
let _orkestrel_queue = require("@orkestrel/queue");
|
|
5
|
+
let _orkestrel_workflow = require("@orkestrel/workflow");
|
|
6
|
+
let _orkestrel_abort = require("@orkestrel/abort");
|
|
7
|
+
let _orkestrel_timeout = require("@orkestrel/timeout");
|
|
8
|
+
let _orkestrel_emitter = require("@orkestrel/emitter");
|
|
9
|
+
let _orkestrel_budget = require("@orkestrel/budget");
|
|
10
|
+
//#region src/core/constants.ts
|
|
11
|
+
/**
|
|
12
|
+
* The default cap on an {@link AgentInterface} turn's tool iterations — the maximum
|
|
13
|
+
* number of context → provider → tools cycles before the loop stops, so a model that
|
|
14
|
+
* keeps requesting tools can never loop forever. Overridable per agent via
|
|
15
|
+
* `AgentOptions.limit`.
|
|
16
|
+
*/
|
|
17
|
+
var DEFAULT_AGENT_LIMIT = 10;
|
|
18
|
+
/**
|
|
19
|
+
* The zone an {@link AuthorityInterface}'s default fallback {@link AuthorityDecision}
|
|
20
|
+
* carries — the classification for a tool call that matched no rule. Paired with the
|
|
21
|
+
* default `allowed: true` fallback, an unmatched call is allowed under this zone, so a
|
|
22
|
+
* rules list of denials acts as a denylist; a caller wanting deny-by-default supplies
|
|
23
|
+
* an `allowed: false` `fallback` of their own (see `AuthorityOptions`).
|
|
24
|
+
*/
|
|
25
|
+
var DEFAULT_AUTHORITY_ZONE = "default";
|
|
26
|
+
/**
|
|
27
|
+
* The default number of recent live messages a {@link ConversationInterface}'s `compact()`
|
|
28
|
+
* RETAINS verbatim — `0`, so a manual `compact()` folds ALL of the current live messages
|
|
29
|
+
* into one summarized section (no tail kept). A caller retains a recent tail by passing
|
|
30
|
+
* `keep` (on {@link ConversationOptions}, {@link ConversationManagerOptions}, or per-fold
|
|
31
|
+
* via {@link CompactOptions}), folding only the older `count - keep` messages and leaving
|
|
32
|
+
* the most recent `keep` live for the next turn. Overridable everywhere `keep` is accepted.
|
|
33
|
+
*/
|
|
34
|
+
var DEFAULT_CONVERSATION_KEEP = 0;
|
|
35
|
+
/**
|
|
36
|
+
* The framing label a {@link ConversationInterface}'s `view()` prefixes onto each compacted
|
|
37
|
+
* section's summary so a small model reads it as a CONDENSED RECAP of earlier turns — not a
|
|
38
|
+
* literal assistant turn to echo or treat as the live answer.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* Deliberately a FIXED, lean handful of tokens (a short bracketed marker) so the framing adds a
|
|
42
|
+
* bounded `prefix × sections` overhead and NEVER an open-ended blow-up — the
|
|
43
|
+
* {@link ConversationInterface} no-bloat test guard pins exactly that. Kept here (beside
|
|
44
|
+
* {@link DEFAULT_CONVERSATION_KEEP}) as the conversation layer's one tunable framing constant, so
|
|
45
|
+
* the wording has a single source of truth as it is optimized against real small-model behavior
|
|
46
|
+
* (the `view()` recap framing is distinct from `reference()`'s cross-conversation provenance
|
|
47
|
+
* marker, which is rendered inline since it interpolates the per-call provenance `label`).
|
|
48
|
+
*/
|
|
49
|
+
var CONVERSATION_RECAP_PREFIX = "[Summary of earlier messages] ";
|
|
50
|
+
/**
|
|
51
|
+
* The opening tag a {@link import('./ThinkSplitter.js').ThinkSplitter} recognizes as the start of
|
|
52
|
+
* an in-content reasoning span — the de-facto wire convention thinking models (qwen3, DeepSeek-R1
|
|
53
|
+
* family) emit their chain-of-thought under when a daemon renders it inline instead of on a
|
|
54
|
+
* separate wire field. Paired with {@link THINK_CLOSE}.
|
|
55
|
+
*/
|
|
56
|
+
var THINK_OPEN = "<think>";
|
|
57
|
+
/**
|
|
58
|
+
* The closing tag that ends a {@link THINK_OPEN} reasoning span. A span the stream never closes
|
|
59
|
+
* (the model was cut off mid-reasoning) is treated as thinking to its end —
|
|
60
|
+
* {@link import('./types.js').ThinkSplitterInterface.flush} settles it.
|
|
61
|
+
*/
|
|
62
|
+
var THINK_CLOSE = "</think>";
|
|
63
|
+
/**
|
|
64
|
+
* The section header {@link import('./AgentContext.js').AgentContext}'s `build()` renders the
|
|
65
|
+
* ACTIVE workspace's TEXT files under — the leading line of the dedicated workspace block in the
|
|
66
|
+
* system message, the carrier-split counterpart to the documents / images section headers.
|
|
67
|
+
*
|
|
68
|
+
* @remarks
|
|
69
|
+
* `build()` OWNS the workspace render (a `Workspace` / `WorkspaceManager` stays file-focused — no
|
|
70
|
+
* `description` / `framing` getters), so this header lives here as the agents module's one
|
|
71
|
+
* workspace-section framing constant rather than on a manager. Each workspace text file renders
|
|
72
|
+
* beneath it as a fenced `` File: <path>\n```<language>\n<text>\n``` `` block — the SAME framing
|
|
73
|
+
* the documents section uses — placed just after the documents section in the system block.
|
|
74
|
+
*/
|
|
75
|
+
var WORKSPACE_SECTION_HEADER = "## Workspace";
|
|
76
|
+
/**
|
|
77
|
+
* The extension→language table {@link import('./helpers.js').inferLanguage} reads to
|
|
78
|
+
* map a file path's extension to a fenced-code language tag. Covers the common source /
|
|
79
|
+
* markup / data extensions; an unlisted extension falls back to `'text'`.
|
|
80
|
+
*/
|
|
81
|
+
var EXTENSION_TO_LANGUAGE = Object.freeze({
|
|
82
|
+
ts: "typescript",
|
|
83
|
+
tsx: "typescript",
|
|
84
|
+
js: "javascript",
|
|
85
|
+
jsx: "javascript",
|
|
86
|
+
mjs: "javascript",
|
|
87
|
+
cjs: "javascript",
|
|
88
|
+
json: "json",
|
|
89
|
+
md: "markdown",
|
|
90
|
+
html: "html",
|
|
91
|
+
htm: "html",
|
|
92
|
+
css: "css",
|
|
93
|
+
scss: "scss",
|
|
94
|
+
sass: "sass",
|
|
95
|
+
less: "less",
|
|
96
|
+
vue: "vue",
|
|
97
|
+
svelte: "svelte",
|
|
98
|
+
py: "python",
|
|
99
|
+
rb: "ruby",
|
|
100
|
+
rs: "rust",
|
|
101
|
+
go: "go",
|
|
102
|
+
java: "java",
|
|
103
|
+
kt: "kotlin",
|
|
104
|
+
swift: "swift",
|
|
105
|
+
c: "c",
|
|
106
|
+
cpp: "cpp",
|
|
107
|
+
h: "c",
|
|
108
|
+
hpp: "cpp",
|
|
109
|
+
cs: "csharp",
|
|
110
|
+
php: "php",
|
|
111
|
+
sql: "sql",
|
|
112
|
+
sh: "bash",
|
|
113
|
+
bash: "bash",
|
|
114
|
+
zsh: "bash",
|
|
115
|
+
ps1: "powershell",
|
|
116
|
+
yaml: "yaml",
|
|
117
|
+
yml: "yaml",
|
|
118
|
+
toml: "toml",
|
|
119
|
+
xml: "xml",
|
|
120
|
+
svg: "xml",
|
|
121
|
+
txt: "text"
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* The name {@link import('./factories.js').createWorkspaceTool} advertises by default — the key a
|
|
125
|
+
* model calls and the {@link import('./types.js').ToolManagerInterface} registers under.
|
|
126
|
+
*/
|
|
127
|
+
var WORKSPACE_TOOL_NAME = "workspace";
|
|
128
|
+
/**
|
|
129
|
+
* A valid {@link WorkspaceOperation} object — the canonical example embedded VERBATIM in
|
|
130
|
+
* {@link WORKSPACE_TOOL_DESCRIPTION} and pinned by a parity test (it must satisfy the compiled
|
|
131
|
+
* contract's `is`), so the doc example can never drift from a real, contract-valid operation.
|
|
132
|
+
*
|
|
133
|
+
* @remarks
|
|
134
|
+
* A `'write'` op (the most common authoring action): create or overwrite `notes.txt` with
|
|
135
|
+
* `hello`. Frozen so it cannot be mutated in place.
|
|
136
|
+
*/
|
|
137
|
+
var WORKSPACE_TOOL_EXAMPLE = Object.freeze({
|
|
138
|
+
operation: "write",
|
|
139
|
+
path: "notes.txt",
|
|
140
|
+
content: "hello"
|
|
141
|
+
});
|
|
142
|
+
/**
|
|
143
|
+
* The DESCRIPTION {@link import('./factories.js').createWorkspaceTool} advertises — a multi-line
|
|
144
|
+
* guide that teaches a small model how to drive a workspace through the single `operation`-keyed
|
|
145
|
+
* tool.
|
|
146
|
+
*
|
|
147
|
+
* @remarks
|
|
148
|
+
* Mirrors the analogous `WORKFLOW_TOOL_DESCRIPTION` in `@orkestrel/workflow`'s teaching style:
|
|
149
|
+
* names the `operation` discriminant field, enumerates all 13 operations with their FLAT fields,
|
|
150
|
+
* gives a worked example for the common ones (read / write / search / replace / splice), and embeds
|
|
151
|
+
* {@link WORKSPACE_TOOL_EXAMPLE} verbatim (pinned by a parity test). The range edit is the FLAT
|
|
152
|
+
* `'splice'` op — four positive-integer caret components, NOT a nested range — the ergonomic lever
|
|
153
|
+
* a 2B model can fill. The edit / read ops target the ACTIVE workspace (one is auto-created on the
|
|
154
|
+
* first edit when none is active); the `workspaces` / `switch` ops let the model move between
|
|
155
|
+
* workspaces.
|
|
156
|
+
*/
|
|
157
|
+
var WORKSPACE_TOOL_DESCRIPTION = [
|
|
158
|
+
"Read and edit files in a workspace. Every call is ONE operation, chosen by the \"operation\" field.",
|
|
159
|
+
"All file operations act on the ACTIVE workspace; use \"workspaces\" then \"switch\" to move between workspaces.",
|
|
160
|
+
"",
|
|
161
|
+
"Operations (each takes the fields listed):",
|
|
162
|
+
"- read { \"operation\": \"read\", \"path\": \"<file>\" } — return the file's text.",
|
|
163
|
+
"- list { \"operation\": \"list\" } — list every file in the active workspace (path, state, size, lines, kind).",
|
|
164
|
+
"- has { \"operation\": \"has\", \"path\": \"<file>\" } — whether the file exists.",
|
|
165
|
+
"- search { \"operation\": \"search\", \"query\": \"<text>\", \"regex\"?: bool, \"exact\"?: bool, \"limit\"?: int } — find lines matching the query across all files.",
|
|
166
|
+
"- replace { \"operation\": \"replace\", \"query\": \"<text>\", \"replacement\": \"<text>\", \"regex\"?: bool, \"exact\"?: bool, \"limit\"?: int } — replace matches across all files.",
|
|
167
|
+
"- write { \"operation\": \"write\", \"path\": \"<file>\", \"content\": \"<text>\" } — create or overwrite the whole file.",
|
|
168
|
+
"- splice { \"operation\": \"splice\", \"path\": \"<file>\", \"content\": \"<text>\", \"fromLine\": int, \"fromColumn\": int, \"toLine\": int, \"toColumn\": int } — replace a 1-based range (from inclusive, to exclusive) with content.",
|
|
169
|
+
"- prepend { \"operation\": \"prepend\", \"path\": \"<file>\", \"content\": \"<text>\" } — add content to the start of the file.",
|
|
170
|
+
"- append { \"operation\": \"append\", \"path\": \"<file>\", \"content\": \"<text>\" } — add content to the end of the file.",
|
|
171
|
+
"- move { \"operation\": \"move\", \"from\": \"<file>\", \"to\": \"<file>\" } — rename / move a file.",
|
|
172
|
+
"- remove { \"operation\": \"remove\", \"path\": \"<file>\" } — delete a file.",
|
|
173
|
+
"- workspaces { \"operation\": \"workspaces\" } — list the workspaces you can switch between (each id, file count, active).",
|
|
174
|
+
"- switch { \"operation\": \"switch\", \"id\": \"<id>\" } — make the workspace with that id active (ids come from \"workspaces\").",
|
|
175
|
+
"",
|
|
176
|
+
"Notes: lines and columns are 1-based (column 1 is the first character). \"regex\" defaults to false (a literal substring), \"exact\" defaults to true (case-sensitive). \"search\"/\"replace\"/\"splice\" act only on text files. Editing with no active workspace auto-creates one.",
|
|
177
|
+
"",
|
|
178
|
+
"Example — write a file:",
|
|
179
|
+
JSON.stringify(WORKSPACE_TOOL_EXAMPLE)
|
|
180
|
+
].join("\n");
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/core/errors.ts
|
|
183
|
+
/**
|
|
184
|
+
* Thrown by a {@link ProviderInterface}'s `stream` when its bound signal aborts
|
|
185
|
+
* mid-flight — carries the {@link ProviderResult} assembled from whatever streamed
|
|
186
|
+
* before the cancel.
|
|
187
|
+
*
|
|
188
|
+
* @remarks
|
|
189
|
+
* Lets a caller recover the partial content (and any tool calls / usage seen so far)
|
|
190
|
+
* on cancellation: `catch` the throw, narrow with {@link isProviderAbortError}, and
|
|
191
|
+
* read `partial`.
|
|
192
|
+
*/
|
|
193
|
+
var ProviderAbortError = class extends Error {
|
|
194
|
+
partial;
|
|
195
|
+
constructor(partial) {
|
|
196
|
+
super("provider stream aborted");
|
|
197
|
+
this.name = "ProviderAbortError";
|
|
198
|
+
this.partial = partial;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
/**
|
|
202
|
+
* Narrow an unknown caught value to a {@link ProviderAbortError}.
|
|
203
|
+
*
|
|
204
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
205
|
+
* @returns `true` when `value` is a {@link ProviderAbortError}
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* try {
|
|
210
|
+
* for await (const delta of provider.stream(messages, signal)) render(delta)
|
|
211
|
+
* } catch (error) {
|
|
212
|
+
* if (isProviderAbortError(error)) keep(error.partial.content) // recover partial
|
|
213
|
+
* }
|
|
214
|
+
* ```
|
|
215
|
+
*/
|
|
216
|
+
function isProviderAbortError(value) {
|
|
217
|
+
return value instanceof ProviderAbortError;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Thrown by an agent-job handler (a `createAgentQueue` / `createAgentRunner` job) when
|
|
221
|
+
* its {@link AgentInterface} run ended {@link AgentResult.partial} and the job's
|
|
222
|
+
* `allowPartial` policy is `false` (the default) — carries the partial
|
|
223
|
+
* {@link AgentResult} so the failure stays inspectable.
|
|
224
|
+
*
|
|
225
|
+
* @remarks
|
|
226
|
+
* A partial result means the agent was cancelled (an external `signal` abort, a queue /
|
|
227
|
+
* runner abort threaded in, a `timeout` deadline, or an exhausted token `budget`) rather
|
|
228
|
+
* than finishing naturally. For a durable JOB that is a failure by default: throwing this
|
|
229
|
+
* lets the Queue's retries re-run the job and a Runner's fail-fast abort its siblings.
|
|
230
|
+
* Set `allowPartial: true` (see `AgentQueueOptions` / `AgentRunnerOptions`) to treat a
|
|
231
|
+
* partial as success instead, in which case this is never thrown. Narrow a caught value
|
|
232
|
+
* with {@link isAgentJobError} to read `partial`.
|
|
233
|
+
*/
|
|
234
|
+
var AgentJobError = class extends Error {
|
|
235
|
+
/** The partial {@link AgentResult} the cancelled job produced. */
|
|
236
|
+
partial;
|
|
237
|
+
constructor(message, partial) {
|
|
238
|
+
super(message);
|
|
239
|
+
this.name = "AgentJobError";
|
|
240
|
+
this.partial = partial;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Narrow an unknown caught value to an {@link AgentJobError}.
|
|
245
|
+
*
|
|
246
|
+
* @param value - The value to test (typically a `catch` binding or a rejected enqueue)
|
|
247
|
+
* @returns `true` when `value` is an {@link AgentJobError}
|
|
248
|
+
*
|
|
249
|
+
* @example
|
|
250
|
+
* ```ts
|
|
251
|
+
* try {
|
|
252
|
+
* await queue.enqueue(job) // retries: 0 → a partial rejects with the error
|
|
253
|
+
* } catch (error) {
|
|
254
|
+
* if (isAgentJobError(error)) keep(error.partial.content) // recover the partial content
|
|
255
|
+
* }
|
|
256
|
+
* ```
|
|
257
|
+
*/
|
|
258
|
+
function isAgentJobError(value) {
|
|
259
|
+
return value instanceof AgentJobError;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Thrown by a {@link ConversationInterface}'s `compact()` when the conversation has no
|
|
263
|
+
* {@link ConversationSummarizer} to fold its messages with — carries a machine-readable
|
|
264
|
+
* `code`.
|
|
265
|
+
*
|
|
266
|
+
* @remarks
|
|
267
|
+
* Compaction REQUIRES a summarizer (it digests the folded slice into a section summary and
|
|
268
|
+
* regenerates the rollup); a conversation created without one can still store + `view()` its
|
|
269
|
+
* live tail, but a `compact()` is a programmer error (§12) and throws this. `code` is
|
|
270
|
+
* `'SUMMARIZER'` (the only condition so far). Narrow a caught value with
|
|
271
|
+
* {@link isConversationError} and branch on `error.code`.
|
|
272
|
+
*/
|
|
273
|
+
var ConversationError = class extends Error {
|
|
274
|
+
/** The machine-readable condition — `'SUMMARIZER'`: a `compact()` with no summarizer. */
|
|
275
|
+
code;
|
|
276
|
+
constructor(code, message) {
|
|
277
|
+
super(message);
|
|
278
|
+
this.name = "ConversationError";
|
|
279
|
+
this.code = code;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
/**
|
|
283
|
+
* Narrow an unknown caught value to a {@link ConversationError}.
|
|
284
|
+
*
|
|
285
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
286
|
+
* @returns `true` when `value` is a {@link ConversationError}
|
|
287
|
+
*
|
|
288
|
+
* @example
|
|
289
|
+
* ```ts
|
|
290
|
+
* try {
|
|
291
|
+
* await conversation.compact()
|
|
292
|
+
* } catch (error) {
|
|
293
|
+
* if (isConversationError(error) && error.code === 'SUMMARIZER') addSummarizer()
|
|
294
|
+
* }
|
|
295
|
+
* ```
|
|
296
|
+
*/
|
|
297
|
+
function isConversationError(value) {
|
|
298
|
+
return value instanceof ConversationError;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* An error thrown by the in-memory {@link import('./workspaces/Workspace.js').Workspace} edit
|
|
302
|
+
* surface.
|
|
303
|
+
*
|
|
304
|
+
* @remarks
|
|
305
|
+
* Carries a {@link WorkspaceErrorCode} and an optional `context` bag naming the offending
|
|
306
|
+
* path / range. Thrown for a text-only operation aimed at an image file (`MODALITY` — a
|
|
307
|
+
* ranged read / write, `prepend`, or `append`), an invalid `search` / `replace` regular
|
|
308
|
+
* expression (`PATTERN`), and a structurally invalid ranged-write {@link import('./types.js').Range}
|
|
309
|
+
* (`RANGE` — inverted, or a sub-1 line / column).
|
|
310
|
+
*/
|
|
311
|
+
var WorkspaceError = class extends Error {
|
|
312
|
+
code;
|
|
313
|
+
context;
|
|
314
|
+
constructor(code, message, context) {
|
|
315
|
+
super(message);
|
|
316
|
+
this.name = "WorkspaceError";
|
|
317
|
+
this.code = code;
|
|
318
|
+
this.context = context;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
/**
|
|
322
|
+
* Narrow an unknown caught value to a {@link WorkspaceError}.
|
|
323
|
+
*
|
|
324
|
+
* @param value - The value to test (typically a `catch` binding)
|
|
325
|
+
* @returns `true` when `value` is a {@link WorkspaceError}
|
|
326
|
+
*
|
|
327
|
+
* @example
|
|
328
|
+
* ```ts
|
|
329
|
+
* try {
|
|
330
|
+
* workspace.prepend('icon.png', '// header')
|
|
331
|
+
* } catch (error) {
|
|
332
|
+
* if (isWorkspaceError(error) && error.code === 'MODALITY') skipBinary()
|
|
333
|
+
* }
|
|
334
|
+
* ```
|
|
335
|
+
*/
|
|
336
|
+
function isWorkspaceError(value) {
|
|
337
|
+
return value instanceof WorkspaceError;
|
|
338
|
+
}
|
|
339
|
+
//#endregion
|
|
340
|
+
//#region src/core/shapers.ts
|
|
341
|
+
/**
|
|
342
|
+
* The shape of a {@link import('./types.js').WorkspaceOperation} — a descriptive tagged union over
|
|
343
|
+
* the 13 workspace edit / read / navigation operations, discriminated by the `operation` literal
|
|
344
|
+
* (never a bare `kind`; AGENTS §4.8). Each variant leads with its `operation` discriminant then its
|
|
345
|
+
* FLAT fields, every field via `stringShape` / `optionalShape` / `integerShape({ min: 1 })` /
|
|
346
|
+
* `booleanShape`, each carrying a strong field-level `description`.
|
|
347
|
+
*
|
|
348
|
+
* @remarks
|
|
349
|
+
* The union compiles to an `anyOf` JSON Schema + a `unionOf` guard + a first-match parser
|
|
350
|
+
* automatically ({@link import('./factories.js').createWorkspaceTool} types the result to the
|
|
351
|
+
* hand-written `WorkspaceOperation`). `limit` and the four `'splice'` caret components are POSITIVE
|
|
352
|
+
* integers (`integerShape({ min: 1 })`); `regex` / `exact` are `optionalShape(booleanShape(...))`.
|
|
353
|
+
* The two REGISTRY arms — `workspaces` (list the workspaces the model can move between) and `switch`
|
|
354
|
+
* (re-point the active one by `id`) — let a model DISCOVER then CHOOSE which workspace the edit /
|
|
355
|
+
* read arms target. The descriptions are the small-model ergonomic lever — they ride into the
|
|
356
|
+
* advertised schema so a model authoring an operation gets per-field guidance.
|
|
357
|
+
*/
|
|
358
|
+
var workspaceToolShape = (0, _orkestrel_contract.unionShape)((0, _orkestrel_contract.objectShape)({
|
|
359
|
+
operation: (0, _orkestrel_contract.literalShape)(["read"], { description: "Read a whole text file's text by path." }),
|
|
360
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path of the file to read." })
|
|
361
|
+
}), (0, _orkestrel_contract.objectShape)({ operation: (0, _orkestrel_contract.literalShape)(["list"], { description: "List every file in the workspace." }) }), (0, _orkestrel_contract.objectShape)({
|
|
362
|
+
operation: (0, _orkestrel_contract.literalShape)(["has"], { description: "Check whether a file exists at the path." }),
|
|
363
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path to check for." })
|
|
364
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
365
|
+
operation: (0, _orkestrel_contract.literalShape)(["search"], { description: "Search every text file for a query, returning each hit." }),
|
|
366
|
+
query: (0, _orkestrel_contract.stringShape)({ description: "The text (or regular-expression source) to search for." }),
|
|
367
|
+
regex: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.booleanShape)({ description: "Treat the query as a regular expression. Defaults to false (a literal substring)." })),
|
|
368
|
+
exact: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.booleanShape)({ description: "Match case-sensitively. Defaults to true (set false for case-insensitive)." })),
|
|
369
|
+
limit: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.integerShape)({
|
|
370
|
+
min: 1,
|
|
371
|
+
description: "Stop after this many matches across all files. Omitted means unlimited."
|
|
372
|
+
}))
|
|
373
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
374
|
+
operation: (0, _orkestrel_contract.literalShape)(["replace"], { description: "Replace a query with a replacement across every text file." }),
|
|
375
|
+
query: (0, _orkestrel_contract.stringShape)({ description: "The text (or regular-expression source) to replace." }),
|
|
376
|
+
replacement: (0, _orkestrel_contract.stringShape)({ description: "The text to substitute for each match." }),
|
|
377
|
+
regex: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.booleanShape)({ description: "Treat the query as a regular expression. Defaults to false (a literal substring)." })),
|
|
378
|
+
exact: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.booleanShape)({ description: "Match case-sensitively. Defaults to true (set false for case-insensitive)." })),
|
|
379
|
+
limit: (0, _orkestrel_contract.optionalShape)((0, _orkestrel_contract.integerShape)({
|
|
380
|
+
min: 1,
|
|
381
|
+
description: "Stop after this many replacements across all files. Omitted means unlimited."
|
|
382
|
+
}))
|
|
383
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
384
|
+
operation: (0, _orkestrel_contract.literalShape)(["write"], { description: "Create or overwrite a whole file with content." }),
|
|
385
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path of the file to write." }),
|
|
386
|
+
content: (0, _orkestrel_contract.stringShape)({ description: "The full new contents of the file." })
|
|
387
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
388
|
+
operation: (0, _orkestrel_contract.literalShape)(["splice"], { description: "Replace a 1-based range of an existing text file (from inclusive, to exclusive) with content." }),
|
|
389
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path of the text file to edit." }),
|
|
390
|
+
content: (0, _orkestrel_contract.stringShape)({ description: "The text to splice in place of the range." }),
|
|
391
|
+
fromLine: (0, _orkestrel_contract.integerShape)({
|
|
392
|
+
min: 1,
|
|
393
|
+
description: "The 1-based start line of the range (inclusive)."
|
|
394
|
+
}),
|
|
395
|
+
fromColumn: (0, _orkestrel_contract.integerShape)({
|
|
396
|
+
min: 1,
|
|
397
|
+
description: "The 1-based start column of the range (inclusive; column 1 is the first character)."
|
|
398
|
+
}),
|
|
399
|
+
toLine: (0, _orkestrel_contract.integerShape)({
|
|
400
|
+
min: 1,
|
|
401
|
+
description: "The 1-based end line of the range (exclusive)."
|
|
402
|
+
}),
|
|
403
|
+
toColumn: (0, _orkestrel_contract.integerShape)({
|
|
404
|
+
min: 1,
|
|
405
|
+
description: "The 1-based end column of the range (exclusive)."
|
|
406
|
+
})
|
|
407
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
408
|
+
operation: (0, _orkestrel_contract.literalShape)(["prepend"], { description: "Add content to the start of a file (creating it when absent)." }),
|
|
409
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path of the file to prepend to." }),
|
|
410
|
+
content: (0, _orkestrel_contract.stringShape)({ description: "The text to add at the start of the file." })
|
|
411
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
412
|
+
operation: (0, _orkestrel_contract.literalShape)(["append"], { description: "Add content to the end of a file (creating it when absent)." }),
|
|
413
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path of the file to append to." }),
|
|
414
|
+
content: (0, _orkestrel_contract.stringShape)({ description: "The text to add at the end of the file." })
|
|
415
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
416
|
+
operation: (0, _orkestrel_contract.literalShape)(["move"], { description: "Rename or move a file (overwriting an occupied target)." }),
|
|
417
|
+
from: (0, _orkestrel_contract.stringShape)({ description: "The current path of the file." }),
|
|
418
|
+
to: (0, _orkestrel_contract.stringShape)({ description: "The new path for the file." })
|
|
419
|
+
}), (0, _orkestrel_contract.objectShape)({
|
|
420
|
+
operation: (0, _orkestrel_contract.literalShape)(["remove"], { description: "Delete a file from the workspace." }),
|
|
421
|
+
path: (0, _orkestrel_contract.stringShape)({ description: "The path of the file to remove." })
|
|
422
|
+
}), (0, _orkestrel_contract.objectShape)({ operation: (0, _orkestrel_contract.literalShape)(["workspaces"], { description: "List the workspaces you can move between (each id, file count, and whether it is active), so you can pick an id to switch to." }) }), (0, _orkestrel_contract.objectShape)({
|
|
423
|
+
operation: (0, _orkestrel_contract.literalShape)(["switch"], { description: "Switch the active workspace to the one with this id (get ids from the \"workspaces\" operation). Edit and read operations then target it." }),
|
|
424
|
+
id: (0, _orkestrel_contract.stringShape)({ description: "The id of the workspace to make active (from the \"workspaces\" listing)." })
|
|
425
|
+
}));
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/core/conversations/Conversation.ts
|
|
428
|
+
/**
|
|
429
|
+
* A conversation grouping messages ABOVE a flat message store — a live uncompacted tail it
|
|
430
|
+
* OWNS DIRECTLY plus compacted, summarized {@link SectionInterface}s and a regenerated rollup
|
|
431
|
+
* `summary`, with on-demand `rehydrate` and substring `search`, driven by a provider-agnostic
|
|
432
|
+
* {@link ConversationSummarizer} seam (so `core` never imports a provider).
|
|
433
|
+
*
|
|
434
|
+
* @remarks
|
|
435
|
+
* - **Live tail + sections.** The conversation OWNS its live tail DIRECTLY — `#messages` is an
|
|
436
|
+
* insertion-ordered `Map` of immutable {@link MessageInterface}s keyed by their minted id
|
|
437
|
+
* (the SAME store mechanics a flat manager had, folded in: `add` / `message` / `messages` /
|
|
438
|
+
* `remove` / `clear` / `count`), exactly as a `Workspace` owns its files (no separate
|
|
439
|
+
* per-value manager). `#sections` are the compacted history (oldest → newest), each a
|
|
440
|
+
* summarized slice that RETAINS its originals. `#summary` is the rollup (a
|
|
441
|
+
* summary-of-summaries over all sections), regenerated on each compaction (`undefined`
|
|
442
|
+
* until the first).
|
|
443
|
+
* - **`view()`.** Each section folds to ONE synthetic summary message (role `'assistant'` — a
|
|
444
|
+
* prior-context recap — keyed by the section's stable `id`), then the live messages
|
|
445
|
+
* verbatim. The rollup `summary` is NOT injected (it is separately pull-able); `view()`
|
|
446
|
+
* carries the per-section summaries, which ARE the compaction benefit.
|
|
447
|
+
* - **`compact()`.** Folds the oldest `count - keep` live messages into a new section
|
|
448
|
+
* (its `summary` from `#summarize`), removes them from the live tail by id, regenerates the
|
|
449
|
+
* rollup (a SECOND `#summarize` over all section summaries), and emits `summary` then
|
|
450
|
+
* `compact`. Returns the section, or `undefined` when nothing folds (`count <= keep`).
|
|
451
|
+
* THROWS a {@link ConversationError} when no `#summarize` was supplied. Two summarizer calls
|
|
452
|
+
* per compaction.
|
|
453
|
+
* - **`rehydrate(id)` / `search(query)`.** `rehydrate` returns a section's full original
|
|
454
|
+
* messages (`[]` for an unknown id) and emits `rehydrate` — a pure read (v1 never
|
|
455
|
+
* auto-reinserts). `search` is a case-insensitive substring scan of `content` across ALL
|
|
456
|
+
* messages (every section's originals + the live tail).
|
|
457
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link ConversationEventMap}) carries
|
|
458
|
+
* `compact` / `summary` / `rehydrate`, emitted directly, strictly AFTER the state change;
|
|
459
|
+
* the emitter isolates a listener throw and routes it to its `error` handler (the `error`
|
|
460
|
+
* option), so a buggy observer can never corrupt a compaction.
|
|
461
|
+
*
|
|
462
|
+
* @example
|
|
463
|
+
* ```ts
|
|
464
|
+
* const conversation = new Conversation({ summarize: async (m) => `recap of ${m.length}` })
|
|
465
|
+
* conversation.add([
|
|
466
|
+
* { role: 'user', content: 'Hello' },
|
|
467
|
+
* { role: 'assistant', content: 'Hi there' },
|
|
468
|
+
* ])
|
|
469
|
+
* const section = await conversation.compact() // folds both into one summarized section
|
|
470
|
+
* conversation.view() // [{ role: 'assistant', content: 'recap of 2' }] — the live tail is empty
|
|
471
|
+
* conversation.summary // 'recap of 1' — the rollup over the one section
|
|
472
|
+
* ```
|
|
473
|
+
*/
|
|
474
|
+
var Conversation = class {
|
|
475
|
+
#id;
|
|
476
|
+
#emitter;
|
|
477
|
+
#summarize;
|
|
478
|
+
#keep;
|
|
479
|
+
#sections = [];
|
|
480
|
+
#summary;
|
|
481
|
+
#messages = /* @__PURE__ */ new Map();
|
|
482
|
+
constructor(options, seed) {
|
|
483
|
+
this.#id = seed?.id ?? options?.id ?? crypto.randomUUID();
|
|
484
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
485
|
+
on: options?.on,
|
|
486
|
+
error: options?.error
|
|
487
|
+
});
|
|
488
|
+
this.#summarize = options?.summarize;
|
|
489
|
+
this.#keep = options?.keep ?? 0;
|
|
490
|
+
if (seed) {
|
|
491
|
+
this.#summary = seed.summary;
|
|
492
|
+
for (const section of seed.sections) this.#sections.push(section);
|
|
493
|
+
for (const message of seed.messages) this.#messages.set(message.id, message);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
get id() {
|
|
497
|
+
return this.#id;
|
|
498
|
+
}
|
|
499
|
+
get emitter() {
|
|
500
|
+
return this.#emitter;
|
|
501
|
+
}
|
|
502
|
+
get summary() {
|
|
503
|
+
return this.#summary;
|
|
504
|
+
}
|
|
505
|
+
get sections() {
|
|
506
|
+
return [...this.#sections];
|
|
507
|
+
}
|
|
508
|
+
get summarizable() {
|
|
509
|
+
return this.#summarize !== void 0;
|
|
510
|
+
}
|
|
511
|
+
get count() {
|
|
512
|
+
return this.#messages.size;
|
|
513
|
+
}
|
|
514
|
+
add(input) {
|
|
515
|
+
if ((0, _orkestrel_contract.isArray)(input)) return input.map((one) => this.#create(one));
|
|
516
|
+
return this.#create(input);
|
|
517
|
+
}
|
|
518
|
+
message(id) {
|
|
519
|
+
return this.#messages.get(id);
|
|
520
|
+
}
|
|
521
|
+
messages() {
|
|
522
|
+
return [...this.#messages.values()];
|
|
523
|
+
}
|
|
524
|
+
remove(ids) {
|
|
525
|
+
if ((0, _orkestrel_contract.isArray)(ids)) {
|
|
526
|
+
let removed = false;
|
|
527
|
+
for (const id of ids) if (this.#messages.delete(id)) removed = true;
|
|
528
|
+
return removed;
|
|
529
|
+
}
|
|
530
|
+
return this.#messages.delete(ids);
|
|
531
|
+
}
|
|
532
|
+
clear() {
|
|
533
|
+
this.#messages.clear();
|
|
534
|
+
}
|
|
535
|
+
view() {
|
|
536
|
+
return [...this.#sections.map((section) => this.#recapMessage(section)), ...this.#messages.values()];
|
|
537
|
+
}
|
|
538
|
+
async compact(options) {
|
|
539
|
+
const summarize = this.#summarize;
|
|
540
|
+
if (summarize === void 0) throw new ConversationError("SUMMARIZER", "cannot compact a conversation without a summarizer");
|
|
541
|
+
const keep = options?.keep ?? this.#keep;
|
|
542
|
+
const live = [...this.#messages.values()];
|
|
543
|
+
const fold = keep <= 0 ? live.length : live.length - keep;
|
|
544
|
+
if (fold <= 0) return void 0;
|
|
545
|
+
const slice = live.slice(0, fold);
|
|
546
|
+
const summary = await summarize(slice);
|
|
547
|
+
const section = {
|
|
548
|
+
id: crypto.randomUUID(),
|
|
549
|
+
summary,
|
|
550
|
+
messages: slice
|
|
551
|
+
};
|
|
552
|
+
for (const message of slice) this.#messages.delete(message.id);
|
|
553
|
+
this.#sections.push(section);
|
|
554
|
+
this.#summary = await summarize(this.#sections.map((one) => this.#summaryMessage(one)));
|
|
555
|
+
this.#emitter.emit("summary", this.#summary);
|
|
556
|
+
this.#emitter.emit("compact", section);
|
|
557
|
+
return section;
|
|
558
|
+
}
|
|
559
|
+
rehydrate(id) {
|
|
560
|
+
const section = this.#sections.find((one) => one.id === id);
|
|
561
|
+
this.#emitter.emit("rehydrate", id);
|
|
562
|
+
return section === void 0 ? [] : section.messages;
|
|
563
|
+
}
|
|
564
|
+
search(query) {
|
|
565
|
+
const needle = query.toLowerCase();
|
|
566
|
+
return [...this.#sections.flatMap((section) => section.messages), ...this.#messages.values()].filter((message) => message.content.toLowerCase().includes(needle));
|
|
567
|
+
}
|
|
568
|
+
reference(options) {
|
|
569
|
+
const lines = [`[Reference — conversation "${options?.label ?? this.#id}" — NOT part of this conversation]`];
|
|
570
|
+
if (options?.summary !== false && this.#summary !== void 0) lines.push(`Summary: ${this.#summary}`);
|
|
571
|
+
const messages = options?.messages ?? [];
|
|
572
|
+
if (messages.length > 0) {
|
|
573
|
+
lines.push("Relevant messages:");
|
|
574
|
+
for (const message of messages) lines.push(`- ${message.role}: ${message.content}`);
|
|
575
|
+
}
|
|
576
|
+
return lines.join("\n");
|
|
577
|
+
}
|
|
578
|
+
snapshot() {
|
|
579
|
+
return {
|
|
580
|
+
id: this.#id,
|
|
581
|
+
...this.#summary === void 0 ? {} : { summary: this.#summary },
|
|
582
|
+
sections: this.sections,
|
|
583
|
+
messages: this.messages()
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
#summaryMessage(section) {
|
|
587
|
+
return {
|
|
588
|
+
id: section.id,
|
|
589
|
+
role: "assistant",
|
|
590
|
+
content: section.summary
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
#recapMessage(section) {
|
|
594
|
+
return {
|
|
595
|
+
id: section.id,
|
|
596
|
+
role: "assistant",
|
|
597
|
+
content: `${CONVERSATION_RECAP_PREFIX}${section.summary}`
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
#create(input) {
|
|
601
|
+
const message = {
|
|
602
|
+
id: crypto.randomUUID(),
|
|
603
|
+
role: input.role,
|
|
604
|
+
content: input.content,
|
|
605
|
+
...input.calls === void 0 ? {} : { calls: input.calls },
|
|
606
|
+
...input.images === void 0 ? {} : { images: input.images }
|
|
607
|
+
};
|
|
608
|
+
this.#messages.set(message.id, message);
|
|
609
|
+
return message;
|
|
610
|
+
}
|
|
611
|
+
};
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/core/conversations/ConversationManager.ts
|
|
614
|
+
/**
|
|
615
|
+
* The registry of {@link Conversation}s keyed by `id`, in insertion order, WITH an active pointer
|
|
616
|
+
* — the §9 store over the conversation layer PLUS the `active` / `switch` seam the
|
|
617
|
+
* {@link import('../AgentContext.js').AgentContext} renders. Event-free (a registry, like
|
|
618
|
+
* {@link import('../workspaces/WorkspaceManager.js').WorkspaceManager}); the observability lives
|
|
619
|
+
* on each {@link Conversation}.
|
|
620
|
+
*
|
|
621
|
+
* @remarks
|
|
622
|
+
* - **Registry.** Conversations live in an insertion-ordered `Map` keyed by `id`. `add(input?)`
|
|
623
|
+
* mints a {@link Conversation} (its `id` from `input` or `crypto.randomUUID()`), flowing the
|
|
624
|
+
* manager's default `#summarize` / `#keep` in unless the `input` OVERRIDES them, and stores
|
|
625
|
+
* it (an already-present `id` OVERWRITES — last write wins). `count` is the map size,
|
|
626
|
+
* `conversation(id)` looks one up, `conversations()` lists them in insertion order.
|
|
627
|
+
* - **Active pointer.** `active` is the active conversation (the agent's message source the
|
|
628
|
+
* context renders), `undefined` until the FIRST `add` (which auto-activates it — a registry
|
|
629
|
+
* with conversations always has one active). A subsequent `add` leaves `active` unchanged.
|
|
630
|
+
* `switch(id)` re-points `active` to the conversation with `id` and returns it; an unknown `id`
|
|
631
|
+
* returns `undefined` and leaves `active` unchanged (the lenient lookup style — never throws,
|
|
632
|
+
* no new error code).
|
|
633
|
+
* - **Removal.** `remove` drops one by id, or a batch (§9.2) — `true` when any was removed;
|
|
634
|
+
* removing the ACTIVE conversation sets `active` to `undefined`. `clear` empties the registry
|
|
635
|
+
* and sets `active` to `undefined`.
|
|
636
|
+
* - **Event-free.** A purely registry store — no Emitter, no events (each conversation owns
|
|
637
|
+
* its own observable `emitter`).
|
|
638
|
+
*
|
|
639
|
+
* @example
|
|
640
|
+
* ```ts
|
|
641
|
+
* const manager = new ConversationManager({ summarize: async (m) => `recap of ${m.length}` })
|
|
642
|
+
* const conversation = manager.add() // auto-activates — active === conversation
|
|
643
|
+
* manager.add({ id: 'scratch' }) // leaves active unchanged
|
|
644
|
+
* manager.switch('scratch') // re-points active to the 'scratch' conversation
|
|
645
|
+
* manager.count // 2
|
|
646
|
+
* ```
|
|
647
|
+
*/
|
|
648
|
+
var ConversationManager = class {
|
|
649
|
+
#conversations = /* @__PURE__ */ new Map();
|
|
650
|
+
#active;
|
|
651
|
+
#summarize;
|
|
652
|
+
#keep;
|
|
653
|
+
#store;
|
|
654
|
+
constructor(options) {
|
|
655
|
+
this.#summarize = options?.summarize;
|
|
656
|
+
this.#keep = options?.keep ?? 0;
|
|
657
|
+
this.#store = options?.store;
|
|
658
|
+
}
|
|
659
|
+
get count() {
|
|
660
|
+
return this.#conversations.size;
|
|
661
|
+
}
|
|
662
|
+
get active() {
|
|
663
|
+
return this.#active === void 0 ? void 0 : this.#conversations.get(this.#active);
|
|
664
|
+
}
|
|
665
|
+
conversation(id) {
|
|
666
|
+
return this.#conversations.get(id);
|
|
667
|
+
}
|
|
668
|
+
conversations() {
|
|
669
|
+
return [...this.#conversations.values()];
|
|
670
|
+
}
|
|
671
|
+
add(input) {
|
|
672
|
+
const conversation = new Conversation({
|
|
673
|
+
...input?.id === void 0 ? {} : { id: input.id },
|
|
674
|
+
...input?.on === void 0 ? {} : { on: input.on },
|
|
675
|
+
summarize: input?.summarize ?? this.#summarize,
|
|
676
|
+
keep: input?.keep ?? this.#keep
|
|
677
|
+
}, input?.snapshot);
|
|
678
|
+
this.#conversations.set(conversation.id, conversation);
|
|
679
|
+
if (this.#active === void 0) this.#active = conversation.id;
|
|
680
|
+
return conversation;
|
|
681
|
+
}
|
|
682
|
+
switch(id) {
|
|
683
|
+
const conversation = this.#conversations.get(id);
|
|
684
|
+
if (conversation === void 0) return void 0;
|
|
685
|
+
this.#active = id;
|
|
686
|
+
return conversation;
|
|
687
|
+
}
|
|
688
|
+
async open(id) {
|
|
689
|
+
const existing = this.#conversations.get(id);
|
|
690
|
+
if (existing !== void 0) {
|
|
691
|
+
this.#active = id;
|
|
692
|
+
return existing;
|
|
693
|
+
}
|
|
694
|
+
if (this.#store === void 0) return void 0;
|
|
695
|
+
const snapshot = await this.#store.get(id);
|
|
696
|
+
if (snapshot === void 0) return void 0;
|
|
697
|
+
const conversation = this.add({ snapshot });
|
|
698
|
+
this.#active = conversation.id;
|
|
699
|
+
return conversation;
|
|
700
|
+
}
|
|
701
|
+
async save(id) {
|
|
702
|
+
const conversation = this.#conversations.get(id);
|
|
703
|
+
if (this.#store === void 0 || conversation === void 0) return false;
|
|
704
|
+
await this.#store.set(conversation.snapshot());
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
remove(ids) {
|
|
708
|
+
if ((0, _orkestrel_contract.isArray)(ids)) {
|
|
709
|
+
let removed = false;
|
|
710
|
+
for (const id of ids) if (this.#drop(id)) removed = true;
|
|
711
|
+
return removed;
|
|
712
|
+
}
|
|
713
|
+
return this.#drop(ids);
|
|
714
|
+
}
|
|
715
|
+
clear() {
|
|
716
|
+
this.#conversations.clear();
|
|
717
|
+
this.#active = void 0;
|
|
718
|
+
}
|
|
719
|
+
#drop(id) {
|
|
720
|
+
const removed = this.#conversations.delete(id);
|
|
721
|
+
if (removed && this.#active === id) this.#active = void 0;
|
|
722
|
+
return removed;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/core/helpers.ts
|
|
727
|
+
/**
|
|
728
|
+
* Infer a fenced-code language tag from a file path's extension — what a workspace text file
|
|
729
|
+
* (or the {@link fencedFile} renderer) renders its content block as.
|
|
730
|
+
*
|
|
731
|
+
* @remarks
|
|
732
|
+
* Reads the extension after the last `.` (case-insensitive) and maps it through a
|
|
733
|
+
* fixed extension→language table. An unknown extension, or a path with no extension,
|
|
734
|
+
* falls back to `'text'` (a safe, language-agnostic fence). Total — never throws.
|
|
735
|
+
*
|
|
736
|
+
* @param path - The document's file path (e.g. `'src/main.ts'`)
|
|
737
|
+
* @returns The inferred language tag (e.g. `'typescript'`), or `'text'` when unknown
|
|
738
|
+
*
|
|
739
|
+
* @example
|
|
740
|
+
* ```ts
|
|
741
|
+
* inferLanguage('src/main.ts') // 'typescript'
|
|
742
|
+
* inferLanguage('README.md') // 'markdown'
|
|
743
|
+
* inferLanguage('LICENSE') // 'text'
|
|
744
|
+
* ```
|
|
745
|
+
*/
|
|
746
|
+
function inferLanguage(path) {
|
|
747
|
+
const dot = path.lastIndexOf(".");
|
|
748
|
+
if (dot === -1) return "text";
|
|
749
|
+
return EXTENSION_TO_LANGUAGE[path.slice(dot + 1).toLowerCase()] ?? "text";
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Filter a list of items by a {@link import('./types.js').ScopeInterface} allow-list of
|
|
753
|
+
* keys — the pure, total set-membership primitive the context's build step and the agent
|
|
754
|
+
* loop's tool-advertise step apply a scope through.
|
|
755
|
+
*
|
|
756
|
+
* @remarks
|
|
757
|
+
* Three-way by the allow-list's shape, so a `Scope` category cleanly expresses "all /
|
|
758
|
+
* none / only these":
|
|
759
|
+
* - `undefined` ⇒ NO constraint — every item passes (returned unchanged).
|
|
760
|
+
* - `[]` (empty) ⇒ NONE pass (no key is in an empty set).
|
|
761
|
+
* - a non-empty list ⇒ only items whose `key(item)` is in the list pass.
|
|
762
|
+
*
|
|
763
|
+
* Order-preserving (it filters `items` in place order, never reorders) and total — never
|
|
764
|
+
* throws. Keys are matched by a `Set` for O(1) membership, so a large list is cheap.
|
|
765
|
+
*
|
|
766
|
+
* @typeParam T - The item type being filtered
|
|
767
|
+
* @param allow - The allow-list of keys (`undefined` ⇒ all, `[]` ⇒ none, else only-listed)
|
|
768
|
+
* @param items - The items to filter (returned unchanged when `allow` is `undefined`)
|
|
769
|
+
* @param key - Extracts the key an item is matched on (e.g. an instruction's `name`)
|
|
770
|
+
* @returns The items that pass the allow-list, in their original order
|
|
771
|
+
*
|
|
772
|
+
* @example
|
|
773
|
+
* ```ts
|
|
774
|
+
* const items = [{ name: 'a' }, { name: 'b' }]
|
|
775
|
+
* filterAllowList(undefined, items, (i) => i.name) // [{ name: 'a' }, { name: 'b' }] (all)
|
|
776
|
+
* filterAllowList([], items, (i) => i.name) // [] (none)
|
|
777
|
+
* filterAllowList(['b'], items, (i) => i.name) // [{ name: 'b' }] (only listed)
|
|
778
|
+
* ```
|
|
779
|
+
*/
|
|
780
|
+
function filterAllowList(allow, items, key) {
|
|
781
|
+
if (allow === void 0) return items;
|
|
782
|
+
if (allow.length === 0) return [];
|
|
783
|
+
const set = new Set(allow);
|
|
784
|
+
return items.filter((item) => set.has(key(item)));
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Estimate the context-token footprint of a string — the deterministic char-based heuristic
|
|
788
|
+
* {@link estimateMessages} sums over a conversation's messages (the default context-budget
|
|
789
|
+
* estimator).
|
|
790
|
+
*
|
|
791
|
+
* @remarks
|
|
792
|
+
* Approximates `ceil(length / 4)` (≈ four characters per token — the rough average for
|
|
793
|
+
* English text), so the same input always yields the same estimate (no model round-trip).
|
|
794
|
+
* Empty text is `0`. This is a planning heuristic for reasoning about how much a turn's
|
|
795
|
+
* messages cost the next request, NOT an exact tokenizer count — it never calls a provider,
|
|
796
|
+
* so the agent layer stays provider-agnostic and synchronous where it can be.
|
|
797
|
+
*
|
|
798
|
+
* @param text - The text to estimate (a section summary, a message's content)
|
|
799
|
+
* @returns The estimated token count (`ceil(text.length / 4)`; `0` for empty text)
|
|
800
|
+
*
|
|
801
|
+
* @example
|
|
802
|
+
* ```ts
|
|
803
|
+
* estimateTokens('') // 0
|
|
804
|
+
* estimateTokens('hello') // 2 (ceil(5 / 4))
|
|
805
|
+
* estimateTokens('a'.repeat(40)) // 10
|
|
806
|
+
* ```
|
|
807
|
+
*/
|
|
808
|
+
function estimateTokens(text) {
|
|
809
|
+
return Math.ceil(text.length / 4);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Estimate the context-token footprint of a batch of messages — the default `consume`
|
|
813
|
+
* estimator for an agent's context `BudgetInterface` (a budgets surface's tracking contract)
|
|
814
|
+
* (the {@link import('./types.js').AgentOptions} `window`).
|
|
815
|
+
*
|
|
816
|
+
* @remarks
|
|
817
|
+
* Sums {@link estimateTokens} over each message's `content` (the `ceil(length / 4)` char
|
|
818
|
+
* heuristic), so it is deterministic and provider-free — the same messages always yield the
|
|
819
|
+
* same estimate, with an empty batch `0`. It is the fully-swappable default an agent's
|
|
820
|
+
* auto-compaction context budget charges each turn's new messages through; a caller wanting a
|
|
821
|
+
* sharper count supplies its own `consume` to `createBudget` instead. Total — never throws.
|
|
822
|
+
*
|
|
823
|
+
* @param messages - The messages to estimate (a turn's appended assistant + tool messages)
|
|
824
|
+
* @returns The summed estimated token count (`Σ estimateTokens(m.content)`; `0` when empty)
|
|
825
|
+
*
|
|
826
|
+
* @example
|
|
827
|
+
* ```ts
|
|
828
|
+
* estimateMessages([]) // 0
|
|
829
|
+
* estimateMessages([{ id: '1', role: 'user', content: 'hello' }]) // 2
|
|
830
|
+
* ```
|
|
831
|
+
*/
|
|
832
|
+
function estimateMessages(messages) {
|
|
833
|
+
return messages.reduce((sum, message) => sum + estimateTokens(message.content), 0);
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Run one rehydrated agent and apply the partial-as-configurable-failure policy — the
|
|
837
|
+
* shared job-handler step BOTH `createAgentQueue` and `createAgentRunner` settle each job
|
|
838
|
+
* through, so the policy can never diverge between them.
|
|
839
|
+
*
|
|
840
|
+
* @remarks
|
|
841
|
+
* A turn that committed PARTIAL (a cancel — abort / budget / timeout) is by default a
|
|
842
|
+
* FAILURE, so it THROWS an {@link import('./errors.js').AgentJobError} carrying the partial
|
|
843
|
+
* (the Queue's retries + a Runner's fail-fast then engage); with `allowPartial` it RESOLVES
|
|
844
|
+
* the partial as success instead. A natural finish ALWAYS resolves with its result.
|
|
845
|
+
*
|
|
846
|
+
* @param agent - The rehydrated {@link AgentInterface} to run to its {@link AgentResult}
|
|
847
|
+
* @param allowPartial - When `true`, a partial result resolves as success; when `false`
|
|
848
|
+
* (the default policy), a partial result throws an {@link AgentJobError}
|
|
849
|
+
* @returns The agent's {@link AgentResult} (a natural finish, or a partial when `allowPartial`)
|
|
850
|
+
* @throws {AgentJobError} When the run ended `partial` and `allowPartial` is `false`
|
|
851
|
+
*
|
|
852
|
+
* @example
|
|
853
|
+
* ```ts
|
|
854
|
+
* const result = await settleAgentJob(registry.build(input, signal), false)
|
|
855
|
+
* ```
|
|
856
|
+
*/
|
|
857
|
+
async function settleAgentJob(agent, allowPartial) {
|
|
858
|
+
const result = await agent.generate();
|
|
859
|
+
if (result.partial && !allowPartial) throw new AgentJobError("agent job ended partial", result);
|
|
860
|
+
return result;
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Whether a {@link FileContent} is the TEXT arm — the narrowing guard for the text-vs-binary
|
|
864
|
+
* union (AGENTS §14: narrow an untyped arm via a guard, never an `as`).
|
|
865
|
+
*
|
|
866
|
+
* @remarks
|
|
867
|
+
* Tests `'text' in content` structurally — there is no `modality` discriminant. A `true`
|
|
868
|
+
* narrows `content` to `{ text: string; language: string }`, unlocking `.text` / `.language`.
|
|
869
|
+
* Total — never throws.
|
|
870
|
+
*
|
|
871
|
+
* @param content - The file content to test
|
|
872
|
+
* @returns `true` when `content` is the text arm (carries `text` + `language`)
|
|
873
|
+
*
|
|
874
|
+
* @example
|
|
875
|
+
* ```ts
|
|
876
|
+
* if (isText(file.content)) file.content.text // the literal text + .language
|
|
877
|
+
* ```
|
|
878
|
+
*/
|
|
879
|
+
function isText(content) {
|
|
880
|
+
return "text" in content;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Whether a {@link FileContent} is the BINARY arm — the narrowing guard for the text-vs-binary
|
|
884
|
+
* union (AGENTS §14: narrow an untyped arm via a guard, never an `as`).
|
|
885
|
+
*
|
|
886
|
+
* @remarks
|
|
887
|
+
* Tests `'data' in content` structurally — there is no `modality` discriminant. A `true`
|
|
888
|
+
* narrows `content` to `{ data: string; mime: BinaryMIME }`, unlocking `.data` / `.mime`. An
|
|
889
|
+
* image is a binary whose `mime` starts with `image/` ({@link isImage}). Total — never throws.
|
|
890
|
+
*
|
|
891
|
+
* @param content - The file content to test
|
|
892
|
+
* @returns `true` when `content` is the binary arm (carries base64 `data` + `mime`)
|
|
893
|
+
*
|
|
894
|
+
* @example
|
|
895
|
+
* ```ts
|
|
896
|
+
* if (isBinary(file.content)) file.content.data // the base64 payload + .mime
|
|
897
|
+
* ```
|
|
898
|
+
*/
|
|
899
|
+
function isBinary(content) {
|
|
900
|
+
return "data" in content;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Whether a {@link FileContent} is an IMAGE — a {@link isBinary} arm whose `mime` is an
|
|
904
|
+
* `image/*` type (an image is just a binary with an image MIME).
|
|
905
|
+
*
|
|
906
|
+
* @remarks
|
|
907
|
+
* Narrows to the binary arm first, then checks the `image/` MIME prefix — so a future
|
|
908
|
+
* non-image binary (a PDF, `'application/pdf'`) is binary but NOT an image. Total — never
|
|
909
|
+
* throws.
|
|
910
|
+
*
|
|
911
|
+
* @param content - The file content to test
|
|
912
|
+
* @returns `true` when `content` is a binary arm with an `image/*` MIME
|
|
913
|
+
*
|
|
914
|
+
* @example
|
|
915
|
+
* ```ts
|
|
916
|
+
* isImage({ data: '<base64>', mime: 'image/png' }) // true
|
|
917
|
+
* isImage({ text: 'hi', language: 'text' }) // false
|
|
918
|
+
* ```
|
|
919
|
+
*/
|
|
920
|
+
function isImage(content) {
|
|
921
|
+
return isBinary(content) && content.mime.startsWith("image/");
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Whether an `unknown` is structurally a {@link FileInterface} record — the per-file step of the
|
|
925
|
+
* {@link isWorkspaceSnapshot} read-boundary narrow (AGENTS §14: narrow an untrusted storage read
|
|
926
|
+
* via a guard, never an `as`).
|
|
927
|
+
*
|
|
928
|
+
* @remarks
|
|
929
|
+
* A total guard (it NEVER throws — adversarial input returns `false`). It checks the file's SHAPE:
|
|
930
|
+
* a record with a `string` `path`, a `string` `state`, `number` `size` / `lines`, and a `content`
|
|
931
|
+
* that is EITHER the TEXT arm (`{ text: string; language: string }`) OR the BINARY arm
|
|
932
|
+
* (`{ data: string; mime: string }`) — the tagless {@link FileContent} union, told apart
|
|
933
|
+
* structurally exactly as {@link isText} / {@link isBinary} do (no `modality` discriminant). Enough
|
|
934
|
+
* to safely impose the {@link FileInterface} type at a storage boundary WITHOUT a cast; the `mime`
|
|
935
|
+
* is left as a broad `string` here (an open {@link BinaryMIME}, so any storage-read MIME string is
|
|
936
|
+
* accepted defensively rather than rejected against the current literal set).
|
|
937
|
+
*
|
|
938
|
+
* @param value - The value to test (one element of a snapshot's opaque `files` array)
|
|
939
|
+
* @returns `true` when `value` has the structural shape of a {@link FileInterface}
|
|
940
|
+
*
|
|
941
|
+
* @example
|
|
942
|
+
* ```ts
|
|
943
|
+
* isFile({ path: 'a.ts', content: { text: 'x', language: 'typescript' }, state: 'created', size: 1, lines: 1 }) // true
|
|
944
|
+
* isFile({ path: 'a.png', content: { data: 'AAAA', mime: 'image/png' }, state: 'created', size: 3, lines: 0 }) // true
|
|
945
|
+
* isFile({ path: 'a.ts' }) // false (missing content / state / size / lines)
|
|
946
|
+
* ```
|
|
947
|
+
*/
|
|
948
|
+
function isFile(value) {
|
|
949
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return false;
|
|
950
|
+
if (!(0, _orkestrel_contract.isString)(value.path) || !(0, _orkestrel_contract.isString)(value.state)) return false;
|
|
951
|
+
if (!(0, _orkestrel_contract.isNumber)(value.size) || !(0, _orkestrel_contract.isNumber)(value.lines)) return false;
|
|
952
|
+
if (!(0, _orkestrel_contract.isRecord)(value.content)) return false;
|
|
953
|
+
const text = (0, _orkestrel_contract.isString)(value.content.text) && (0, _orkestrel_contract.isString)(value.content.language);
|
|
954
|
+
const binary = (0, _orkestrel_contract.isString)(value.content.data) && (0, _orkestrel_contract.isString)(value.content.mime);
|
|
955
|
+
return text || binary;
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Narrow an `unknown` to a {@link WorkspaceSnapshot} — the AGENTS §14 boundary guard for an
|
|
959
|
+
* UNTRUSTED snapshot read (a storage row a
|
|
960
|
+
* {@link import('./workspaces/stores/DatabaseWorkspaceStore.js').DatabaseWorkspaceStore} reads back
|
|
961
|
+
* from its opaque JSON column, a snapshot loaded from disk).
|
|
962
|
+
*
|
|
963
|
+
* @remarks
|
|
964
|
+
* A total guard (it NEVER throws — adversarial input returns `false`). It checks the snapshot's
|
|
965
|
+
* SHAPE: a `string` `id` and a `files` array EVERY element of which is a valid {@link FileInterface}
|
|
966
|
+
* record ({@link isFile}) — enough to safely impose the {@link WorkspaceSnapshot} type at a storage
|
|
967
|
+
* boundary WITHOUT a cast. The structural twin of
|
|
968
|
+
* the analogous `isWorkflowSnapshot` in `@orkestrel/workflow`. A malformed blob (a non-record, a
|
|
969
|
+
* missing / non-string `id`, a non-array `files`, or any malformed file element) resolves `false`,
|
|
970
|
+
* so a {@link import('./workspaces/stores/DatabaseWorkspaceStore.js').DatabaseWorkspaceStore} read
|
|
971
|
+
* yields `undefined` rather than a broken workspace.
|
|
972
|
+
*
|
|
973
|
+
* @param value - The value to test (an opaque storage read)
|
|
974
|
+
* @returns `true` when `value` has the structural shape of a {@link WorkspaceSnapshot}
|
|
975
|
+
*
|
|
976
|
+
* @example
|
|
977
|
+
* ```ts
|
|
978
|
+
* isWorkspaceSnapshot({ id: 'w1', files: [] }) // true
|
|
979
|
+
* isWorkspaceSnapshot({ id: 'w1', files: 'nope' }) // false
|
|
980
|
+
* isWorkspaceSnapshot({ files: [] }) // false (missing id)
|
|
981
|
+
* ```
|
|
982
|
+
*/
|
|
983
|
+
function isWorkspaceSnapshot(value) {
|
|
984
|
+
return (0, _orkestrel_contract.isRecord)(value) && (0, _orkestrel_contract.isString)(value.id) && (0, _orkestrel_contract.isArray)(value.files) && value.files.every(isFile);
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Whether an `unknown` is structurally a {@link ToolCall} record — the per-call step of the
|
|
988
|
+
* {@link isMessage} read-boundary narrow (AGENTS §14: narrow an untrusted storage read via a
|
|
989
|
+
* guard, never an `as`).
|
|
990
|
+
*
|
|
991
|
+
* @remarks
|
|
992
|
+
* A total guard (it NEVER throws — adversarial input returns `false`). It checks the call's
|
|
993
|
+
* SHAPE: a record with a `string` `id`, a `string` `name`, and a record `arguments` — the real
|
|
994
|
+
* {@link ToolCall} shape a restored assistant turn replays to a provider. This is the ASI06
|
|
995
|
+
* fail-CLOSED element check: a tampered persisted call (`null`, a bare string, a non-record
|
|
996
|
+
* `arguments`) fails the guard, so {@link isMessage} — and through it
|
|
997
|
+
* {@link isConversationSnapshot} — rejects the whole snapshot and a poisoned store row reads
|
|
998
|
+
* back as ABSENT instead of replaying a malformed call into a chat template.
|
|
999
|
+
*
|
|
1000
|
+
* @param value - The value to test (one element of an assistant message's `calls`)
|
|
1001
|
+
* @returns `true` when `value` has the structural shape of a {@link ToolCall}
|
|
1002
|
+
*
|
|
1003
|
+
* @example
|
|
1004
|
+
* ```ts
|
|
1005
|
+
* isToolCall({ id: 'c1', name: 'search', arguments: { q: 'acme' } }) // true
|
|
1006
|
+
* isToolCall({ id: 'c1', name: 'search' }) // false (missing arguments)
|
|
1007
|
+
* isToolCall({ id: 'c1', name: 123, arguments: {} }) // false (non-string name)
|
|
1008
|
+
* ```
|
|
1009
|
+
*/
|
|
1010
|
+
function isToolCall(value) {
|
|
1011
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return false;
|
|
1012
|
+
return (0, _orkestrel_contract.isString)(value.id) && (0, _orkestrel_contract.isString)(value.name) && (0, _orkestrel_contract.isRecord)(value.arguments);
|
|
1013
|
+
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Project a {@link ToolResult} into the MCP `CallToolResult` shape — a top-level `error`
|
|
1016
|
+
* maps to a single `isError: true` text block (the failure reason), otherwise the `value`
|
|
1017
|
+
* is JSON-stringified into a single text block with no `isError`.
|
|
1018
|
+
*
|
|
1019
|
+
* @param result - The {@link ToolResult} to project (as returned by {@link import('./tools/ToolManager.js').ToolManager.execute})
|
|
1020
|
+
* @returns The equivalent {@link ToolCallResult}
|
|
1021
|
+
*
|
|
1022
|
+
* @example
|
|
1023
|
+
* ```ts
|
|
1024
|
+
* buildToolResult({ id: 'c1', name: 'search', error: 'max depth' })
|
|
1025
|
+
* // { content: [{ type: 'text', text: 'max depth' }], isError: true }
|
|
1026
|
+
* buildToolResult({ id: 'c1', name: 'search', value: { count: 1 } })
|
|
1027
|
+
* // { content: [{ type: 'text', text: '{"count":1}' }] }
|
|
1028
|
+
* ```
|
|
1029
|
+
*/
|
|
1030
|
+
function buildToolResult(result) {
|
|
1031
|
+
if (result.error !== void 0) return {
|
|
1032
|
+
content: [{
|
|
1033
|
+
type: "text",
|
|
1034
|
+
text: result.error
|
|
1035
|
+
}],
|
|
1036
|
+
isError: true
|
|
1037
|
+
};
|
|
1038
|
+
return { content: [{
|
|
1039
|
+
type: "text",
|
|
1040
|
+
text: JSON.stringify(result.value)
|
|
1041
|
+
}] };
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Whether an `unknown` is structurally a {@link MessageInterface} record — the per-message step of
|
|
1045
|
+
* the {@link isConversationSnapshot} read-boundary narrow (AGENTS §14: narrow an untrusted storage
|
|
1046
|
+
* read via a guard, never an `as`). The conversation analogue of {@link isFile}.
|
|
1047
|
+
*
|
|
1048
|
+
* @remarks
|
|
1049
|
+
* A total guard (it NEVER throws — adversarial input returns `false`). It checks the message's
|
|
1050
|
+
* SHAPE: a record with a `string` `id`, a `string` `role`, a `string` `content`, and — WHEN present
|
|
1051
|
+
* — a `calls` array EVERY element of which is a valid {@link ToolCall} ({@link isToolCall} — the
|
|
1052
|
+
* ASI06 fail-closed deepening: a tampered `calls` element rejects the message, so the snapshot
|
|
1053
|
+
* reads back as absent rather than replaying a malformed call) and an `images` that is an array
|
|
1054
|
+
* (an absent optional passes). The `role` is left as a broad `string` here (an open
|
|
1055
|
+
* {@link import('./types.js').MessageRole}, so any storage-read role string is accepted
|
|
1056
|
+
* defensively rather than rejected against the current literal set) — exactly as {@link isFile}
|
|
1057
|
+
* accepts a broad `state` / `mime`. Enough to safely impose the {@link MessageInterface} type at
|
|
1058
|
+
* a storage boundary WITHOUT a cast.
|
|
1059
|
+
*
|
|
1060
|
+
* @param value - The value to test (one element of a snapshot's `messages` / a section's `messages`)
|
|
1061
|
+
* @returns `true` when `value` has the structural shape of a {@link MessageInterface}
|
|
1062
|
+
*
|
|
1063
|
+
* @example
|
|
1064
|
+
* ```ts
|
|
1065
|
+
* isMessage({ id: '1', role: 'user', content: 'hi' }) // true
|
|
1066
|
+
* isMessage({ id: '1', role: 'assistant', content: '', calls: [] }) // true
|
|
1067
|
+
* isMessage({ id: '1', role: 'user' }) // false (missing content)
|
|
1068
|
+
* isMessage({ id: '1', role: 'assistant', content: '', calls: [null] }) // false (malformed call)
|
|
1069
|
+
* ```
|
|
1070
|
+
*/
|
|
1071
|
+
function isMessage(value) {
|
|
1072
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return false;
|
|
1073
|
+
if (!(0, _orkestrel_contract.isString)(value.id) || !(0, _orkestrel_contract.isString)(value.role) || !(0, _orkestrel_contract.isString)(value.content)) return false;
|
|
1074
|
+
if (value.calls !== void 0 && !((0, _orkestrel_contract.isArray)(value.calls) && value.calls.every(isToolCall))) return false;
|
|
1075
|
+
return value.images === void 0 || (0, _orkestrel_contract.isArray)(value.images);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Whether an `unknown` is structurally a {@link SectionInterface} record — the per-section step of
|
|
1079
|
+
* the {@link isConversationSnapshot} read-boundary narrow (AGENTS §14: narrow an untrusted storage
|
|
1080
|
+
* read via a guard, never an `as`).
|
|
1081
|
+
*
|
|
1082
|
+
* @remarks
|
|
1083
|
+
* A total guard (it NEVER throws — adversarial input returns `false`). It checks the section's
|
|
1084
|
+
* SHAPE: a record with a `string` `id`, a `string` `summary`, and a `messages` array EVERY element
|
|
1085
|
+
* of which is a valid {@link MessageInterface} record ({@link isMessage}). Enough to safely impose
|
|
1086
|
+
* the {@link SectionInterface} type at a storage boundary WITHOUT a cast.
|
|
1087
|
+
*
|
|
1088
|
+
* @param value - The value to test (one element of a snapshot's `sections` array)
|
|
1089
|
+
* @returns `true` when `value` has the structural shape of a {@link SectionInterface}
|
|
1090
|
+
*
|
|
1091
|
+
* @example
|
|
1092
|
+
* ```ts
|
|
1093
|
+
* isSection({ id: 's', summary: 'recap', messages: [{ id: '1', role: 'user', content: 'hi' }] }) // true
|
|
1094
|
+
* isSection({ id: 's', summary: 'recap', messages: 'nope' }) // false
|
|
1095
|
+
* isSection({ id: 's', messages: [] }) // false (missing summary)
|
|
1096
|
+
* ```
|
|
1097
|
+
*/
|
|
1098
|
+
function isSection(value) {
|
|
1099
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return false;
|
|
1100
|
+
if (!(0, _orkestrel_contract.isString)(value.id) || !(0, _orkestrel_contract.isString)(value.summary)) return false;
|
|
1101
|
+
return (0, _orkestrel_contract.isArray)(value.messages) && value.messages.every(isMessage);
|
|
1102
|
+
}
|
|
1103
|
+
/**
|
|
1104
|
+
* Narrow an `unknown` to a {@link ConversationSnapshot} — the AGENTS §14 boundary guard for an
|
|
1105
|
+
* UNTRUSTED snapshot read (a storage row a
|
|
1106
|
+
* {@link import('./conversations/stores/DatabaseConversationStore.js').DatabaseConversationStore}
|
|
1107
|
+
* reads back from its opaque JSON column, a snapshot loaded from disk). The EXACT analogue of
|
|
1108
|
+
* {@link isWorkspaceSnapshot}.
|
|
1109
|
+
*
|
|
1110
|
+
* @remarks
|
|
1111
|
+
* A total guard (it NEVER throws — adversarial input returns `false`). It checks the snapshot's
|
|
1112
|
+
* SHAPE: a `string` `id`, an OPTIONAL `string` `summary` (present-or-absent — the rollup is
|
|
1113
|
+
* `undefined` until the first compaction), a `sections` array EVERY element of which is a valid
|
|
1114
|
+
* {@link SectionInterface} ({@link isSection}), and a `messages` array EVERY element of which is a
|
|
1115
|
+
* valid {@link MessageInterface} ({@link isMessage}) — enough to safely impose the
|
|
1116
|
+
* {@link ConversationSnapshot} type at a storage boundary WITHOUT a cast. The structural twin of
|
|
1117
|
+
* {@link isWorkspaceSnapshot}. A malformed blob (a non-record, a missing / non-string `id`, a
|
|
1118
|
+
* non-string `summary` when present, a non-array `sections` / `messages`, or any malformed
|
|
1119
|
+
* element) resolves `false`, so a
|
|
1120
|
+
* {@link import('./conversations/stores/DatabaseConversationStore.js').DatabaseConversationStore}
|
|
1121
|
+
* read yields `undefined` rather than a broken conversation.
|
|
1122
|
+
*
|
|
1123
|
+
* @param value - The value to test (an opaque storage read)
|
|
1124
|
+
* @returns `true` when `value` has the structural shape of a {@link ConversationSnapshot}
|
|
1125
|
+
*
|
|
1126
|
+
* @example
|
|
1127
|
+
* ```ts
|
|
1128
|
+
* isConversationSnapshot({ id: 'c1', sections: [], messages: [] }) // true
|
|
1129
|
+
* isConversationSnapshot({ id: 'c1', summary: 'recap', sections: [], messages: [] }) // true
|
|
1130
|
+
* isConversationSnapshot({ id: 'c1', sections: 'nope', messages: [] }) // false
|
|
1131
|
+
* isConversationSnapshot({ sections: [], messages: [] }) // false (missing id)
|
|
1132
|
+
* ```
|
|
1133
|
+
*/
|
|
1134
|
+
function isConversationSnapshot(value) {
|
|
1135
|
+
if (!(0, _orkestrel_contract.isRecord)(value)) return false;
|
|
1136
|
+
if (!(0, _orkestrel_contract.isString)(value.id)) return false;
|
|
1137
|
+
if (value.summary !== void 0 && !(0, _orkestrel_contract.isString)(value.summary)) return false;
|
|
1138
|
+
if (!(0, _orkestrel_contract.isArray)(value.sections) || !value.sections.every(isSection)) return false;
|
|
1139
|
+
return (0, _orkestrel_contract.isArray)(value.messages) && value.messages.every(isMessage);
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Compute the byte size of a {@link FileContent} — the `size` a {@link FileInterface} derives
|
|
1143
|
+
* once when built.
|
|
1144
|
+
*
|
|
1145
|
+
* @remarks
|
|
1146
|
+
* Dispatches on {@link isText}: a text arm is its UTF-8 byte length (via `new
|
|
1147
|
+
* TextEncoder().encode(text).length` — the standard Web/Node API, so a multi-byte
|
|
1148
|
+
* character like `'é'` or `'😀'` counts as its real encoded bytes, NOT its character
|
|
1149
|
+
* count); a binary arm is the decoded payload byte length of its base64 `data` (via
|
|
1150
|
+
* {@link decodedSize}, computed arithmetically — no `atob` / `Buffer`). Total — never throws.
|
|
1151
|
+
*
|
|
1152
|
+
* @param content - The file content to size
|
|
1153
|
+
* @returns The byte size (UTF-8 bytes for text; decoded payload bytes for binary)
|
|
1154
|
+
*
|
|
1155
|
+
* @example
|
|
1156
|
+
* ```ts
|
|
1157
|
+
* computeSize({ text: 'café', language: 'text' }) // 5 (é is two bytes)
|
|
1158
|
+
* computeSize({ data: 'AAAA', mime: 'image/png' }) // 3
|
|
1159
|
+
* ```
|
|
1160
|
+
*/
|
|
1161
|
+
function computeSize(content) {
|
|
1162
|
+
if (isText(content)) return new TextEncoder().encode(content.text).length;
|
|
1163
|
+
return decodedSize(content.data);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Count the lines of a {@link FileContent} — the `lines` a {@link FileInterface} derives once
|
|
1167
|
+
* when built.
|
|
1168
|
+
*
|
|
1169
|
+
* @remarks
|
|
1170
|
+
* A text arm's line count is `0` for the empty string, otherwise one more than the
|
|
1171
|
+
* number of newline (`\n`) separators it contains — so `'a'` is one line, `'a\nb'` is two,
|
|
1172
|
+
* and a trailing newline `'a\n'` counts the empty final line as two. A binary arm has no
|
|
1173
|
+
* lines and returns `0`. Total — never throws.
|
|
1174
|
+
*
|
|
1175
|
+
* @param content - The file content to count lines for
|
|
1176
|
+
* @returns The line count (text line count; `0` for a binary arm or empty text)
|
|
1177
|
+
*
|
|
1178
|
+
* @example
|
|
1179
|
+
* ```ts
|
|
1180
|
+
* countLines({ text: '', language: 'text' }) // 0
|
|
1181
|
+
* countLines({ text: 'a\nb\nc', language: 'text' }) // 3
|
|
1182
|
+
* countLines({ data: 'AAAA', mime: 'image/png' }) // 0
|
|
1183
|
+
* ```
|
|
1184
|
+
*/
|
|
1185
|
+
function countLines(content) {
|
|
1186
|
+
if (!isText(content)) return 0;
|
|
1187
|
+
if (content.text.length === 0) return 0;
|
|
1188
|
+
let count = 1;
|
|
1189
|
+
for (const character of content.text) if (character === "\n") count += 1;
|
|
1190
|
+
return count;
|
|
1191
|
+
}
|
|
1192
|
+
/**
|
|
1193
|
+
* Compute the decoded byte length of a base64 string ARITHMETICALLY — the image-payload
|
|
1194
|
+
* sizing primitive {@link computeSize} uses, with no `atob` / `Buffer` decode.
|
|
1195
|
+
*
|
|
1196
|
+
* @remarks
|
|
1197
|
+
* A base64 string encodes each group of 3 input bytes as 4 characters, so a well-formed
|
|
1198
|
+
* string's length is a multiple of 4 and its decoded length is `(length / 4) * 3` MINUS
|
|
1199
|
+
* the trailing `=` padding (one `=` ⇒ the last group held 2 bytes, two `==` ⇒ 1 byte).
|
|
1200
|
+
* The padding is counted from the trailing `=` characters, so both the `=` and `==` cases
|
|
1201
|
+
* are handled. The empty string decodes to `0`. Total — never throws (a malformed,
|
|
1202
|
+
* non-multiple-of-4 length still yields a defined non-negative estimate via `floor`).
|
|
1203
|
+
*
|
|
1204
|
+
* @param base64 - The base64-encoded string (e.g. an image's `data`)
|
|
1205
|
+
* @returns The decoded payload's byte length
|
|
1206
|
+
*
|
|
1207
|
+
* @example
|
|
1208
|
+
* ```ts
|
|
1209
|
+
* decodedSize('') // 0
|
|
1210
|
+
* decodedSize('AAAA') // 3 (no padding)
|
|
1211
|
+
* decodedSize('AAA=') // 2 (one '=')
|
|
1212
|
+
* decodedSize('AA==') // 1 (two '=')
|
|
1213
|
+
* ```
|
|
1214
|
+
*/
|
|
1215
|
+
function decodedSize(base64) {
|
|
1216
|
+
if (base64.length === 0) return 0;
|
|
1217
|
+
let padding = 0;
|
|
1218
|
+
if (base64.endsWith("==")) padding = 2;
|
|
1219
|
+
else if (base64.endsWith("=")) padding = 1;
|
|
1220
|
+
return Math.floor(base64.length / 4) * 3 - padding;
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Whether a {@link Range} is STRUCTURALLY valid — the predicate a ranged `write` checks
|
|
1224
|
+
* before applying (a `false` here is the `RANGE` throw).
|
|
1225
|
+
*
|
|
1226
|
+
* @remarks
|
|
1227
|
+
* Structural validity is independent of any content: every component must be `>= 1`
|
|
1228
|
+
* (1-based lines and columns), and `start` must not come after `end` (an inverted range
|
|
1229
|
+
* is invalid). A range that is structurally valid but reaches PAST the end of a specific
|
|
1230
|
+
* text is still valid — it is {@link clampRange}d to the bounds when applied, not rejected.
|
|
1231
|
+
* Total — never throws.
|
|
1232
|
+
*
|
|
1233
|
+
* @param range - The range to validate
|
|
1234
|
+
* @returns `true` when every component is `>= 1` and `start` is at or before `end`
|
|
1235
|
+
*
|
|
1236
|
+
* @example
|
|
1237
|
+
* ```ts
|
|
1238
|
+
* isValidRange({ start: { line: 1, column: 1 }, end: { line: 2, column: 1 } }) // true
|
|
1239
|
+
* isValidRange({ start: { line: 2, column: 1 }, end: { line: 1, column: 1 } }) // false (inverted)
|
|
1240
|
+
* isValidRange({ start: { line: 0, column: 1 }, end: { line: 1, column: 1 } }) // false (sub-1 line)
|
|
1241
|
+
* ```
|
|
1242
|
+
*/
|
|
1243
|
+
function isValidRange(range) {
|
|
1244
|
+
if (range.start.line < 1 || range.start.column < 1) return false;
|
|
1245
|
+
if (range.end.line < 1 || range.end.column < 1) return false;
|
|
1246
|
+
if (range.start.line > range.end.line) return false;
|
|
1247
|
+
return !(range.start.line === range.end.line && range.start.column > range.end.column);
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Clamp a 1-based {@link Position} to the bounds of `text` — every component pinned into a
|
|
1251
|
+
* caret that actually exists in the content.
|
|
1252
|
+
*
|
|
1253
|
+
* @remarks
|
|
1254
|
+
* `line` is pinned to `[1, lineCount]` and `column` to `[1, lineLength + 1]` of the
|
|
1255
|
+
* resolved line (column `lineLength + 1` is the caret just past the line's last
|
|
1256
|
+
* character). So a position beyond the end of the text resolves to the end rather than
|
|
1257
|
+
* overflowing. Total — never throws.
|
|
1258
|
+
*
|
|
1259
|
+
* @param text - The text the position addresses
|
|
1260
|
+
* @param position - The 1-based position to clamp
|
|
1261
|
+
* @returns The clamped {@link Position}
|
|
1262
|
+
*
|
|
1263
|
+
* @example
|
|
1264
|
+
* ```ts
|
|
1265
|
+
* clampPosition('ab\ncd', { line: 9, column: 9 }) // { line: 2, column: 3 } (end of 'cd')
|
|
1266
|
+
* ```
|
|
1267
|
+
*/
|
|
1268
|
+
function clampPosition(text, position) {
|
|
1269
|
+
const lines = text.split("\n");
|
|
1270
|
+
const line = Math.max(1, Math.min(position.line, lines.length));
|
|
1271
|
+
const lineText = lines[line - 1] ?? "";
|
|
1272
|
+
return {
|
|
1273
|
+
line,
|
|
1274
|
+
column: Math.max(1, Math.min(position.column, lineText.length + 1))
|
|
1275
|
+
};
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Clamp both ends of a {@link Range} to the bounds of `text` — the actual span a ranged
|
|
1279
|
+
* read / write applies (and the `range` a {@link import('./types.js').ReadResult} reports).
|
|
1280
|
+
*
|
|
1281
|
+
* @remarks
|
|
1282
|
+
* Clamps `start` and `end` independently via {@link clampPosition}, so a range reaching
|
|
1283
|
+
* past the end of the content is trimmed to the content's end. Total — never throws.
|
|
1284
|
+
*
|
|
1285
|
+
* @param text - The text the range addresses
|
|
1286
|
+
* @param range - The 1-based range to clamp
|
|
1287
|
+
* @returns The clamped {@link Range}
|
|
1288
|
+
*
|
|
1289
|
+
* @example
|
|
1290
|
+
* ```ts
|
|
1291
|
+
* clampRange('ab\ncd', { start: { line: 1, column: 1 }, end: { line: 9, column: 9 } })
|
|
1292
|
+
* // { start: { line: 1, column: 1 }, end: { line: 2, column: 3 } }
|
|
1293
|
+
* ```
|
|
1294
|
+
*/
|
|
1295
|
+
function clampRange(text, range) {
|
|
1296
|
+
return {
|
|
1297
|
+
start: clampPosition(text, range.start),
|
|
1298
|
+
end: clampPosition(text, range.end)
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Convert a 1-based {@link Position} to a 0-based string offset into `text` — the indexing
|
|
1303
|
+
* primitive {@link sliceRange} / {@link spliceRange} use, clamped to `text.length`.
|
|
1304
|
+
*
|
|
1305
|
+
* @remarks
|
|
1306
|
+
* Sums each preceding line's length plus its `\n` separator, then adds the `column - 1`
|
|
1307
|
+
* within the target line, capped at `text.length`. Total — never throws (an out-of-bounds
|
|
1308
|
+
* position yields a defined in-range offset).
|
|
1309
|
+
*
|
|
1310
|
+
* @param text - The text to index into
|
|
1311
|
+
* @param position - The 1-based position to resolve
|
|
1312
|
+
* @returns The 0-based offset (in `[0, text.length]`)
|
|
1313
|
+
*
|
|
1314
|
+
* @example
|
|
1315
|
+
* ```ts
|
|
1316
|
+
* offsetAt('ab\ncd', { line: 2, column: 1 }) // 3 (just after 'ab\n')
|
|
1317
|
+
* ```
|
|
1318
|
+
*/
|
|
1319
|
+
function offsetAt(text, position) {
|
|
1320
|
+
const lines = text.split("\n");
|
|
1321
|
+
let offset = 0;
|
|
1322
|
+
for (let index = 0; index < position.line - 1 && index < lines.length; index += 1) offset += (lines[index]?.length ?? 0) + 1;
|
|
1323
|
+
offset += position.column - 1;
|
|
1324
|
+
return Math.min(offset, text.length);
|
|
1325
|
+
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Slice the substring of `text` spanned by a {@link Range} (start INCLUSIVE, end
|
|
1328
|
+
* EXCLUSIVE), clamping the range to the text's bounds first — the read half of the ranged
|
|
1329
|
+
* edit surface.
|
|
1330
|
+
*
|
|
1331
|
+
* @remarks
|
|
1332
|
+
* Clamps via {@link clampRange}, resolves each end to an offset via {@link offsetAt}, and
|
|
1333
|
+
* returns `text.slice(startOffset, endOffset)`. Total — never throws.
|
|
1334
|
+
*
|
|
1335
|
+
* @param text - The text to slice
|
|
1336
|
+
* @param range - The 1-based range to extract
|
|
1337
|
+
* @returns The spanned substring (empty when the clamped span is empty)
|
|
1338
|
+
*
|
|
1339
|
+
* @example
|
|
1340
|
+
* ```ts
|
|
1341
|
+
* sliceRange('hello\nworld', { start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }) // 'hello'
|
|
1342
|
+
* ```
|
|
1343
|
+
*/
|
|
1344
|
+
function sliceRange(text, range) {
|
|
1345
|
+
const clamped = clampRange(text, range);
|
|
1346
|
+
return text.slice(offsetAt(text, clamped.start), offsetAt(text, clamped.end));
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Replace the span of `text` covered by a {@link Range} with `replacement` (start
|
|
1350
|
+
* INCLUSIVE, end EXCLUSIVE), clamping the range to the text's bounds first — the write
|
|
1351
|
+
* half of the ranged edit surface.
|
|
1352
|
+
*
|
|
1353
|
+
* @remarks
|
|
1354
|
+
* Clamps via {@link clampRange}, then stitches `before + replacement + after` around the
|
|
1355
|
+
* resolved offsets. An empty span (`start === end`) becomes a pure insertion. Total —
|
|
1356
|
+
* never throws.
|
|
1357
|
+
*
|
|
1358
|
+
* @param text - The original text
|
|
1359
|
+
* @param range - The 1-based range to overwrite
|
|
1360
|
+
* @param replacement - The text to splice in place of the spanned range
|
|
1361
|
+
* @returns The text with the spanned range replaced by `replacement`
|
|
1362
|
+
*
|
|
1363
|
+
* @example
|
|
1364
|
+
* ```ts
|
|
1365
|
+
* spliceRange('hello', { start: { line: 1, column: 1 }, end: { line: 1, column: 6 } }, 'bye') // 'bye'
|
|
1366
|
+
* ```
|
|
1367
|
+
*/
|
|
1368
|
+
function spliceRange(text, range, replacement) {
|
|
1369
|
+
const clamped = clampRange(text, range);
|
|
1370
|
+
const start = offsetAt(text, clamped.start);
|
|
1371
|
+
const end = offsetAt(text, clamped.end);
|
|
1372
|
+
return text.slice(0, start) + replacement + text.slice(end);
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Assemble a 1-based nested {@link Range} from the four FLAT caret integers of the workspace tool's
|
|
1376
|
+
* `'splice'` operation — the bridge from the small-model FLAT surface
|
|
1377
|
+
* ({@link import('./types.js').WorkspaceOperation}) back to the nested {@link Range} the
|
|
1378
|
+
* {@link import('./workspaces/Workspace.js').Workspace} edit surface speaks.
|
|
1379
|
+
*
|
|
1380
|
+
* @remarks
|
|
1381
|
+
* Pairs `(fromLine, fromColumn)` into `start` and `(toLine, toColumn)` into `end` verbatim — a pure
|
|
1382
|
+
* structural lift, no validation (a structurally invalid range is rejected downstream by the ranged
|
|
1383
|
+
* `write` it feeds, via {@link isValidRange}). Total — never throws. Zero-Node.
|
|
1384
|
+
*
|
|
1385
|
+
* @param fromLine - The 1-based start line
|
|
1386
|
+
* @param fromColumn - The 1-based start column
|
|
1387
|
+
* @param toLine - The 1-based end line
|
|
1388
|
+
* @param toColumn - The 1-based end column
|
|
1389
|
+
* @returns The `{ start, end }` {@link Range}
|
|
1390
|
+
*
|
|
1391
|
+
* @example
|
|
1392
|
+
* ```ts
|
|
1393
|
+
* rangeOf(1, 11, 1, 12) // { start: { line: 1, column: 11 }, end: { line: 1, column: 12 } }
|
|
1394
|
+
* ```
|
|
1395
|
+
*/
|
|
1396
|
+
function rangeOf(fromLine, fromColumn, toLine, toColumn) {
|
|
1397
|
+
return {
|
|
1398
|
+
start: {
|
|
1399
|
+
line: fromLine,
|
|
1400
|
+
column: fromColumn
|
|
1401
|
+
},
|
|
1402
|
+
end: {
|
|
1403
|
+
line: toLine,
|
|
1404
|
+
column: toColumn
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Render a path-addressed text body as a fenced reference block — the framing an
|
|
1410
|
+
* {@link import('./AgentContext.js').AgentContext}'s ACTIVE-workspace text-file render emits (the
|
|
1411
|
+
* active workspace is the SOLE document/image context).
|
|
1412
|
+
*
|
|
1413
|
+
* @remarks
|
|
1414
|
+
* Produces `` File: <path>\n```<language>\n<content>\n``` `` — the `File:` label line, then a
|
|
1415
|
+
* fenced code block tagged with `language`, the `content` verbatim inside. Pure string assembly,
|
|
1416
|
+
* total — never throws. The one fenced-file format string for the whole module — `AgentContext.build()`
|
|
1417
|
+
* frames an active workspace's text files with it (each carries its own `language` on its
|
|
1418
|
+
* {@link FileContent} text arm).
|
|
1419
|
+
*
|
|
1420
|
+
* @param path - The file path shown on the `File:` label line
|
|
1421
|
+
* @param language - The fenced-code language tag (e.g. `'typescript'`)
|
|
1422
|
+
* @param content - The file body rendered verbatim inside the fence
|
|
1423
|
+
* @returns The fenced reference block
|
|
1424
|
+
*
|
|
1425
|
+
* @example
|
|
1426
|
+
* ```ts
|
|
1427
|
+
* import { fencedFile } from '@src/core'
|
|
1428
|
+
*
|
|
1429
|
+
* fencedFile('src/main.ts', 'typescript', 'const x = 1')
|
|
1430
|
+
* // 'File: src/main.ts\n```typescript\nconst x = 1\n```'
|
|
1431
|
+
* ```
|
|
1432
|
+
*/
|
|
1433
|
+
function fencedFile(path, language, content) {
|
|
1434
|
+
return `File: ${path}\n\`\`\`${language}\n${content}\n\`\`\``;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Escape a string's regex-special characters so it matches LITERALLY inside a `RegExp` — the
|
|
1438
|
+
* primitive a {@link import('./workspaces/Workspace.js').Workspace} search builds a literal-text
|
|
1439
|
+
* search pattern through (as opposed to a caller-supplied regex pattern).
|
|
1440
|
+
*
|
|
1441
|
+
* @remarks
|
|
1442
|
+
* Prefixes every character in the class `. * + ? ^ $ { } ( ) | [ ] \` with a backslash, so the
|
|
1443
|
+
* escaped string, when compiled into a `RegExp`, matches only its own literal characters — no
|
|
1444
|
+
* character acts as a quantifier, anchor, group, or class. Pure string assembly, total — never
|
|
1445
|
+
* throws.
|
|
1446
|
+
*
|
|
1447
|
+
* @param value - The text to escape for literal use inside a `RegExp` pattern
|
|
1448
|
+
* @returns `value` with every regex-special character backslash-escaped
|
|
1449
|
+
*
|
|
1450
|
+
* @example
|
|
1451
|
+
* ```ts
|
|
1452
|
+
* escapeRegExp('a.b*c') // 'a\\.b\\*c'
|
|
1453
|
+
* new RegExp(escapeRegExp('a.b*c')).test('a.b*c') // true
|
|
1454
|
+
* ```
|
|
1455
|
+
*/
|
|
1456
|
+
function escapeRegExp(value) {
|
|
1457
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1458
|
+
}
|
|
1459
|
+
//#endregion
|
|
1460
|
+
//#region src/core/instructions/Instruction.ts
|
|
1461
|
+
/**
|
|
1462
|
+
* An immutable named directive — a {@link InstructionInterface} assembled once from its
|
|
1463
|
+
* input (`name` / `content`, an optional `priority` defaulting to `0`), the `id` minted
|
|
1464
|
+
* at construction.
|
|
1465
|
+
*
|
|
1466
|
+
* @remarks
|
|
1467
|
+
* A thin immutable value object (mirroring {@link import('../tools/Tool.js').Tool}): the
|
|
1468
|
+
* constructor mints a fresh `id` (`crypto.randomUUID()`), copies the input's `name` /
|
|
1469
|
+
* `content`, resolves `priority` to the input's value or `0`, and carries the input's
|
|
1470
|
+
* per-item `format` override ONLY when supplied (assigned just when present, mirroring a
|
|
1471
|
+
* message's `images` / `calls` present-when-given convention — kept absent otherwise).
|
|
1472
|
+
* Never mutated after construction. An
|
|
1473
|
+
* {@link import('./InstructionManager.js').InstructionManager} keys it by `name` and
|
|
1474
|
+
* renders it (highest `priority` first) under its section header.
|
|
1475
|
+
*
|
|
1476
|
+
* @example
|
|
1477
|
+
* ```ts
|
|
1478
|
+
* const instruction = new Instruction({ name: 'tone', content: 'Be concise.', priority: 5 })
|
|
1479
|
+
* instruction.priority // 5
|
|
1480
|
+
* ```
|
|
1481
|
+
*/
|
|
1482
|
+
var Instruction = class {
|
|
1483
|
+
id;
|
|
1484
|
+
name;
|
|
1485
|
+
content;
|
|
1486
|
+
priority;
|
|
1487
|
+
format;
|
|
1488
|
+
constructor(input) {
|
|
1489
|
+
this.id = crypto.randomUUID();
|
|
1490
|
+
this.name = input.name;
|
|
1491
|
+
this.content = input.content;
|
|
1492
|
+
this.priority = input.priority ?? 0;
|
|
1493
|
+
if (input.format !== void 0) this.format = input.format;
|
|
1494
|
+
}
|
|
1495
|
+
};
|
|
1496
|
+
//#endregion
|
|
1497
|
+
//#region src/core/instructions/InstructionManager.ts
|
|
1498
|
+
/**
|
|
1499
|
+
* The instruction registry a richer context assembles a directives block from —
|
|
1500
|
+
* immutable {@link Instruction}s keyed by `name`, listed by descending `priority`.
|
|
1501
|
+
*
|
|
1502
|
+
* @remarks
|
|
1503
|
+
* - **Registry.** Instructions live in an insertion-ordered `Map` keyed by `name`;
|
|
1504
|
+
* `add` takes one {@link InstructionInput} or a batch (§9.2), MINTS each instruction's
|
|
1505
|
+
* `id`, and a re-`add` of the same name OVERWRITES it (last write wins). `count` is the
|
|
1506
|
+
* map size, `instruction(name)` looks one up, and `instructions()` lists them SORTED by
|
|
1507
|
+
* descending `priority` (a stable sort, so equal priorities keep insertion order).
|
|
1508
|
+
* - **Build contract (with the manager-options override).** `description` is the section
|
|
1509
|
+
* header a context renders the instructions under; `format(instruction)` renders one
|
|
1510
|
+
* instruction (its `content`). Each ENCAPSULATES the cascade's `[options-override →
|
|
1511
|
+
* built-in]` half: when `InstructionManagerOptions.format` supplies an `open` /
|
|
1512
|
+
* `render`, `description` / `format` return IT, else the built-in — so a richer context
|
|
1513
|
+
* reads one consistent pair and layers the provider default + per-item override on top
|
|
1514
|
+
* (see {@link import('../AgentContext.js').AgentContext}). The per-item
|
|
1515
|
+
* {@link InstructionInput.format} is round-tripped onto the stored instruction.
|
|
1516
|
+
* - **Removal.** `remove` drops one by name, or a batch (§9.2) — `true` when any was
|
|
1517
|
+
* removed; `clear` empties the registry.
|
|
1518
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link InstructionManagerEventMap})
|
|
1519
|
+
* carries `add` (the created instruction) / `remove` (the name) / `clear` for
|
|
1520
|
+
* fire-and-forget observers. Every event is emitted directly, strictly AFTER the map
|
|
1521
|
+
* mutation completes; the emitter isolates a listener throw and routes it to its `error`
|
|
1522
|
+
* handler (the `error` option), so a buggy observer can never corrupt a mutation.
|
|
1523
|
+
*
|
|
1524
|
+
* @example
|
|
1525
|
+
* ```ts
|
|
1526
|
+
* const manager = new InstructionManager()
|
|
1527
|
+
* manager.add([
|
|
1528
|
+
* { name: 'tone', content: 'Be concise.', priority: 1 },
|
|
1529
|
+
* { name: 'safety', content: 'Refuse unsafe requests.', priority: 10 },
|
|
1530
|
+
* ])
|
|
1531
|
+
* manager.instructions().map((one) => one.name) // ['safety', 'tone'] — highest priority first
|
|
1532
|
+
* ```
|
|
1533
|
+
*/
|
|
1534
|
+
var InstructionManager = class {
|
|
1535
|
+
#instructions = /* @__PURE__ */ new Map();
|
|
1536
|
+
#emitter;
|
|
1537
|
+
#format;
|
|
1538
|
+
constructor(options) {
|
|
1539
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1540
|
+
on: options?.on,
|
|
1541
|
+
error: options?.error
|
|
1542
|
+
});
|
|
1543
|
+
this.#format = options?.format;
|
|
1544
|
+
}
|
|
1545
|
+
get emitter() {
|
|
1546
|
+
return this.#emitter;
|
|
1547
|
+
}
|
|
1548
|
+
get count() {
|
|
1549
|
+
return this.#instructions.size;
|
|
1550
|
+
}
|
|
1551
|
+
get description() {
|
|
1552
|
+
return this.#format?.open ?? "## Instructions";
|
|
1553
|
+
}
|
|
1554
|
+
get framing() {
|
|
1555
|
+
return this.#format;
|
|
1556
|
+
}
|
|
1557
|
+
add(input) {
|
|
1558
|
+
if ((0, _orkestrel_contract.isArray)(input)) return input.map((one) => this.#create(one));
|
|
1559
|
+
return this.#create(input);
|
|
1560
|
+
}
|
|
1561
|
+
instruction(name) {
|
|
1562
|
+
return this.#instructions.get(name);
|
|
1563
|
+
}
|
|
1564
|
+
instructions() {
|
|
1565
|
+
return [...this.#instructions.values()].sort((a, b) => b.priority - a.priority);
|
|
1566
|
+
}
|
|
1567
|
+
format(instruction) {
|
|
1568
|
+
return this.#format?.render?.(instruction) ?? instruction.content;
|
|
1569
|
+
}
|
|
1570
|
+
remove(names) {
|
|
1571
|
+
if ((0, _orkestrel_contract.isArray)(names)) {
|
|
1572
|
+
let removed = false;
|
|
1573
|
+
for (const name of names) if (this.#delete(name)) removed = true;
|
|
1574
|
+
return removed;
|
|
1575
|
+
}
|
|
1576
|
+
return this.#delete(names);
|
|
1577
|
+
}
|
|
1578
|
+
clear() {
|
|
1579
|
+
this.#instructions.clear();
|
|
1580
|
+
this.#emitter.emit("clear");
|
|
1581
|
+
}
|
|
1582
|
+
#create(input) {
|
|
1583
|
+
const instruction = new Instruction(input);
|
|
1584
|
+
this.#instructions.set(instruction.name, instruction);
|
|
1585
|
+
this.#emitter.emit("add", instruction);
|
|
1586
|
+
return instruction;
|
|
1587
|
+
}
|
|
1588
|
+
#delete(name) {
|
|
1589
|
+
const removed = this.#instructions.delete(name);
|
|
1590
|
+
if (removed) this.#emitter.emit("remove", name);
|
|
1591
|
+
return removed;
|
|
1592
|
+
}
|
|
1593
|
+
};
|
|
1594
|
+
//#endregion
|
|
1595
|
+
//#region src/core/tools/ToolManager.ts
|
|
1596
|
+
/**
|
|
1597
|
+
* The tool registry the agent loop dispatches model tool-calls through — resolves
|
|
1598
|
+
* names, lists {@link ToolDefinition}s for the provider, and executes calls with
|
|
1599
|
+
* per-call error isolation.
|
|
1600
|
+
*
|
|
1601
|
+
* @remarks
|
|
1602
|
+
* - **Registry.** Tools live in an insertion-ordered `Map` keyed by `tool.name`;
|
|
1603
|
+
* `add` takes one or a batch (§9.2) and a re-`add` of the same name OVERWRITES it
|
|
1604
|
+
* (last write wins). `count` is the map size, `tool(name)` looks one up, `tools()`
|
|
1605
|
+
* lists them in insertion order, and `definitions()` maps each to a plain
|
|
1606
|
+
* {@link ToolDefinition} (`name` / `description?` / `parameters?`, the `execute`
|
|
1607
|
+
* handler stripped) — exactly what a provider advertises to the model.
|
|
1608
|
+
* - **Per-call error isolation (the load-bearing part).** `execute` resolves a
|
|
1609
|
+
* {@link ToolCall}'s tool by name and ALWAYS resolves a {@link ToolResult}: an
|
|
1610
|
+
* unknown name → `{ id, name, error: 'tool not found: <name>' }`; a successful run →
|
|
1611
|
+
* `{ id, name, value }`; a handler throw is CAUGHT into `{ id, name, error }` (an
|
|
1612
|
+
* `Error`'s message, else the stringified throw). A tool throw never escapes — it
|
|
1613
|
+
* becomes a result the model can react to.
|
|
1614
|
+
* - **Batch never fails as a whole.** `execute(calls)` runs every call via
|
|
1615
|
+
* `Promise.all(calls.map(...))` and resolves the results correlated by `id` in the
|
|
1616
|
+
* input order — a mix of success, throw, and not-found all resolve; one bad call
|
|
1617
|
+
* does not reject the batch.
|
|
1618
|
+
* - **Event-free.** A purely functional registry — no Emitter, no events.
|
|
1619
|
+
*
|
|
1620
|
+
* @example
|
|
1621
|
+
* ```ts
|
|
1622
|
+
* const manager = new ToolManager()
|
|
1623
|
+
* manager.add(new Tool({ name: 'add', execute: (a) => Number(a.x) + Number(a.y) }))
|
|
1624
|
+
* const result = await manager.execute({ id: '1', name: 'add', arguments: { x: 1, y: 2 } })
|
|
1625
|
+
* result.value // 3
|
|
1626
|
+
* ```
|
|
1627
|
+
*/
|
|
1628
|
+
var ToolManager = class {
|
|
1629
|
+
#tools = /* @__PURE__ */ new Map();
|
|
1630
|
+
get count() {
|
|
1631
|
+
return this.#tools.size;
|
|
1632
|
+
}
|
|
1633
|
+
add(tools) {
|
|
1634
|
+
if ((0, _orkestrel_contract.isArray)(tools)) {
|
|
1635
|
+
for (const tool of tools) this.#tools.set(tool.name, tool);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
this.#tools.set(tools.name, tools);
|
|
1639
|
+
}
|
|
1640
|
+
tool(name) {
|
|
1641
|
+
return this.#tools.get(name);
|
|
1642
|
+
}
|
|
1643
|
+
tools() {
|
|
1644
|
+
return [...this.#tools.values()];
|
|
1645
|
+
}
|
|
1646
|
+
definitions() {
|
|
1647
|
+
return [...this.#tools.values()].map((tool) => this.#definition(tool));
|
|
1648
|
+
}
|
|
1649
|
+
execute(call) {
|
|
1650
|
+
if ((0, _orkestrel_contract.isArray)(call)) return Promise.all(call.map((one) => this.#run(one)));
|
|
1651
|
+
return this.#run(call);
|
|
1652
|
+
}
|
|
1653
|
+
remove(names) {
|
|
1654
|
+
if ((0, _orkestrel_contract.isArray)(names)) {
|
|
1655
|
+
let removed = false;
|
|
1656
|
+
for (const name of names) if (this.#tools.delete(name)) removed = true;
|
|
1657
|
+
return removed;
|
|
1658
|
+
}
|
|
1659
|
+
return this.#tools.delete(names);
|
|
1660
|
+
}
|
|
1661
|
+
clear() {
|
|
1662
|
+
this.#tools.clear();
|
|
1663
|
+
}
|
|
1664
|
+
async #run(call) {
|
|
1665
|
+
const tool = this.#tools.get(call.name);
|
|
1666
|
+
if (tool === void 0) return {
|
|
1667
|
+
id: call.id,
|
|
1668
|
+
name: call.name,
|
|
1669
|
+
error: `tool not found: ${call.name}`
|
|
1670
|
+
};
|
|
1671
|
+
try {
|
|
1672
|
+
const value = await tool.execute(call.arguments);
|
|
1673
|
+
return {
|
|
1674
|
+
id: call.id,
|
|
1675
|
+
name: call.name,
|
|
1676
|
+
value
|
|
1677
|
+
};
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
return {
|
|
1680
|
+
id: call.id,
|
|
1681
|
+
name: call.name,
|
|
1682
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
#definition(tool) {
|
|
1687
|
+
const definition = { name: tool.name };
|
|
1688
|
+
if (tool.description !== void 0) definition.description = tool.description;
|
|
1689
|
+
if (tool.parameters !== void 0) definition.parameters = tool.parameters;
|
|
1690
|
+
return definition;
|
|
1691
|
+
}
|
|
1692
|
+
};
|
|
1693
|
+
//#endregion
|
|
1694
|
+
//#region src/core/workspaces/Workspace.ts
|
|
1695
|
+
/**
|
|
1696
|
+
* A mutable, `path`-keyed working set of immutable {@link FileInterface}s — the
|
|
1697
|
+
* in-memory editing surface over the file primitive.
|
|
1698
|
+
*
|
|
1699
|
+
* @remarks
|
|
1700
|
+
* - **Registry.** Files live in an insertion-ordered `Map` keyed by `path`; `count` is the
|
|
1701
|
+
* map size, `file(path)` looks one up, and `files()` lists them in insertion order.
|
|
1702
|
+
* - **Write replaces the File (§11 immutability).** Every edit MINTS a replacement
|
|
1703
|
+
* {@link FileInterface} (via {@link import('../factories.js').createFile}) rather than
|
|
1704
|
+
* mutating in place, transitioning `state` to `'created'` for a brand-new path or
|
|
1705
|
+
* `'modified'` for an existing one. A whole-file string write preserves an existing text
|
|
1706
|
+
* file's `language`, else infers it from the `path` (`inferLanguage`); writing a string
|
|
1707
|
+
* onto an existing BINARY path replaces it with a text file (a deliberate retype). A
|
|
1708
|
+
* ranged `write` splices a `Range` of an existing text file.
|
|
1709
|
+
* - **Modality rules.** Text-only ops on a binary file are rejected: a ranged `read` /
|
|
1710
|
+
* `write`, `prepend`, and `append` throw `MODALITY`; a plain `read(path)` of a binary
|
|
1711
|
+
* file returns `undefined`; `search` / `replace` skip binary files (0 matches).
|
|
1712
|
+
* - **In-memory removal.** `remove(path)` / `remove(paths)` drop files outright (no
|
|
1713
|
+
* tombstone — the `'loaded'` / `'deleted'` states are reserved for a future FileStore);
|
|
1714
|
+
* `remove()` and `clear()` both empty the registry and emit the single `clear` signal.
|
|
1715
|
+
* The disk/sync lifecycle (`load` / `revert` / `accept` / `purge` / `dirty`) is NOT part
|
|
1716
|
+
* of this surface.
|
|
1717
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link WorkspaceEventMap}) carries
|
|
1718
|
+
* `write` (the resulting file) / `remove` (the path) / `move` (`{ from, to }`) / `clear`.
|
|
1719
|
+
* Every event is emitted directly, strictly AFTER the map mutation completes; the
|
|
1720
|
+
* emitter isolates a listener throw and routes it to its `error` handler, so a buggy
|
|
1721
|
+
* observer can never corrupt a mutation.
|
|
1722
|
+
*
|
|
1723
|
+
* @example
|
|
1724
|
+
* ```ts
|
|
1725
|
+
* const workspace = new Workspace()
|
|
1726
|
+
* workspace.write('src/main.ts', 'const x = 1')
|
|
1727
|
+
* workspace.file('src/main.ts')?.state // 'created'
|
|
1728
|
+
* workspace.append('src/main.ts', '\nconst y = 2')
|
|
1729
|
+
* workspace.file('src/main.ts')?.state // 'modified'
|
|
1730
|
+
* workspace.read('src/main.ts') // 'const x = 1\nconst y = 2'
|
|
1731
|
+
* ```
|
|
1732
|
+
*/
|
|
1733
|
+
var Workspace = class {
|
|
1734
|
+
#id;
|
|
1735
|
+
#files = /* @__PURE__ */ new Map();
|
|
1736
|
+
#emitter;
|
|
1737
|
+
constructor(options, seed) {
|
|
1738
|
+
this.#id = options?.id ?? crypto.randomUUID();
|
|
1739
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
1740
|
+
on: options?.on,
|
|
1741
|
+
error: options?.error
|
|
1742
|
+
});
|
|
1743
|
+
if (seed) for (const [path, file] of seed) this.#files.set(path, file);
|
|
1744
|
+
}
|
|
1745
|
+
get id() {
|
|
1746
|
+
return this.#id;
|
|
1747
|
+
}
|
|
1748
|
+
get emitter() {
|
|
1749
|
+
return this.#emitter;
|
|
1750
|
+
}
|
|
1751
|
+
get count() {
|
|
1752
|
+
return this.#files.size;
|
|
1753
|
+
}
|
|
1754
|
+
file(path) {
|
|
1755
|
+
return this.#files.get(path);
|
|
1756
|
+
}
|
|
1757
|
+
files() {
|
|
1758
|
+
return [...this.#files.values()];
|
|
1759
|
+
}
|
|
1760
|
+
read(path, range) {
|
|
1761
|
+
if ((0, _orkestrel_contract.isArray)(path)) {
|
|
1762
|
+
const result = {};
|
|
1763
|
+
for (const one of path) {
|
|
1764
|
+
const file = this.#files.get(one);
|
|
1765
|
+
if (file && isText(file.content)) result[one] = file.content.text;
|
|
1766
|
+
}
|
|
1767
|
+
return result;
|
|
1768
|
+
}
|
|
1769
|
+
const file = this.#files.get(path);
|
|
1770
|
+
if (!file) return void 0;
|
|
1771
|
+
if (!range) return isText(file.content) ? file.content.text : void 0;
|
|
1772
|
+
if (!isText(file.content)) throw new WorkspaceError("MODALITY", `Cannot read a range of a binary file: ${path}`, { path });
|
|
1773
|
+
return {
|
|
1774
|
+
content: sliceRange(file.content.text, range),
|
|
1775
|
+
range: clampRange(file.content.text, range)
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
has(path) {
|
|
1779
|
+
if ((0, _orkestrel_contract.isArray)(path)) return path.some((one) => this.#files.has(one));
|
|
1780
|
+
return this.#files.has(path);
|
|
1781
|
+
}
|
|
1782
|
+
search(query, options) {
|
|
1783
|
+
const pattern = this.#pattern(query, options);
|
|
1784
|
+
const limit = options?.limit;
|
|
1785
|
+
const matches = [];
|
|
1786
|
+
for (const file of this.#files.values()) {
|
|
1787
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
1788
|
+
if (!isText(file.content)) continue;
|
|
1789
|
+
const lines = file.content.text.split("\n");
|
|
1790
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1791
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
1792
|
+
const lineText = lines[index] ?? "";
|
|
1793
|
+
pattern.lastIndex = 0;
|
|
1794
|
+
let hit = pattern.exec(lineText);
|
|
1795
|
+
while (hit !== null) {
|
|
1796
|
+
if (limit !== void 0 && matches.length >= limit) break;
|
|
1797
|
+
matches.push({
|
|
1798
|
+
path: file.path,
|
|
1799
|
+
line: index + 1,
|
|
1800
|
+
column: hit.index + 1,
|
|
1801
|
+
length: hit[0].length,
|
|
1802
|
+
content: lineText
|
|
1803
|
+
});
|
|
1804
|
+
if (hit[0].length === 0) pattern.lastIndex += 1;
|
|
1805
|
+
hit = pattern.exec(lineText);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
return matches;
|
|
1810
|
+
}
|
|
1811
|
+
replace(query, replacement, options) {
|
|
1812
|
+
const pattern = this.#pattern(query, options);
|
|
1813
|
+
const limit = options?.limit;
|
|
1814
|
+
let replaced = 0;
|
|
1815
|
+
let files = 0;
|
|
1816
|
+
for (const [path, file] of this.#files) {
|
|
1817
|
+
if (limit !== void 0 && replaced >= limit) break;
|
|
1818
|
+
if (!isText(file.content)) continue;
|
|
1819
|
+
const remaining = limit === void 0 ? void 0 : limit - replaced;
|
|
1820
|
+
let count = 0;
|
|
1821
|
+
pattern.lastIndex = 0;
|
|
1822
|
+
const next = file.content.text.replace(pattern, (match) => {
|
|
1823
|
+
if (remaining !== void 0 && count >= remaining) return match;
|
|
1824
|
+
count += 1;
|
|
1825
|
+
return replacement;
|
|
1826
|
+
});
|
|
1827
|
+
if (count > 0) {
|
|
1828
|
+
replaced += count;
|
|
1829
|
+
files += 1;
|
|
1830
|
+
this.write(path, next);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return {
|
|
1834
|
+
query,
|
|
1835
|
+
replaced,
|
|
1836
|
+
files
|
|
1837
|
+
};
|
|
1838
|
+
}
|
|
1839
|
+
write(path, content, range) {
|
|
1840
|
+
if ((0, _orkestrel_contract.isRecord)(path)) {
|
|
1841
|
+
for (const [one, text] of Object.entries(path)) this.#write(one, text);
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
const text = content ?? "";
|
|
1845
|
+
if (!range) {
|
|
1846
|
+
this.#write(path, text);
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1849
|
+
this.#splice(path, text, range);
|
|
1850
|
+
}
|
|
1851
|
+
prepend(path, content) {
|
|
1852
|
+
if ((0, _orkestrel_contract.isRecord)(path)) {
|
|
1853
|
+
for (const [one, text] of Object.entries(path)) this.#prepend(one, text);
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
this.#prepend(path, content ?? "");
|
|
1857
|
+
}
|
|
1858
|
+
append(path, content) {
|
|
1859
|
+
if ((0, _orkestrel_contract.isRecord)(path)) {
|
|
1860
|
+
for (const [one, text] of Object.entries(path)) this.#append(one, text);
|
|
1861
|
+
return;
|
|
1862
|
+
}
|
|
1863
|
+
this.#append(path, content ?? "");
|
|
1864
|
+
}
|
|
1865
|
+
move(from, to) {
|
|
1866
|
+
if ((0, _orkestrel_contract.isRecord)(from)) {
|
|
1867
|
+
let moved = false;
|
|
1868
|
+
for (const [one, target] of Object.entries(from)) if (this.#move(one, target)) moved = true;
|
|
1869
|
+
return moved;
|
|
1870
|
+
}
|
|
1871
|
+
return this.#move(from, to ?? "");
|
|
1872
|
+
}
|
|
1873
|
+
remove(path) {
|
|
1874
|
+
if (path === void 0) {
|
|
1875
|
+
this.#files.clear();
|
|
1876
|
+
this.#emitter.emit("clear");
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
if ((0, _orkestrel_contract.isArray)(path)) {
|
|
1880
|
+
let removed = false;
|
|
1881
|
+
for (const one of path) if (this.#remove(one)) removed = true;
|
|
1882
|
+
return removed;
|
|
1883
|
+
}
|
|
1884
|
+
return this.#remove(path);
|
|
1885
|
+
}
|
|
1886
|
+
clear() {
|
|
1887
|
+
this.#files.clear();
|
|
1888
|
+
this.#emitter.emit("clear");
|
|
1889
|
+
}
|
|
1890
|
+
snapshot() {
|
|
1891
|
+
return {
|
|
1892
|
+
id: this.#id,
|
|
1893
|
+
files: this.files()
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
#write(path, content) {
|
|
1897
|
+
const existing = this.#files.get(path);
|
|
1898
|
+
const file = createFile({
|
|
1899
|
+
path,
|
|
1900
|
+
content: {
|
|
1901
|
+
text: content,
|
|
1902
|
+
language: existing && isText(existing.content) ? existing.content.language : inferLanguage(path)
|
|
1903
|
+
},
|
|
1904
|
+
state: existing ? "modified" : "created"
|
|
1905
|
+
});
|
|
1906
|
+
this.#files.set(path, file);
|
|
1907
|
+
this.#emitter.emit("write", file);
|
|
1908
|
+
}
|
|
1909
|
+
#splice(path, content, range) {
|
|
1910
|
+
const existing = this.#files.get(path);
|
|
1911
|
+
if (!existing || !isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot splice a range of a non-text file: ${path}`, { path });
|
|
1912
|
+
if (!isValidRange(range)) throw new WorkspaceError("RANGE", `Invalid range for file: ${path}`, {
|
|
1913
|
+
path,
|
|
1914
|
+
range
|
|
1915
|
+
});
|
|
1916
|
+
const file = createFile({
|
|
1917
|
+
path,
|
|
1918
|
+
content: {
|
|
1919
|
+
text: spliceRange(existing.content.text, range, content),
|
|
1920
|
+
language: existing.content.language
|
|
1921
|
+
},
|
|
1922
|
+
state: "modified"
|
|
1923
|
+
});
|
|
1924
|
+
this.#files.set(path, file);
|
|
1925
|
+
this.#emitter.emit("write", file);
|
|
1926
|
+
}
|
|
1927
|
+
#prepend(path, content) {
|
|
1928
|
+
this.#write(path, content + this.#text(path, "prepend"));
|
|
1929
|
+
}
|
|
1930
|
+
#append(path, content) {
|
|
1931
|
+
this.#write(path, this.#text(path, "append") + content);
|
|
1932
|
+
}
|
|
1933
|
+
#text(path, operation) {
|
|
1934
|
+
const existing = this.#files.get(path);
|
|
1935
|
+
if (!existing) return "";
|
|
1936
|
+
if (!isText(existing.content)) throw new WorkspaceError("MODALITY", `Cannot ${operation} text to a binary file: ${path}`, { path });
|
|
1937
|
+
return existing.content.text;
|
|
1938
|
+
}
|
|
1939
|
+
#move(from, to) {
|
|
1940
|
+
const file = this.#files.get(from);
|
|
1941
|
+
if (!file) return false;
|
|
1942
|
+
const moved = createFile({
|
|
1943
|
+
path: to,
|
|
1944
|
+
content: file.content,
|
|
1945
|
+
state: "modified"
|
|
1946
|
+
});
|
|
1947
|
+
this.#files.delete(from);
|
|
1948
|
+
this.#files.set(to, moved);
|
|
1949
|
+
this.#emitter.emit("move", {
|
|
1950
|
+
from,
|
|
1951
|
+
to
|
|
1952
|
+
});
|
|
1953
|
+
return true;
|
|
1954
|
+
}
|
|
1955
|
+
#remove(path) {
|
|
1956
|
+
const removed = this.#files.delete(path);
|
|
1957
|
+
if (removed) this.#emitter.emit("remove", path);
|
|
1958
|
+
return removed;
|
|
1959
|
+
}
|
|
1960
|
+
#pattern(query, options) {
|
|
1961
|
+
const source = options?.regex === true ? query : escapeRegExp(query);
|
|
1962
|
+
const flags = options?.exact === false ? "gi" : "g";
|
|
1963
|
+
try {
|
|
1964
|
+
return new RegExp(source, flags);
|
|
1965
|
+
} catch {
|
|
1966
|
+
throw new WorkspaceError("PATTERN", `Invalid search pattern: ${query}`, { query });
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
};
|
|
1970
|
+
//#endregion
|
|
1971
|
+
//#region src/core/workspaces/WorkspaceManager.ts
|
|
1972
|
+
/**
|
|
1973
|
+
* The registry of {@link Workspace}s keyed by `id`, in insertion order, WITH an active pointer
|
|
1974
|
+
* — the §9 store over the workspace layer PLUS the `active` / `switch` seam the context renders.
|
|
1975
|
+
* Event-free (a registry, like {@link ConversationManager}); the observability lives on each
|
|
1976
|
+
* {@link Workspace}.
|
|
1977
|
+
*
|
|
1978
|
+
* @remarks
|
|
1979
|
+
* - **Registry.** Workspaces live in an insertion-ordered `Map` keyed by `id`. `add(input?)`
|
|
1980
|
+
* mints a {@link Workspace} (its `id` from `input` or `crypto.randomUUID()`), flowing the
|
|
1981
|
+
* manager's default `on` / `error` in unless the `input` OVERRIDES them, and stores it (an
|
|
1982
|
+
* already-present `id` OVERWRITES — last write wins). `count` is the map size, `workspace(id)`
|
|
1983
|
+
* looks one up, `workspaces()` lists them in insertion order.
|
|
1984
|
+
* - **Active pointer.** `active` is the active workspace (what the context renders), `undefined`
|
|
1985
|
+
* until the FIRST `add` (which auto-activates it — a registry with workspaces always has one
|
|
1986
|
+
* active). A subsequent `add` leaves `active` unchanged. `switch(id)` re-points `active` to the
|
|
1987
|
+
* workspace with `id` and returns it; an unknown `id` returns `undefined` and leaves `active`
|
|
1988
|
+
* unchanged (the lenient lookup style — never throws, no new error code).
|
|
1989
|
+
* - **Removal.** `remove` drops one by id, or a batch (§9.2) — `true` when any was removed;
|
|
1990
|
+
* removing the ACTIVE workspace sets `active` to `undefined`. `clear` empties the registry and
|
|
1991
|
+
* sets `active` to `undefined`.
|
|
1992
|
+
* - **Durable open / save (the optional `store`).** With a {@link WorkspaceStoreInterface} supplied
|
|
1993
|
+
* (the `store` option), `open(id)` HYDRATES a workspace on a registry miss — it `store.get(id)`s
|
|
1994
|
+
* the snapshot and rebuilds a fresh {@link Workspace} through the constructor `seed`
|
|
1995
|
+
* (`snapshot.files` → path → File), then activates it — and `save(id)` PERSISTS a registered
|
|
1996
|
+
* workspace's `snapshot()`. Both are LENIENT without a store (open resolves only registered ids,
|
|
1997
|
+
* save is a no-op `false`), consistent with the lenient `switch`.
|
|
1998
|
+
* - **Event-free.** A purely registry store — no Emitter, no events (each workspace owns its own
|
|
1999
|
+
* observable `emitter`).
|
|
2000
|
+
*
|
|
2001
|
+
* @example
|
|
2002
|
+
* ```ts
|
|
2003
|
+
* const manager = new WorkspaceManager()
|
|
2004
|
+
* const first = manager.add() // auto-activates — active === first
|
|
2005
|
+
* manager.add({ id: 'scratch' }) // leaves active unchanged
|
|
2006
|
+
* manager.switch('scratch') // re-points active to the 'scratch' workspace
|
|
2007
|
+
* manager.count // 2
|
|
2008
|
+
* ```
|
|
2009
|
+
*/
|
|
2010
|
+
var WorkspaceManager = class {
|
|
2011
|
+
#workspaces = /* @__PURE__ */ new Map();
|
|
2012
|
+
#active;
|
|
2013
|
+
#on;
|
|
2014
|
+
#error;
|
|
2015
|
+
#store;
|
|
2016
|
+
constructor(options) {
|
|
2017
|
+
this.#on = options?.on;
|
|
2018
|
+
this.#error = options?.error;
|
|
2019
|
+
this.#store = options?.store;
|
|
2020
|
+
}
|
|
2021
|
+
get count() {
|
|
2022
|
+
return this.#workspaces.size;
|
|
2023
|
+
}
|
|
2024
|
+
get active() {
|
|
2025
|
+
return this.#active === void 0 ? void 0 : this.#workspaces.get(this.#active);
|
|
2026
|
+
}
|
|
2027
|
+
workspace(id) {
|
|
2028
|
+
return this.#workspaces.get(id);
|
|
2029
|
+
}
|
|
2030
|
+
workspaces() {
|
|
2031
|
+
return [...this.#workspaces.values()];
|
|
2032
|
+
}
|
|
2033
|
+
add(input) {
|
|
2034
|
+
const workspace = new Workspace({
|
|
2035
|
+
...input?.id === void 0 ? {} : { id: input.id },
|
|
2036
|
+
on: input?.on ?? this.#on,
|
|
2037
|
+
error: input?.error ?? this.#error
|
|
2038
|
+
}, input?.seed);
|
|
2039
|
+
this.#workspaces.set(workspace.id, workspace);
|
|
2040
|
+
if (this.#active === void 0) this.#active = workspace.id;
|
|
2041
|
+
return workspace;
|
|
2042
|
+
}
|
|
2043
|
+
switch(id) {
|
|
2044
|
+
const workspace = this.#workspaces.get(id);
|
|
2045
|
+
if (workspace === void 0) return void 0;
|
|
2046
|
+
this.#active = id;
|
|
2047
|
+
return workspace;
|
|
2048
|
+
}
|
|
2049
|
+
async open(id) {
|
|
2050
|
+
const existing = this.#workspaces.get(id);
|
|
2051
|
+
if (existing !== void 0) {
|
|
2052
|
+
this.#active = id;
|
|
2053
|
+
return existing;
|
|
2054
|
+
}
|
|
2055
|
+
if (this.#store === void 0) return void 0;
|
|
2056
|
+
const snapshot = await this.#store.get(id);
|
|
2057
|
+
if (snapshot === void 0) return void 0;
|
|
2058
|
+
const workspace = this.add({
|
|
2059
|
+
id,
|
|
2060
|
+
seed: snapshot.files.map((file) => [file.path, file])
|
|
2061
|
+
});
|
|
2062
|
+
this.#active = workspace.id;
|
|
2063
|
+
return workspace;
|
|
2064
|
+
}
|
|
2065
|
+
async save(id) {
|
|
2066
|
+
const workspace = this.#workspaces.get(id);
|
|
2067
|
+
if (this.#store === void 0 || workspace === void 0) return false;
|
|
2068
|
+
await this.#store.set(workspace.snapshot());
|
|
2069
|
+
return true;
|
|
2070
|
+
}
|
|
2071
|
+
remove(ids) {
|
|
2072
|
+
if ((0, _orkestrel_contract.isArray)(ids)) {
|
|
2073
|
+
let removed = false;
|
|
2074
|
+
for (const id of ids) if (this.#drop(id)) removed = true;
|
|
2075
|
+
return removed;
|
|
2076
|
+
}
|
|
2077
|
+
return this.#drop(ids);
|
|
2078
|
+
}
|
|
2079
|
+
clear() {
|
|
2080
|
+
this.#workspaces.clear();
|
|
2081
|
+
this.#active = void 0;
|
|
2082
|
+
}
|
|
2083
|
+
#drop(id) {
|
|
2084
|
+
const removed = this.#workspaces.delete(id);
|
|
2085
|
+
if (removed && this.#active === id) this.#active = void 0;
|
|
2086
|
+
return removed;
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
//#endregion
|
|
2090
|
+
//#region src/core/AgentContext.ts
|
|
2091
|
+
/**
|
|
2092
|
+
* The richer turn context the agent loop assembles a provider request from — the optional
|
|
2093
|
+
* system prompt, the observable context managers (instructions / workspaces), the
|
|
2094
|
+
* {@link ConversationManagerInterface} message source (whose active conversation IS `messages`),
|
|
2095
|
+
* the {@link ToolManagerInterface} registry, and a mutable active {@link ScopeInterface}.
|
|
2096
|
+
*
|
|
2097
|
+
* @remarks
|
|
2098
|
+
* - **Composition.** `system` is the optional system prompt; `instructions` / `tools` /
|
|
2099
|
+
* `workspaces` / `conversations` are the registries passed in `options` (bring your own), or
|
|
2100
|
+
* fresh empty ones when omitted (so `workspaces` is ALWAYS present); `messages` is the ACTIVE
|
|
2101
|
+
* conversation's live tail (ALWAYS defined — see below). `scope` is the active filter —
|
|
2102
|
+
* `undefined` (the default) ⇒ no filtering; settable afterwards. `workspaces` / `conversations`
|
|
2103
|
+
* are likewise SETTABLE (swap the whole registry between runs).
|
|
2104
|
+
* - **The message source — the conversation registry's ACTIVE conversation.** `conversations` is a
|
|
2105
|
+
* {@link ConversationManagerInterface}; the context ENSURES it always has an active conversation
|
|
2106
|
+
* (at construction it `add`s a default when the manager has none), so the DYNAMIC `messages`
|
|
2107
|
+
* getter — `this.#conversations.active` — is ALWAYS defined. `messages` returns the active
|
|
2108
|
+
* conversation ITSELF (it owns the live tail + the message verbs directly, satisfying
|
|
2109
|
+
* {@link MessageManagerInterface} structurally — the same reference, no duplication), and
|
|
2110
|
+
* `build()` folds that conversation's `view()` (its per-section summaries + live tail) as the
|
|
2111
|
+
* AUTHORITATIVE message inclusion — the scope does NOT filter the conversation (it owns inclusion
|
|
2112
|
+
* via compaction; scope filters only instructions / tools / workspace files). Because `messages`
|
|
2113
|
+
* is read dynamically, an agent SWITCHES the active
|
|
2114
|
+
* conversation BETWEEN runs (`conversations.switch(id)`) to serve MANY threads (the real
|
|
2115
|
+
* multi-conversation pattern); switch between runs, not during a run, and use separate agents
|
|
2116
|
+
* for concurrent threads.
|
|
2117
|
+
* - **`build(format?)` — the scoped assembly + the format cascade.** It folds, in order,
|
|
2118
|
+
* the system prompt then the scope-filtered instructions → the ACTIVE workspace's text files
|
|
2119
|
+
* (each as a block: the section's resolved `open` text, each item's resolved rendering, then
|
|
2120
|
+
* any resolved `close` text) into ONE leading `system` message (prepended only when at least
|
|
2121
|
+
* one part exists), then appends the ACTIVE conversation's `view()` (the conversation owns
|
|
2122
|
+
* message inclusion via compaction — the scope does NOT filter the conversation). Each
|
|
2123
|
+
* `open` / item / `close`
|
|
2124
|
+
* resolves INDEPENDENTLY, MOST-SPECIFIC-FIRST — `build()`'s optional `format` (a provider's
|
|
2125
|
+
* per-section default) is the PROVIDER level: `open` = manager-options-override > provider >
|
|
2126
|
+
* built-in; per item = item-override > manager-options-override > provider > built-in; `close` =
|
|
2127
|
+
* manager-options-override > provider (NO built-in ⇒ no closing line when unset) (see
|
|
2128
|
+
* {@link AgentContextInterface.build}). Passing NO `format` (and with no overrides / no per-item
|
|
2129
|
+
* format) reproduces the built-in framing byte-for-byte (each section is its built-in header +
|
|
2130
|
+
* items, no closing line). The active workspace's scoped-in image files' `data` is attached to
|
|
2131
|
+
* the LAST user message (a vision provider reads images off a user turn); when no user message
|
|
2132
|
+
* exists the attachment is skipped. Built fresh each call (recomputed, never cached), so it
|
|
2133
|
+
* always reflects the current managers / messages / scope / active workspace; it never mutates a
|
|
2134
|
+
* manager or the stored messages.
|
|
2135
|
+
* - **The ACTIVE workspace, rendered BY CARRIER — the SOLE document/image context.**
|
|
2136
|
+
* `workspaces.active` (when set) has its {@link FileInterface}s scope-filtered by `scope.files`,
|
|
2137
|
+
* then split: TEXT files fold into a dedicated `## Workspace` system section (fenced reference
|
|
2138
|
+
* blocks — placed right after the instructions section), and IMAGE files' base64 `data` attaches
|
|
2139
|
+
* to the last user message. ACTIVE-ONLY — never the other registered workspaces; with no active
|
|
2140
|
+
* workspace nothing renders for workspaces. `build()` OWNS this render (a `Workspace` /
|
|
2141
|
+
* `WorkspaceManager` stays file-focused).
|
|
2142
|
+
* - **Tools are structural, not in the prompt.** The registry is advertised to the provider
|
|
2143
|
+
* via `tools.definitions()` (scope-filtered by the loop), NEVER serialized into the
|
|
2144
|
+
* message array — so `build()`'s output carries no tool content, scoped or not.
|
|
2145
|
+
* - **Event-free context; observable managers.** The context itself owns no Emitter; the
|
|
2146
|
+
* context managers each carry their own (the §13 observation surface).
|
|
2147
|
+
*
|
|
2148
|
+
* @example
|
|
2149
|
+
* ```ts
|
|
2150
|
+
* const context = new AgentContext({ system: 'You are concise.' })
|
|
2151
|
+
* context.instructions.add({ name: 'tone', content: 'Be terse.' })
|
|
2152
|
+
* context.messages.add({ role: 'user', content: 'Hi' })
|
|
2153
|
+
* context.build() // [{ role: 'system', content: 'You are concise.\n\n## Instructions\n\nBe terse.' }, { role: 'user', content: 'Hi' }]
|
|
2154
|
+
* ```
|
|
2155
|
+
*/
|
|
2156
|
+
var AgentContext = class {
|
|
2157
|
+
#system;
|
|
2158
|
+
#instructions;
|
|
2159
|
+
#workspaces;
|
|
2160
|
+
#conversations;
|
|
2161
|
+
#tools;
|
|
2162
|
+
#scope;
|
|
2163
|
+
constructor(options) {
|
|
2164
|
+
this.#system = options?.system;
|
|
2165
|
+
this.#instructions = options?.instructions ?? new InstructionManager();
|
|
2166
|
+
this.#workspaces = options?.workspaces ?? new WorkspaceManager();
|
|
2167
|
+
this.#conversations = options?.conversations ?? new ConversationManager();
|
|
2168
|
+
if (this.#conversations.active === void 0) this.#conversations.add();
|
|
2169
|
+
this.#tools = options?.tools ?? new ToolManager();
|
|
2170
|
+
this.#scope = options?.scope;
|
|
2171
|
+
}
|
|
2172
|
+
get system() {
|
|
2173
|
+
return this.#system;
|
|
2174
|
+
}
|
|
2175
|
+
get instructions() {
|
|
2176
|
+
return this.#instructions;
|
|
2177
|
+
}
|
|
2178
|
+
get workspaces() {
|
|
2179
|
+
return this.#workspaces;
|
|
2180
|
+
}
|
|
2181
|
+
set workspaces(value) {
|
|
2182
|
+
this.#workspaces = value;
|
|
2183
|
+
}
|
|
2184
|
+
get messages() {
|
|
2185
|
+
return this.#conversations.active ?? this.#ensure();
|
|
2186
|
+
}
|
|
2187
|
+
get conversations() {
|
|
2188
|
+
return this.#conversations;
|
|
2189
|
+
}
|
|
2190
|
+
set conversations(value) {
|
|
2191
|
+
this.#conversations = value;
|
|
2192
|
+
if (this.#conversations.active === void 0) this.#conversations.add();
|
|
2193
|
+
}
|
|
2194
|
+
get tools() {
|
|
2195
|
+
return this.#tools;
|
|
2196
|
+
}
|
|
2197
|
+
get scope() {
|
|
2198
|
+
return this.#scope;
|
|
2199
|
+
}
|
|
2200
|
+
set scope(value) {
|
|
2201
|
+
this.#scope = value;
|
|
2202
|
+
}
|
|
2203
|
+
build(format) {
|
|
2204
|
+
const scope = this.#scope;
|
|
2205
|
+
const parts = [];
|
|
2206
|
+
if (this.#system !== void 0) parts.push(this.#system);
|
|
2207
|
+
const instructions = filterAllowList(scope?.instructions, this.#instructions.instructions(), (one) => one.name);
|
|
2208
|
+
this.#section(parts, this.#header(this.#instructions, format?.instructions), instructions, (one) => this.#render(this.#instructions, format?.instructions, one), this.#footer(this.#instructions, format?.instructions));
|
|
2209
|
+
const files = filterAllowList(scope?.files, this.#workspaces.active?.files() ?? [], (one) => one.path);
|
|
2210
|
+
const workspaceTexts = files.filter((file) => isText(file.content));
|
|
2211
|
+
this.#section(parts, WORKSPACE_SECTION_HEADER, workspaceTexts, (file) => this.#fenced(file), void 0);
|
|
2212
|
+
const conversation = (this.#conversations.active ?? this.#ensure()).view();
|
|
2213
|
+
const tail = this.#attach(conversation, this.#workspaceImages(files));
|
|
2214
|
+
if (parts.length === 0) return tail;
|
|
2215
|
+
return [{
|
|
2216
|
+
id: crypto.randomUUID(),
|
|
2217
|
+
role: "system",
|
|
2218
|
+
content: parts.join("\n\n")
|
|
2219
|
+
}, ...tail];
|
|
2220
|
+
}
|
|
2221
|
+
#section(parts, open, items, format, close) {
|
|
2222
|
+
if (items.length === 0) return;
|
|
2223
|
+
const lines = [open, ...items.map(format)];
|
|
2224
|
+
if (close !== void 0) lines.push(close);
|
|
2225
|
+
parts.push(lines.join("\n\n"));
|
|
2226
|
+
}
|
|
2227
|
+
#header(manager, provider) {
|
|
2228
|
+
return manager.framing?.open ?? provider?.open ?? manager.description;
|
|
2229
|
+
}
|
|
2230
|
+
#footer(manager, provider) {
|
|
2231
|
+
return manager.framing?.close ?? provider?.close;
|
|
2232
|
+
}
|
|
2233
|
+
#render(manager, provider, item) {
|
|
2234
|
+
return item.format ?? manager.framing?.render?.(item) ?? provider?.render?.(item) ?? manager.format(item);
|
|
2235
|
+
}
|
|
2236
|
+
#attach(conversation, data) {
|
|
2237
|
+
if (data.length === 0) return conversation;
|
|
2238
|
+
let target = -1;
|
|
2239
|
+
for (let index = conversation.length - 1; index >= 0; index -= 1) if (conversation[index]?.role === "user") {
|
|
2240
|
+
target = index;
|
|
2241
|
+
break;
|
|
2242
|
+
}
|
|
2243
|
+
if (target === -1) return conversation;
|
|
2244
|
+
return conversation.map((message, index) => index === target ? this.#withImages(message, data) : message);
|
|
2245
|
+
}
|
|
2246
|
+
#withImages(message, data) {
|
|
2247
|
+
const images = [...message.images ?? [], ...data];
|
|
2248
|
+
return message.calls === void 0 ? {
|
|
2249
|
+
id: message.id,
|
|
2250
|
+
role: message.role,
|
|
2251
|
+
content: message.content,
|
|
2252
|
+
images
|
|
2253
|
+
} : {
|
|
2254
|
+
id: message.id,
|
|
2255
|
+
role: message.role,
|
|
2256
|
+
content: message.content,
|
|
2257
|
+
calls: message.calls,
|
|
2258
|
+
images
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
#fenced(file) {
|
|
2262
|
+
if (isText(file.content)) return fencedFile(file.path, file.content.language, file.content.text);
|
|
2263
|
+
return fencedFile(file.path, "text", "");
|
|
2264
|
+
}
|
|
2265
|
+
#workspaceImages(files) {
|
|
2266
|
+
const data = [];
|
|
2267
|
+
for (const file of files) if (isBinary(file.content) && isImage(file.content)) data.push(file.content.data);
|
|
2268
|
+
return data;
|
|
2269
|
+
}
|
|
2270
|
+
#ensure() {
|
|
2271
|
+
const conversation = this.#conversations.add();
|
|
2272
|
+
return this.#conversations.active ?? conversation;
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
//#endregion
|
|
2276
|
+
//#region src/core/Channel.ts
|
|
2277
|
+
/**
|
|
2278
|
+
* A minimal unbounded async channel — the eager pump WRITES chunks into it (`push`)
|
|
2279
|
+
* and ends it (`close` / `fail`) regardless of consumption; a consumer READS them back
|
|
2280
|
+
* live via the `drain` async-iterator. Decoupling write from read is what lets a
|
|
2281
|
+
* producer make progress without a consumer pulling.
|
|
2282
|
+
*
|
|
2283
|
+
* @remarks
|
|
2284
|
+
* The standard resolver-swap: a waiting `drain` parks on `#wake` (a void resolver);
|
|
2285
|
+
* `push` / `close` / `fail` enqueue/flag, then fire `#wake` so the parked reader wakes,
|
|
2286
|
+
* re-reads the buffer, and either yields the next chunk, returns (on `close`), or throws
|
|
2287
|
+
* (on `fail`). Event-free, no `!` / `as` / `any`.
|
|
2288
|
+
*/
|
|
2289
|
+
var Channel = class {
|
|
2290
|
+
#buffer = [];
|
|
2291
|
+
#wake;
|
|
2292
|
+
#closed = false;
|
|
2293
|
+
#failure;
|
|
2294
|
+
push(value) {
|
|
2295
|
+
this.#buffer.push(value);
|
|
2296
|
+
this.#signal();
|
|
2297
|
+
}
|
|
2298
|
+
close() {
|
|
2299
|
+
this.#closed = true;
|
|
2300
|
+
this.#signal();
|
|
2301
|
+
}
|
|
2302
|
+
fail(error) {
|
|
2303
|
+
if (this.#failure === void 0) this.#failure = { error };
|
|
2304
|
+
this.#closed = true;
|
|
2305
|
+
this.#signal();
|
|
2306
|
+
}
|
|
2307
|
+
async *drain() {
|
|
2308
|
+
for (;;) {
|
|
2309
|
+
while (this.#buffer.length > 0) {
|
|
2310
|
+
const next = this.#buffer.shift();
|
|
2311
|
+
if (next !== void 0) yield next;
|
|
2312
|
+
}
|
|
2313
|
+
if (this.#failure !== void 0) throw this.#failure.error;
|
|
2314
|
+
if (this.#closed) return;
|
|
2315
|
+
await this.#parked();
|
|
2316
|
+
}
|
|
2317
|
+
}
|
|
2318
|
+
#signal() {
|
|
2319
|
+
const wake = this.#wake;
|
|
2320
|
+
this.#wake = void 0;
|
|
2321
|
+
wake?.();
|
|
2322
|
+
}
|
|
2323
|
+
#parked() {
|
|
2324
|
+
return new Promise((resolve) => {
|
|
2325
|
+
this.#wake = resolve;
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
};
|
|
2329
|
+
//#endregion
|
|
2330
|
+
//#region src/core/Agent.ts
|
|
2331
|
+
/**
|
|
2332
|
+
* The agent loop — composes a {@link ProviderInterface}, an {@link AgentContext}, and
|
|
2333
|
+
* a {@link ToolManagerInterface} into a bounded context → provider → tools → repeat
|
|
2334
|
+
* turn, exposed as both a one-shot `generate` and a live `stream`.
|
|
2335
|
+
*
|
|
2336
|
+
* @remarks
|
|
2337
|
+
* - **One loop, two faces.** A single private async generator (`#run`) drives the
|
|
2338
|
+
* whole turn. `stream` kicks off an eager pump that iterates `#run` into a private
|
|
2339
|
+
* {@link Channel}, settling `result` from the run's outcome — so `result` settles
|
|
2340
|
+
* whether or not the live `events` are drained; `generate` simply awaits that same
|
|
2341
|
+
* settled `result` — so the two can never diverge.
|
|
2342
|
+
* - **The turn.** `#run` builds the provider input once (`context.build()` into a
|
|
2343
|
+
* working array) then loops up to `limit`: drive `provider.stream(...)` accumulating
|
|
2344
|
+
* + yielding each content delta as a `token` chunk; fold the turn's usage into the
|
|
2345
|
+
* running total + the `budget` and yield a `usage` chunk; if the model requested
|
|
2346
|
+
* tools, append the assistant turn, `execute` them, yield a `tool` chunk per call,
|
|
2347
|
+
* append each tool result message, and continue; otherwise append the final
|
|
2348
|
+
* assistant message and stop.
|
|
2349
|
+
* - **Bounded.** Each run arms one cancel via `createAbort({ signal: AbortSignal.any([
|
|
2350
|
+
* …]) })` folding the external `signal`, the `timeout` deadline, and the `budget`
|
|
2351
|
+
* signal; `abort()` fires it. Any trip stops the loop and commits a PARTIAL result
|
|
2352
|
+
* (the `result` promise RESOLVES, never rejects, on a cancel) — only a genuine
|
|
2353
|
+
* provider / tool error rejects.
|
|
2354
|
+
* - **Paced + capped.** The `scheduler` (when given) `yield`s between turns; tool
|
|
2355
|
+
* iteration is capped at `limit`.
|
|
2356
|
+
* - **Two observation surfaces.** The PULL {@link AgentChunk} stream carries per-token
|
|
2357
|
+
* deltas (+ usage/tool chunks); the PUSH {@link emitter} ({@link AgentEventMap}) carries
|
|
2358
|
+
* lifecycle + usage/tool/deny moments for fire-and-forget observers. Every event is
|
|
2359
|
+
* emitted directly, AFTER the relevant state transition / settle; the emitter isolates a
|
|
2360
|
+
* listener throw and routes it to its `error` handler (the `error` option), so a buggy
|
|
2361
|
+
* observer can never escape into / reorder / corrupt the settle-once loop — observation is
|
|
2362
|
+
* purely a side-channel.
|
|
2363
|
+
*
|
|
2364
|
+
* @example
|
|
2365
|
+
* ```ts
|
|
2366
|
+
* const agent = new Agent(provider, { system: 'You are concise.' })
|
|
2367
|
+
* agent.context.messages.add({ role: 'user', content: 'Say hi.' })
|
|
2368
|
+
* const result = await agent.generate()
|
|
2369
|
+
* ```
|
|
2370
|
+
*/
|
|
2371
|
+
var Agent = class {
|
|
2372
|
+
#provider;
|
|
2373
|
+
#context;
|
|
2374
|
+
#limit;
|
|
2375
|
+
#timeoutMs;
|
|
2376
|
+
#budget;
|
|
2377
|
+
#scheduler;
|
|
2378
|
+
#signal;
|
|
2379
|
+
#authority;
|
|
2380
|
+
#window;
|
|
2381
|
+
#emitter;
|
|
2382
|
+
id = crypto.randomUUID();
|
|
2383
|
+
#status = "idle";
|
|
2384
|
+
#runs = /* @__PURE__ */ new Set();
|
|
2385
|
+
constructor(provider, options) {
|
|
2386
|
+
this.#provider = provider;
|
|
2387
|
+
this.#context = new AgentContext({
|
|
2388
|
+
system: options?.system,
|
|
2389
|
+
tools: options?.tools,
|
|
2390
|
+
conversations: options?.conversations
|
|
2391
|
+
});
|
|
2392
|
+
this.#limit = options?.limit ?? 10;
|
|
2393
|
+
this.#timeoutMs = options?.timeout;
|
|
2394
|
+
this.#budget = options?.budget;
|
|
2395
|
+
this.#scheduler = options?.scheduler;
|
|
2396
|
+
this.#signal = options?.signal;
|
|
2397
|
+
this.#authority = options?.authority;
|
|
2398
|
+
this.#window = options?.window;
|
|
2399
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
2400
|
+
on: options?.on,
|
|
2401
|
+
error: options?.error
|
|
2402
|
+
});
|
|
2403
|
+
}
|
|
2404
|
+
get emitter() {
|
|
2405
|
+
return this.#emitter;
|
|
2406
|
+
}
|
|
2407
|
+
get status() {
|
|
2408
|
+
return this.#status;
|
|
2409
|
+
}
|
|
2410
|
+
get context() {
|
|
2411
|
+
return this.#context;
|
|
2412
|
+
}
|
|
2413
|
+
generate(options) {
|
|
2414
|
+
return this.stream(options).result;
|
|
2415
|
+
}
|
|
2416
|
+
stream(options) {
|
|
2417
|
+
const timeout = this.#timeoutMs === void 0 ? void 0 : (0, _orkestrel_timeout.createTimeout)({ ms: this.#timeoutMs });
|
|
2418
|
+
timeout?.start();
|
|
2419
|
+
this.#budget?.start();
|
|
2420
|
+
const abort = (0, _orkestrel_abort.createAbort)({ signal: this.#parents(timeout) });
|
|
2421
|
+
this.#runs.add(abort);
|
|
2422
|
+
this.#status = "running";
|
|
2423
|
+
this.#emitter.emit("start", this.id);
|
|
2424
|
+
const outcome = {
|
|
2425
|
+
content: "",
|
|
2426
|
+
thinking: void 0,
|
|
2427
|
+
usage: void 0,
|
|
2428
|
+
partial: false
|
|
2429
|
+
};
|
|
2430
|
+
const channel = new Channel();
|
|
2431
|
+
const settled = (0, _orkestrel_workflow.createDeferred)();
|
|
2432
|
+
this.#pump(abort, outcome, timeout, channel, settled, options?.think);
|
|
2433
|
+
settled.promise.catch(() => {});
|
|
2434
|
+
return {
|
|
2435
|
+
events: this.#events(channel, abort),
|
|
2436
|
+
result: settled.promise,
|
|
2437
|
+
abort: (reason) => {
|
|
2438
|
+
abort.abort(reason);
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
abort(reason) {
|
|
2443
|
+
for (const abort of [...this.#runs]) abort.abort(reason);
|
|
2444
|
+
}
|
|
2445
|
+
async #pump(abort, outcome, timeout, channel, settled, think) {
|
|
2446
|
+
let failure;
|
|
2447
|
+
try {
|
|
2448
|
+
for await (const chunk of this.#run(abort, outcome, think)) channel.push(chunk);
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
failure = { error };
|
|
2451
|
+
} finally {
|
|
2452
|
+
timeout?.clear();
|
|
2453
|
+
this.#runs.delete(abort);
|
|
2454
|
+
if (failure === void 0) {
|
|
2455
|
+
this.#status = "done";
|
|
2456
|
+
channel.close();
|
|
2457
|
+
const result = this.#result(outcome);
|
|
2458
|
+
settled.resolve(result);
|
|
2459
|
+
if (outcome.partial) this.#emitter.emit("abort", abort.signal.reason);
|
|
2460
|
+
this.#emitter.emit("finish", result);
|
|
2461
|
+
} else {
|
|
2462
|
+
this.#status = "error";
|
|
2463
|
+
channel.fail(failure.error);
|
|
2464
|
+
settled.reject(failure.error);
|
|
2465
|
+
this.#emitter.emit("error", failure.error);
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
async *#events(channel, abort) {
|
|
2470
|
+
try {
|
|
2471
|
+
yield* channel.drain();
|
|
2472
|
+
} finally {
|
|
2473
|
+
abort.abort();
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
async *#run(abort, outcome, think) {
|
|
2477
|
+
const messages = [...this.#context.build(this.#provider.format)];
|
|
2478
|
+
const tools = this.#context.tools;
|
|
2479
|
+
let content = "";
|
|
2480
|
+
let thinking;
|
|
2481
|
+
let usage;
|
|
2482
|
+
const compaction = { futile: false };
|
|
2483
|
+
const compacting = this.#window !== void 0 && this.#context.conversations.active?.summarizable === true;
|
|
2484
|
+
if (compacting) {
|
|
2485
|
+
this.#window?.clear();
|
|
2486
|
+
if (!abort.signal.aborted) await this.#trim(messages, compaction, false);
|
|
2487
|
+
}
|
|
2488
|
+
for (let turn = 0; turn < this.#limit; turn += 1) {
|
|
2489
|
+
this.#emitter.emit("turn", turn);
|
|
2490
|
+
if (turn > 0) try {
|
|
2491
|
+
await this.#scheduler?.yield({ signal: abort.signal });
|
|
2492
|
+
} catch (error) {
|
|
2493
|
+
if (abort.signal.aborted) {
|
|
2494
|
+
outcome.partial = true;
|
|
2495
|
+
break;
|
|
2496
|
+
}
|
|
2497
|
+
throw error;
|
|
2498
|
+
}
|
|
2499
|
+
if (abort.signal.aborted) {
|
|
2500
|
+
outcome.partial = true;
|
|
2501
|
+
break;
|
|
2502
|
+
}
|
|
2503
|
+
const advertised = filterAllowList(this.#context.scope?.tools, tools.definitions(), (definition) => definition.name);
|
|
2504
|
+
const definitions = advertised.length > 0 ? advertised : void 0;
|
|
2505
|
+
let result;
|
|
2506
|
+
try {
|
|
2507
|
+
result = yield* this.#provide(messages, abort.signal, definitions, think, (delta) => {
|
|
2508
|
+
content += delta;
|
|
2509
|
+
});
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
if (abort.signal.aborted) {
|
|
2512
|
+
if (isProviderAbortError(error) && error.partial.thinking !== void 0) thinking = this.#thought(thinking, error.partial.thinking);
|
|
2513
|
+
outcome.partial = true;
|
|
2514
|
+
break;
|
|
2515
|
+
}
|
|
2516
|
+
throw error;
|
|
2517
|
+
}
|
|
2518
|
+
if (result.thinking !== void 0 && result.thinking.length > 0) thinking = this.#thought(thinking, result.thinking);
|
|
2519
|
+
if (result.usage !== void 0) {
|
|
2520
|
+
this.#budget?.consume(result.usage);
|
|
2521
|
+
usage = this.#sum(usage, result.usage);
|
|
2522
|
+
this.#emitter.emit("usage", result.usage);
|
|
2523
|
+
yield {
|
|
2524
|
+
type: "usage",
|
|
2525
|
+
usage: result.usage
|
|
2526
|
+
};
|
|
2527
|
+
}
|
|
2528
|
+
if (result.tools !== void 0 && result.tools.length > 0) {
|
|
2529
|
+
const assistant = this.#context.messages.add({
|
|
2530
|
+
role: "assistant",
|
|
2531
|
+
content: result.content,
|
|
2532
|
+
calls: result.tools
|
|
2533
|
+
});
|
|
2534
|
+
messages.push(assistant);
|
|
2535
|
+
const results = await this.#authorize(tools, result.tools);
|
|
2536
|
+
for (let index = 0; index < result.tools.length; index += 1) {
|
|
2537
|
+
const call = result.tools[index];
|
|
2538
|
+
const outcomeResult = results[index];
|
|
2539
|
+
if (call === void 0 || outcomeResult === void 0) continue;
|
|
2540
|
+
this.#emitter.emit("tool", call, outcomeResult);
|
|
2541
|
+
yield {
|
|
2542
|
+
type: "tool",
|
|
2543
|
+
call,
|
|
2544
|
+
result: outcomeResult
|
|
2545
|
+
};
|
|
2546
|
+
const toolMessage = this.#context.messages.add({
|
|
2547
|
+
role: "tool",
|
|
2548
|
+
content: JSON.stringify(outcomeResult.value ?? outcomeResult.error)
|
|
2549
|
+
});
|
|
2550
|
+
messages.push(toolMessage);
|
|
2551
|
+
}
|
|
2552
|
+
if (compacting) await this.#trim(messages, compaction, true);
|
|
2553
|
+
continue;
|
|
2554
|
+
}
|
|
2555
|
+
messages.push(this.#context.messages.add({
|
|
2556
|
+
role: "assistant",
|
|
2557
|
+
content: result.content
|
|
2558
|
+
}));
|
|
2559
|
+
content = result.content;
|
|
2560
|
+
break;
|
|
2561
|
+
}
|
|
2562
|
+
outcome.content = content;
|
|
2563
|
+
outcome.thinking = thinking;
|
|
2564
|
+
outcome.usage = usage;
|
|
2565
|
+
}
|
|
2566
|
+
async #trim(messages, compaction, latchFutile) {
|
|
2567
|
+
const conversation = this.#context.conversations.active;
|
|
2568
|
+
if (this.#window === void 0 || conversation?.summarizable !== true || compaction.futile) return;
|
|
2569
|
+
this.#window.clear();
|
|
2570
|
+
this.#window.consume(messages);
|
|
2571
|
+
if (!this.#window.exhausted) return;
|
|
2572
|
+
let section;
|
|
2573
|
+
try {
|
|
2574
|
+
section = await conversation.compact();
|
|
2575
|
+
} catch (error) {
|
|
2576
|
+
this.#emitter.emit("compactError", error);
|
|
2577
|
+
return;
|
|
2578
|
+
}
|
|
2579
|
+
if (section === void 0) {
|
|
2580
|
+
if (latchFutile) compaction.futile = true;
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
messages.splice(0, messages.length, ...this.#context.build(this.#provider.format));
|
|
2584
|
+
}
|
|
2585
|
+
async #authorize(tools, calls) {
|
|
2586
|
+
if (this.#authority === void 0) return tools.execute(calls);
|
|
2587
|
+
const authority = this.#authority;
|
|
2588
|
+
const allowed = [];
|
|
2589
|
+
const denials = /* @__PURE__ */ new Map();
|
|
2590
|
+
for (const call of calls) {
|
|
2591
|
+
let decision;
|
|
2592
|
+
try {
|
|
2593
|
+
decision = authority.evaluate({ call });
|
|
2594
|
+
} catch (error) {
|
|
2595
|
+
const reason = this.#reason(error);
|
|
2596
|
+
denials.set(call.id, this.#denial(call, reason));
|
|
2597
|
+
this.#emitter.emit("deny", call, reason);
|
|
2598
|
+
continue;
|
|
2599
|
+
}
|
|
2600
|
+
if (decision.allowed) allowed.push(call);
|
|
2601
|
+
else {
|
|
2602
|
+
denials.set(call.id, this.#denial(call, decision.reason));
|
|
2603
|
+
this.#emitter.emit("deny", call, decision.reason);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
const executed = allowed.length > 0 ? await tools.execute(allowed) : [];
|
|
2607
|
+
const byId = new Map(denials);
|
|
2608
|
+
for (const result of executed) byId.set(result.id, result);
|
|
2609
|
+
return calls.map((call) => byId.get(call.id) ?? this.#denial(call, void 0));
|
|
2610
|
+
}
|
|
2611
|
+
#denial(call, reason) {
|
|
2612
|
+
return {
|
|
2613
|
+
id: call.id,
|
|
2614
|
+
name: call.name,
|
|
2615
|
+
error: reason !== void 0 ? `denied: ${reason}` : "denied by authority"
|
|
2616
|
+
};
|
|
2617
|
+
}
|
|
2618
|
+
#reason(error) {
|
|
2619
|
+
return error instanceof Error ? error.message : String(error);
|
|
2620
|
+
}
|
|
2621
|
+
async *#provide(messages, signal, definitions, think, onDelta) {
|
|
2622
|
+
const generator = this.#provider.stream(messages, signal, definitions, think === void 0 ? void 0 : { think });
|
|
2623
|
+
let next = await generator.next();
|
|
2624
|
+
while (!next.done) {
|
|
2625
|
+
const delta = next.value;
|
|
2626
|
+
if (delta.type === "content") {
|
|
2627
|
+
onDelta(delta.text);
|
|
2628
|
+
yield {
|
|
2629
|
+
type: "token",
|
|
2630
|
+
content: delta.text
|
|
2631
|
+
};
|
|
2632
|
+
} else yield {
|
|
2633
|
+
type: "think",
|
|
2634
|
+
content: delta.text
|
|
2635
|
+
};
|
|
2636
|
+
next = await generator.next();
|
|
2637
|
+
}
|
|
2638
|
+
return next.value;
|
|
2639
|
+
}
|
|
2640
|
+
#parents(timeout) {
|
|
2641
|
+
const signals = [];
|
|
2642
|
+
if (this.#signal !== void 0) signals.push(this.#signal);
|
|
2643
|
+
if (timeout !== void 0) signals.push(timeout.signal);
|
|
2644
|
+
if (this.#budget !== void 0) signals.push(this.#budget.signal);
|
|
2645
|
+
if (signals.length === 0) return void 0;
|
|
2646
|
+
if (signals.length === 1) return signals[0];
|
|
2647
|
+
return AbortSignal.any(signals);
|
|
2648
|
+
}
|
|
2649
|
+
#thought(running, next) {
|
|
2650
|
+
return running === void 0 ? next : `${running}\n\n${next}`;
|
|
2651
|
+
}
|
|
2652
|
+
#sum(running, next) {
|
|
2653
|
+
if (running === void 0) return next;
|
|
2654
|
+
return {
|
|
2655
|
+
prompt: running.prompt + next.prompt,
|
|
2656
|
+
completion: running.completion + next.completion,
|
|
2657
|
+
total: running.total + next.total
|
|
2658
|
+
};
|
|
2659
|
+
}
|
|
2660
|
+
#result(outcome) {
|
|
2661
|
+
const result = {
|
|
2662
|
+
content: outcome.content,
|
|
2663
|
+
partial: outcome.partial
|
|
2664
|
+
};
|
|
2665
|
+
if (outcome.thinking !== void 0) result.thinking = outcome.thinking;
|
|
2666
|
+
if (outcome.usage !== void 0) result.usage = outcome.usage;
|
|
2667
|
+
return result;
|
|
2668
|
+
}
|
|
2669
|
+
};
|
|
2670
|
+
//#endregion
|
|
2671
|
+
//#region src/core/AgentRegistry.ts
|
|
2672
|
+
/**
|
|
2673
|
+
* The bridge that makes a durable, JSON-serializable {@link AgentJobInput} runnable —
|
|
2674
|
+
* it holds the named pools of live, non-serializable pieces (providers, tools,
|
|
2675
|
+
* authorities, schedulers) and rehydrates a seeded, signal-wired {@link Agent} from a
|
|
2676
|
+
* job's names + data.
|
|
2677
|
+
*
|
|
2678
|
+
* @remarks
|
|
2679
|
+
* - **Why it exists.** An `AgentJobInput` is serializable so it can survive a crash in a
|
|
2680
|
+
* Queue's store; the live objects it needs (a provider with sockets, tools / rules /
|
|
2681
|
+
* schedulers carrying functions) cannot serialize. The registry closes that gap:
|
|
2682
|
+
* construct it once with the live pools, then a queue / runner handler calls `build`
|
|
2683
|
+
* on each (possibly restored) job to get a ready agent.
|
|
2684
|
+
* - **Accessors throw on a miss (§9.1 + §12).** `provider` / `tool` / `authority` /
|
|
2685
|
+
* `scheduler` resolve a name against their pool and THROW `unknown <category>: <name>`
|
|
2686
|
+
* when it is absent — an unknown name in a rehydrated job is a programmer / config
|
|
2687
|
+
* error that must fail LOUDLY at build time, never silently resolve to `undefined` and
|
|
2688
|
+
* run an agent missing a dependency.
|
|
2689
|
+
* - **`build` rehydrates.** Resolve the job's `provider`; assemble a fresh
|
|
2690
|
+
* {@link ToolManager} from the `tools` names; rebuild the token `budget` from its
|
|
2691
|
+
* ceiling (`createTokenBudget({ max })`); resolve the optional `authority` /
|
|
2692
|
+
* `scheduler` names; construct the {@link Agent} with `system` / `limit` / `timeout` /
|
|
2693
|
+
* the threaded `signal`; seed its context with the job's `messages`; return it. The
|
|
2694
|
+
* `signal` is the queue attempt's / runner unit's cancel, so a bounded abort propagates
|
|
2695
|
+
* into the agent (which commits a partial — the job's `allowPartial` policy then
|
|
2696
|
+
* decides success vs. retry).
|
|
2697
|
+
* - **Event-free.** A pure resolver — no Emitter, no events.
|
|
2698
|
+
*
|
|
2699
|
+
* @example
|
|
2700
|
+
* ```ts
|
|
2701
|
+
* declare const provider: ProviderInterface // any concrete implementation supplied by the host app
|
|
2702
|
+
* const registry = new AgentRegistry({ providers: { main: provider } })
|
|
2703
|
+
* const agent = registry.build({ provider: 'main', messages: [{ role: 'user', content: 'Say ok.' }] })
|
|
2704
|
+
* const result = await agent.generate()
|
|
2705
|
+
* ```
|
|
2706
|
+
*/
|
|
2707
|
+
var AgentRegistry = class {
|
|
2708
|
+
#providers;
|
|
2709
|
+
#tools;
|
|
2710
|
+
#authorities;
|
|
2711
|
+
#schedulers;
|
|
2712
|
+
constructor(options) {
|
|
2713
|
+
this.#providers = new Map(Object.entries(options.providers));
|
|
2714
|
+
this.#tools = new Map(Object.entries(options.tools ?? {}));
|
|
2715
|
+
this.#authorities = new Map(Object.entries(options.authorities ?? {}));
|
|
2716
|
+
this.#schedulers = new Map(Object.entries(options.schedulers ?? {}));
|
|
2717
|
+
}
|
|
2718
|
+
provider(name) {
|
|
2719
|
+
return this.#resolve(this.#providers, "provider", name);
|
|
2720
|
+
}
|
|
2721
|
+
tool(name) {
|
|
2722
|
+
return this.#resolve(this.#tools, "tool", name);
|
|
2723
|
+
}
|
|
2724
|
+
authority(name) {
|
|
2725
|
+
return this.#resolve(this.#authorities, "authority", name);
|
|
2726
|
+
}
|
|
2727
|
+
scheduler(name) {
|
|
2728
|
+
return this.#resolve(this.#schedulers, "scheduler", name);
|
|
2729
|
+
}
|
|
2730
|
+
build(input, signal) {
|
|
2731
|
+
const agent = new Agent(this.provider(input.provider), this.#options(input, signal));
|
|
2732
|
+
for (const message of input.messages) agent.context.messages.add(message);
|
|
2733
|
+
return agent;
|
|
2734
|
+
}
|
|
2735
|
+
#options(input, signal) {
|
|
2736
|
+
return {
|
|
2737
|
+
system: input.system,
|
|
2738
|
+
tools: this.#manager(input.tools),
|
|
2739
|
+
limit: input.limit,
|
|
2740
|
+
timeout: input.timeout,
|
|
2741
|
+
budget: this.#budget(input.budget),
|
|
2742
|
+
authority: input.authority === void 0 ? void 0 : this.authority(input.authority),
|
|
2743
|
+
scheduler: input.scheduler === void 0 ? void 0 : this.scheduler(input.scheduler),
|
|
2744
|
+
signal
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
#manager(names) {
|
|
2748
|
+
const manager = new ToolManager();
|
|
2749
|
+
if (names !== void 0) for (const name of names) manager.add(this.tool(name));
|
|
2750
|
+
return manager;
|
|
2751
|
+
}
|
|
2752
|
+
#budget(max) {
|
|
2753
|
+
return max === void 0 ? void 0 : (0, _orkestrel_budget.createTokenBudget)({ max });
|
|
2754
|
+
}
|
|
2755
|
+
#resolve(pool, category, name) {
|
|
2756
|
+
const value = pool.get(name);
|
|
2757
|
+
if (value === void 0) throw new Error(`unknown ${category}: ${name}`);
|
|
2758
|
+
return value;
|
|
2759
|
+
}
|
|
2760
|
+
};
|
|
2761
|
+
//#endregion
|
|
2762
|
+
//#region src/core/Authority.ts
|
|
2763
|
+
/**
|
|
2764
|
+
* The synchronous policy gate the agent loop consults before each tool call runs —
|
|
2765
|
+
* it turns one {@link AuthorityContextInterface} into an {@link AuthorityDecision}.
|
|
2766
|
+
*
|
|
2767
|
+
* @remarks
|
|
2768
|
+
* - **Ordered, first-match-wins.** `evaluate` walks the configured rules in order and
|
|
2769
|
+
* returns the FIRST whose `match(context)` is true as
|
|
2770
|
+
* `{ zone, allowed: rule.allowed ?? true, reason }` — a matched rule ALLOWS by
|
|
2771
|
+
* default and DENIES only when its `allowed` is explicitly `false`.
|
|
2772
|
+
* - **Fallback.** When no rule matches, `evaluate` returns the configured `fallback`.
|
|
2773
|
+
* It defaults to `{ zone: DEFAULT_AUTHORITY_ZONE, allowed: true }` (allow-unmatched),
|
|
2774
|
+
* so a rules list of denials behaves as a DENYLIST. To make the gate deny-by-default
|
|
2775
|
+
* (an ALLOWLIST — only matched rules that allow get through), pass an `allowed: false`
|
|
2776
|
+
* `fallback`.
|
|
2777
|
+
* - **Consulted before each tool call.** The agent loop calls `evaluate({ call })` for
|
|
2778
|
+
* every {@link import('./types.js').ToolCall} the model emits; a denied call is fed
|
|
2779
|
+
* back to the model as a denial {@link import('./types.js').ToolResult} (a `tool`
|
|
2780
|
+
* chunk + a tool message) instead of being executed — no tool run, no budget cost —
|
|
2781
|
+
* so the model sees the denial and can react.
|
|
2782
|
+
* - **Synchronous now.** The async human-approval handshake (request / grant / deny) is
|
|
2783
|
+
* deferred to a later chunk; `evaluate` returns a verdict directly.
|
|
2784
|
+
* - **Event-free.** A purely functional gate — no Emitter, no events.
|
|
2785
|
+
*
|
|
2786
|
+
* @example
|
|
2787
|
+
* ```ts
|
|
2788
|
+
* // A denylist: deny the `delete` tool, allow everything else (default fallback).
|
|
2789
|
+
* const authority = new Authority({
|
|
2790
|
+
* rules: [{ match: (c) => c.call.name === 'delete', zone: 'restricted', allowed: false }],
|
|
2791
|
+
* })
|
|
2792
|
+
* authority.evaluate({ call: { id: '1', name: 'delete', arguments: {} } }) // { zone: 'restricted', allowed: false }
|
|
2793
|
+
* authority.evaluate({ call: { id: '2', name: 'add', arguments: {} } }) // { zone: 'default', allowed: true }
|
|
2794
|
+
* ```
|
|
2795
|
+
*/
|
|
2796
|
+
var Authority = class {
|
|
2797
|
+
#rules;
|
|
2798
|
+
#fallback;
|
|
2799
|
+
constructor(options) {
|
|
2800
|
+
this.#rules = options?.rules ?? [];
|
|
2801
|
+
this.#fallback = options?.fallback ?? {
|
|
2802
|
+
zone: "default",
|
|
2803
|
+
allowed: true
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
evaluate(context) {
|
|
2807
|
+
for (const rule of this.#rules) if (rule.match(context)) return {
|
|
2808
|
+
zone: rule.zone,
|
|
2809
|
+
allowed: rule.allowed ?? true,
|
|
2810
|
+
reason: rule.reason
|
|
2811
|
+
};
|
|
2812
|
+
return this.#fallback;
|
|
2813
|
+
}
|
|
2814
|
+
};
|
|
2815
|
+
//#endregion
|
|
2816
|
+
//#region src/core/conversations/stores/DatabaseConversationStore.ts
|
|
2817
|
+
/**
|
|
2818
|
+
* A {@link ConversationStoreInterface} backed by one table of the `databases` layer — a
|
|
2819
|
+
* conversation's durable state IS a row, so persistence reduces to keyed point-access (`get` / `set`
|
|
2820
|
+
* / `delete`) over a {@link TableInterface}, the driver-pluggable twin of the plain-`Map`
|
|
2821
|
+
* {@link import('./MemoryConversationStore.js').MemoryConversationStore}. The EXACT twin of
|
|
2822
|
+
* {@link import('../../workspaces/stores/DatabaseWorkspaceStore.js').DatabaseWorkspaceStore}.
|
|
2823
|
+
*
|
|
2824
|
+
* @remarks
|
|
2825
|
+
* The store is driver-agnostic: it holds a single {@link TableInterface} whose backend (memory,
|
|
2826
|
+
* JSON, SQLite, IndexedDB) is chosen by whoever builds it (the factories), so a JSON / SQLite /
|
|
2827
|
+
* IndexedDB backend swaps in WITHOUT touching the
|
|
2828
|
+
* {@link import('../ConversationManager.js').ConversationManager} or the
|
|
2829
|
+
* {@link import('../Conversation.js').Conversation} — the same seam as
|
|
2830
|
+
* {@link import('../../workspaces/stores/DatabaseWorkspaceStore.js').DatabaseWorkspaceStore}. The
|
|
2831
|
+
* driver defaults to memory ({@link import('../../factories.js').createDatabaseConversationStore}
|
|
2832
|
+
* passes `createMemoryDriver()`), so it ALSO works in memory out of the box; you opt into the
|
|
2833
|
+
* durable plumbing by passing a JSON / SQLite / IndexedDB driver.
|
|
2834
|
+
*
|
|
2835
|
+
* The {@link ConversationSnapshot} is stored as ONE OPAQUE JSON COLUMN — the table is a row of
|
|
2836
|
+
* `{ id; snapshot }` ({@link ConversationSnapshotRow}), the snapshot the whole JSON blob (a
|
|
2837
|
+
* `rawShape` column the factory builds) — exactly as `DatabaseWorkspaceStore` stores its snapshot.
|
|
2838
|
+
* The snapshot is already a COMPLETE, self-contained, pure-JSON payload, so storing it whole is
|
|
2839
|
+
* lossless AND keeps the row type flat (`snapshot` reads back as `unknown`).
|
|
2840
|
+
*
|
|
2841
|
+
* - **`set(snapshot)` upserts under the snapshot's OWN `id`** (no separate id param) — it writes
|
|
2842
|
+
* the row `{ id: snapshot.id, snapshot }`.
|
|
2843
|
+
* - **`get(id)` resolves the stored snapshot for an id**, narrowing the opaque JSON column back to
|
|
2844
|
+
* a {@link ConversationSnapshot} ({@link import('../../helpers.js').isConversationSnapshot} — the
|
|
2845
|
+
* AGENTS §14 boundary narrow for an untrusted storage read), or `undefined` if none is stored.
|
|
2846
|
+
* - **`delete(id)` drops a snapshot by id**; an absent id is a no-op (no throw).
|
|
2847
|
+
*
|
|
2848
|
+
* UNLIKE a session store there is NO idle-TTL / eviction — a persisted conversation lives until an
|
|
2849
|
+
* explicit `delete`. The public surface is EXACTLY `get` / `set` / `delete` — no extra members (the
|
|
2850
|
+
* §22 method bijection with {@link ConversationStoreInterface}). Hydration stays a caller concern: a
|
|
2851
|
+
* {@link import('../ConversationManager.js').ConversationManager} reads a snapshot back and rebuilds
|
|
2852
|
+
* the live conversation through the constructor `seed` (its `open` / `save`).
|
|
2853
|
+
*
|
|
2854
|
+
* @example
|
|
2855
|
+
* ```ts
|
|
2856
|
+
* import { createConversation, createDatabaseConversationStore, createMemoryDriver } from '@src/core'
|
|
2857
|
+
*
|
|
2858
|
+
* const store = createDatabaseConversationStore(createMemoryDriver()) // a durable driver swaps in here
|
|
2859
|
+
* const conversation = createConversation()
|
|
2860
|
+
* conversation.add({ role: 'user', content: 'hello' })
|
|
2861
|
+
* await store.set(conversation.snapshot()) // persist the conversation (one JSON column)
|
|
2862
|
+
* const snapshot = await store.get(conversation.id)
|
|
2863
|
+
* await store.delete(conversation.id) // drop it
|
|
2864
|
+
* ```
|
|
2865
|
+
*/
|
|
2866
|
+
var DatabaseConversationStore = class {
|
|
2867
|
+
#table;
|
|
2868
|
+
/**
|
|
2869
|
+
* Wrap a table as a conversation store.
|
|
2870
|
+
*
|
|
2871
|
+
* @param table - The {@link TableInterface} holding the snapshots — its row is the
|
|
2872
|
+
* {@link ConversationSnapshotRow} `{ id; snapshot }` shape (the snapshot one opaque JSON column)
|
|
2873
|
+
*/
|
|
2874
|
+
constructor(table) {
|
|
2875
|
+
this.#table = table;
|
|
2876
|
+
}
|
|
2877
|
+
/** Resolve the persisted snapshot for `id`, narrowing the opaque JSON column back to a `ConversationSnapshot`. */
|
|
2878
|
+
async get(id) {
|
|
2879
|
+
const row = await this.#table.get(id);
|
|
2880
|
+
if (row === void 0) return void 0;
|
|
2881
|
+
return isConversationSnapshot(row.snapshot) ? row.snapshot : void 0;
|
|
2882
|
+
}
|
|
2883
|
+
/** Insert or replace under the snapshot's OWN `id` (no separate id param) — the row is `{ id, snapshot }`. */
|
|
2884
|
+
async set(snapshot) {
|
|
2885
|
+
await this.#table.set({
|
|
2886
|
+
id: snapshot.id,
|
|
2887
|
+
snapshot
|
|
2888
|
+
});
|
|
2889
|
+
}
|
|
2890
|
+
/** Drop a snapshot by id; an absent id is a no-op (no throw). */
|
|
2891
|
+
async delete(id) {
|
|
2892
|
+
await this.#table.remove(id);
|
|
2893
|
+
}
|
|
2894
|
+
};
|
|
2895
|
+
//#endregion
|
|
2896
|
+
//#region src/core/conversations/stores/MemoryConversationStore.ts
|
|
2897
|
+
/**
|
|
2898
|
+
* The in-memory {@link ConversationStoreInterface} — a process-lifetime `Map` of
|
|
2899
|
+
* {@link ConversationSnapshot}s keyed by conversation id, the DEFAULT store
|
|
2900
|
+
* {@link import('../../factories.js').createMemoryConversationStore} builds. The EXACT twin of
|
|
2901
|
+
* {@link import('../../workspaces/stores/MemoryWorkspaceStore.js').MemoryWorkspaceStore}.
|
|
2902
|
+
*
|
|
2903
|
+
* @remarks
|
|
2904
|
+
* A plain `Map<string, ConversationSnapshot>` (AGENTS §21 — the snapshot is already pure,
|
|
2905
|
+
* self-contained JSON, so no encoding is needed for the memory tier). Like the
|
|
2906
|
+
* {@link import('../../workspaces/stores/MemoryWorkspaceStore.js').MemoryWorkspaceStore} it twins,
|
|
2907
|
+
* there is NO idle-TTL and NO eviction: a persisted conversation lives until an explicit `delete`. A
|
|
2908
|
+
* durable backend (JSON / SQLite / IndexedDB) swaps in through the SAME interface without touching
|
|
2909
|
+
* the {@link import('../ConversationManager.js').ConversationManager} or the
|
|
2910
|
+
* {@link import('../Conversation.js').Conversation} — its driver-pluggable twin is
|
|
2911
|
+
* {@link import('./DatabaseConversationStore.js').DatabaseConversationStore} (the snapshot as one
|
|
2912
|
+
* opaque JSON column).
|
|
2913
|
+
*
|
|
2914
|
+
* - **`get` resolves the persisted snapshot for an id**, or `undefined` if none is stored.
|
|
2915
|
+
* - **`set` inserts / replaces under the snapshot's OWN `id`** (no separate id param).
|
|
2916
|
+
* - **`delete` drops a snapshot by id**; an absent id is a no-op (no throw).
|
|
2917
|
+
*
|
|
2918
|
+
* The public surface is EXACTLY `get` / `set` / `delete` — no extra members (the §22 method
|
|
2919
|
+
* bijection with {@link ConversationStoreInterface}). Hydration is a caller concern: a
|
|
2920
|
+
* {@link import('../ConversationManager.js').ConversationManager} reads a snapshot back and rebuilds
|
|
2921
|
+
* the live conversation through the constructor `seed` (its `open` / `save`).
|
|
2922
|
+
*
|
|
2923
|
+
* @example
|
|
2924
|
+
* ```ts
|
|
2925
|
+
* import { createConversation, createMemoryConversationStore } from '@src/core'
|
|
2926
|
+
*
|
|
2927
|
+
* const store = createMemoryConversationStore()
|
|
2928
|
+
* const conversation = createConversation()
|
|
2929
|
+
* conversation.add({ role: 'user', content: 'hello' })
|
|
2930
|
+
* await store.set(conversation.snapshot()) // persist the conversation
|
|
2931
|
+
* const snapshot = await store.get(conversation.id)
|
|
2932
|
+
* await store.delete(conversation.id) // drop it
|
|
2933
|
+
* ```
|
|
2934
|
+
*/
|
|
2935
|
+
var MemoryConversationStore = class {
|
|
2936
|
+
#snapshots = /* @__PURE__ */ new Map();
|
|
2937
|
+
get(id) {
|
|
2938
|
+
return Promise.resolve(this.#snapshots.get(id));
|
|
2939
|
+
}
|
|
2940
|
+
set(snapshot) {
|
|
2941
|
+
this.#snapshots.set(snapshot.id, snapshot);
|
|
2942
|
+
return Promise.resolve();
|
|
2943
|
+
}
|
|
2944
|
+
delete(id) {
|
|
2945
|
+
this.#snapshots.delete(id);
|
|
2946
|
+
return Promise.resolve();
|
|
2947
|
+
}
|
|
2948
|
+
};
|
|
2949
|
+
//#endregion
|
|
2950
|
+
//#region src/core/scopes/Scope.ts
|
|
2951
|
+
/**
|
|
2952
|
+
* A named, immutable filter over a richer context's items — three optional allow-lists,
|
|
2953
|
+
* one per category (`instructions` / `tools` / `files`), each keyed by that category's
|
|
2954
|
+
* identity (an instruction's `name`, a tool's `name`, a workspace file's `path`).
|
|
2955
|
+
*
|
|
2956
|
+
* @remarks
|
|
2957
|
+
* - **A category list is three-way.** `undefined` ⇒ NO constraint on that category (all
|
|
2958
|
+
* pass); `[]` ⇒ NONE pass; a non-empty list ⇒ only the listed keys pass. The build
|
|
2959
|
+
* step / loop apply this via `filterAllowList`.
|
|
2960
|
+
* - **Immutable.** The `id` is minted at construction; every supplied list is COPIED in
|
|
2961
|
+
* (so a later mutation of the caller's array can't leak in), and the lists are
|
|
2962
|
+
* `readonly`. A `Scope` is never mutated after construction — `narrow` returns a NEW
|
|
2963
|
+
* one rather than altering this one.
|
|
2964
|
+
* - **`narrow` is set-INTERSECTION (immutable composition).** A child scope's visible set
|
|
2965
|
+
* per category is the intersection of THIS scope's list and the config's list — but
|
|
2966
|
+
* `undefined` means "no constraint", so it acts as the universal set: intersecting
|
|
2967
|
+
* `undefined` with a list yields the list, and `undefined` with `undefined` stays
|
|
2968
|
+
* `undefined`. Narrowing can only TIGHTEN, never widen — a key excluded by a parent
|
|
2969
|
+
* can never be re-admitted by a child.
|
|
2970
|
+
*
|
|
2971
|
+
* @example
|
|
2972
|
+
* ```ts
|
|
2973
|
+
* const scope = new Scope({ name: 'reader', tools: ['search', 'read'] })
|
|
2974
|
+
* // narrow intersects: tools ∩ ['read', 'write'] = ['read'] (write was never in the parent).
|
|
2975
|
+
* const tighter = scope.narrow({ tools: ['read', 'write'] })
|
|
2976
|
+
* tighter.tools // ['read']
|
|
2977
|
+
* // instructions had no parent constraint (undefined) → the child's list passes through.
|
|
2978
|
+
* tighter.narrow({ instructions: ['safety'] }).instructions // ['safety']
|
|
2979
|
+
* ```
|
|
2980
|
+
*/
|
|
2981
|
+
var Scope = class Scope {
|
|
2982
|
+
id = crypto.randomUUID();
|
|
2983
|
+
name;
|
|
2984
|
+
instructions;
|
|
2985
|
+
tools;
|
|
2986
|
+
files;
|
|
2987
|
+
constructor(input) {
|
|
2988
|
+
this.name = input.name;
|
|
2989
|
+
this.instructions = input.instructions === void 0 ? void 0 : [...input.instructions];
|
|
2990
|
+
this.tools = input.tools === void 0 ? void 0 : [...input.tools];
|
|
2991
|
+
this.files = input.files === void 0 ? void 0 : [...input.files];
|
|
2992
|
+
}
|
|
2993
|
+
narrow(config) {
|
|
2994
|
+
return new Scope({
|
|
2995
|
+
name: this.name,
|
|
2996
|
+
instructions: Scope.#intersect(this.instructions, config.instructions),
|
|
2997
|
+
tools: Scope.#intersect(this.tools, config.tools),
|
|
2998
|
+
files: Scope.#intersect(this.files, config.files)
|
|
2999
|
+
});
|
|
3000
|
+
}
|
|
3001
|
+
static #intersect(parent, child) {
|
|
3002
|
+
if (parent === void 0) return child === void 0 ? void 0 : [...child];
|
|
3003
|
+
if (child === void 0) return [...parent];
|
|
3004
|
+
const allowed = new Set(parent);
|
|
3005
|
+
return child.filter((key) => allowed.has(key));
|
|
3006
|
+
}
|
|
3007
|
+
};
|
|
3008
|
+
//#endregion
|
|
3009
|
+
//#region src/core/scopes/ScopeManager.ts
|
|
3010
|
+
/**
|
|
3011
|
+
* The scope registry a richer context reuses named filters from — immutable {@link Scope}s
|
|
3012
|
+
* keyed by their minted `id`, in insertion order.
|
|
3013
|
+
*
|
|
3014
|
+
* @remarks
|
|
3015
|
+
* - **Registry.** Scopes live in an insertion-ordered `Map` keyed by their minted `id`;
|
|
3016
|
+
* `create` mints a {@link Scope} from a {@link ScopeInput} (an `id` plus the three
|
|
3017
|
+
* allow-lists), stores it, and returns it. `count` is the map size, `scope(id)` looks
|
|
3018
|
+
* one up, and `scopes()` lists them in insertion order. (Unlike the name-keyed
|
|
3019
|
+
* instruction registry, a scope's key is its minted `id`, so two scopes may share a
|
|
3020
|
+
* `name`; `create` therefore always adds — it never overwrites.)
|
|
3021
|
+
* - **Removal.** `remove` drops one by id, or a batch (§9.2) — `true` when any was
|
|
3022
|
+
* removed; `clear` empties the registry.
|
|
3023
|
+
* - **Observable (§13).** The owned {@link emitter} ({@link ScopeManagerEventMap}) carries
|
|
3024
|
+
* `create` (the created scope) / `remove` (the id) / `clear`. Every event is emitted
|
|
3025
|
+
* directly, strictly AFTER the map mutation completes; the emitter isolates a listener
|
|
3026
|
+
* throw and routes it to its `error` handler (the `error` option), so a buggy observer can
|
|
3027
|
+
* never corrupt a mutation.
|
|
3028
|
+
*
|
|
3029
|
+
* @example
|
|
3030
|
+
* ```ts
|
|
3031
|
+
* const manager = new ScopeManager()
|
|
3032
|
+
* const reader = manager.create({ name: 'reader', tools: ['search', 'read'] })
|
|
3033
|
+
* manager.scope(reader.id) // the same scope
|
|
3034
|
+
* manager.count // 1
|
|
3035
|
+
* ```
|
|
3036
|
+
*/
|
|
3037
|
+
var ScopeManager = class {
|
|
3038
|
+
#scopes = /* @__PURE__ */ new Map();
|
|
3039
|
+
#emitter;
|
|
3040
|
+
constructor(on, error) {
|
|
3041
|
+
this.#emitter = new _orkestrel_emitter.Emitter({
|
|
3042
|
+
on,
|
|
3043
|
+
error
|
|
3044
|
+
});
|
|
3045
|
+
}
|
|
3046
|
+
get emitter() {
|
|
3047
|
+
return this.#emitter;
|
|
3048
|
+
}
|
|
3049
|
+
get count() {
|
|
3050
|
+
return this.#scopes.size;
|
|
3051
|
+
}
|
|
3052
|
+
create(input) {
|
|
3053
|
+
const scope = new Scope(input);
|
|
3054
|
+
this.#scopes.set(scope.id, scope);
|
|
3055
|
+
this.#emitter.emit("create", scope);
|
|
3056
|
+
return scope;
|
|
3057
|
+
}
|
|
3058
|
+
scope(id) {
|
|
3059
|
+
return this.#scopes.get(id);
|
|
3060
|
+
}
|
|
3061
|
+
scopes() {
|
|
3062
|
+
return [...this.#scopes.values()];
|
|
3063
|
+
}
|
|
3064
|
+
remove(ids) {
|
|
3065
|
+
if ((0, _orkestrel_contract.isArray)(ids)) {
|
|
3066
|
+
let removed = false;
|
|
3067
|
+
for (const id of ids) if (this.#delete(id)) removed = true;
|
|
3068
|
+
return removed;
|
|
3069
|
+
}
|
|
3070
|
+
return this.#delete(ids);
|
|
3071
|
+
}
|
|
3072
|
+
clear() {
|
|
3073
|
+
this.#scopes.clear();
|
|
3074
|
+
this.#emitter.emit("clear");
|
|
3075
|
+
}
|
|
3076
|
+
#delete(id) {
|
|
3077
|
+
const removed = this.#scopes.delete(id);
|
|
3078
|
+
if (removed) this.#emitter.emit("remove", id);
|
|
3079
|
+
return removed;
|
|
3080
|
+
}
|
|
3081
|
+
};
|
|
3082
|
+
//#endregion
|
|
3083
|
+
//#region src/core/ThinkSplitter.ts
|
|
3084
|
+
/**
|
|
3085
|
+
* The stream-stateful `<think>` separator — feeds raw content deltas through a tiny
|
|
3086
|
+
* state machine that routes everything inside a `<think>…</think>` span to `thinking`
|
|
3087
|
+
* and returns everything outside it as clean content, so a provider yields ONLY the
|
|
3088
|
+
* answer and surfaces the reasoning as {@link import('./types.js').ProviderResult.thinking}.
|
|
3089
|
+
*
|
|
3090
|
+
* @remarks
|
|
3091
|
+
* - **Cross-chunk tags.** A tag may arrive split across wire deltas (`'<thi'` then
|
|
3092
|
+
* `'nk>'`): any suffix of the pending text that is a strict PREFIX of a tag being
|
|
3093
|
+
* scanned for is HELD BACK (neither surfaced nor routed) until the next delta — or
|
|
3094
|
+
* `flush()` — disambiguates it. A held tag prefix that never completes is real
|
|
3095
|
+
* content; a held close-tag prefix inside a span is thinking.
|
|
3096
|
+
* - **The IMPLICIT leading open (the qwen3-template shape).** Some chat templates
|
|
3097
|
+
* PRE-SEED `<think>` into the prompt scaffold, so the wire stream begins
|
|
3098
|
+
* MID-REASONING and only a bare `</think>` appears. Before any tag event, a bare
|
|
3099
|
+
* close therefore RECLASSIFIES everything surfaced so far (plus the pre-close
|
|
3100
|
+
* pending) as thinking — `content` is corrected retroactively (the already-returned
|
|
3101
|
+
* prefix cannot be recalled, so `content` is the authoritative accumulation). The
|
|
3102
|
+
* rule is ONE-SHOT: after any tag event a bare `</think>` is plain text.
|
|
3103
|
+
* - **Multiple spans** accumulate onto `thinking` in stream order. A nested-looking
|
|
3104
|
+
* `<think>` inside an open span is just thinking text (no nesting is tracked — the
|
|
3105
|
+
* first `</think>` closes the span), matching how the models emit it.
|
|
3106
|
+
* - **Unclosed span at stream end.** `flush()` routes the open span's tail (including
|
|
3107
|
+
* any held partial close tag) to `thinking` — a cut-off model was still reasoning.
|
|
3108
|
+
* - **One splitter, one stream.** State is per-stream; create a fresh instance per
|
|
3109
|
+
* provider call ({@link import('./factories.js').createThinkSplitter}).
|
|
3110
|
+
*
|
|
3111
|
+
* @example
|
|
3112
|
+
* ```ts
|
|
3113
|
+
* const splitter = new ThinkSplitter()
|
|
3114
|
+
* splitter.split('<thi') // '' (held — ambiguous)
|
|
3115
|
+
* splitter.split('nk>plan</think>ok') // 'ok'
|
|
3116
|
+
* splitter.thinking // 'plan'
|
|
3117
|
+
* splitter.content // 'ok'
|
|
3118
|
+
* splitter.flush() // '' (nothing held)
|
|
3119
|
+
* ```
|
|
3120
|
+
*/
|
|
3121
|
+
var ThinkSplitter = class {
|
|
3122
|
+
#pending = "";
|
|
3123
|
+
#inside = false;
|
|
3124
|
+
#opened = false;
|
|
3125
|
+
#content = "";
|
|
3126
|
+
#thinking = "";
|
|
3127
|
+
get content() {
|
|
3128
|
+
return this.#content;
|
|
3129
|
+
}
|
|
3130
|
+
get thinking() {
|
|
3131
|
+
return this.#thinking;
|
|
3132
|
+
}
|
|
3133
|
+
split(delta) {
|
|
3134
|
+
const out = this.#scan(delta);
|
|
3135
|
+
this.#content += out;
|
|
3136
|
+
return out;
|
|
3137
|
+
}
|
|
3138
|
+
flush() {
|
|
3139
|
+
const pending = this.#pending;
|
|
3140
|
+
this.#pending = "";
|
|
3141
|
+
if (this.#inside) {
|
|
3142
|
+
this.#thinking += pending;
|
|
3143
|
+
this.#inside = false;
|
|
3144
|
+
return "";
|
|
3145
|
+
}
|
|
3146
|
+
this.#content += pending;
|
|
3147
|
+
return pending;
|
|
3148
|
+
}
|
|
3149
|
+
#scan(delta) {
|
|
3150
|
+
this.#pending += delta;
|
|
3151
|
+
let content = "";
|
|
3152
|
+
for (;;) {
|
|
3153
|
+
if (this.#inside) {
|
|
3154
|
+
const close = this.#pending.indexOf(THINK_CLOSE);
|
|
3155
|
+
if (close === -1) {
|
|
3156
|
+
this.#thinking += this.#hold([THINK_CLOSE]);
|
|
3157
|
+
return content;
|
|
3158
|
+
}
|
|
3159
|
+
this.#thinking += this.#pending.slice(0, close);
|
|
3160
|
+
this.#pending = this.#pending.slice(close + THINK_CLOSE.length);
|
|
3161
|
+
this.#inside = false;
|
|
3162
|
+
continue;
|
|
3163
|
+
}
|
|
3164
|
+
const open = this.#pending.indexOf(THINK_OPEN);
|
|
3165
|
+
if (!this.#opened) {
|
|
3166
|
+
const close = this.#pending.indexOf(THINK_CLOSE);
|
|
3167
|
+
if (close !== -1 && (open === -1 || close < open)) {
|
|
3168
|
+
this.#thinking += this.#content + content + this.#pending.slice(0, close);
|
|
3169
|
+
this.#content = "";
|
|
3170
|
+
content = "";
|
|
3171
|
+
this.#pending = this.#pending.slice(close + THINK_CLOSE.length);
|
|
3172
|
+
this.#opened = true;
|
|
3173
|
+
continue;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
if (open === -1) {
|
|
3177
|
+
const tags = this.#opened ? [THINK_OPEN] : [THINK_OPEN, THINK_CLOSE];
|
|
3178
|
+
content += this.#hold(tags);
|
|
3179
|
+
return content;
|
|
3180
|
+
}
|
|
3181
|
+
this.#opened = true;
|
|
3182
|
+
content += this.#pending.slice(0, open);
|
|
3183
|
+
this.#pending = this.#pending.slice(open + THINK_OPEN.length);
|
|
3184
|
+
this.#inside = true;
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
#hold(tags) {
|
|
3188
|
+
const keep = Math.max(...tags.map((tag) => this.#overlap(tag)));
|
|
3189
|
+
const cut = this.#pending.length - keep;
|
|
3190
|
+
const settled = this.#pending.slice(0, cut);
|
|
3191
|
+
this.#pending = this.#pending.slice(cut);
|
|
3192
|
+
return settled;
|
|
3193
|
+
}
|
|
3194
|
+
#overlap(tag) {
|
|
3195
|
+
const max = Math.min(this.#pending.length, tag.length - 1);
|
|
3196
|
+
for (let length = max; length > 0; length -= 1) if (this.#pending.endsWith(tag.slice(0, length))) return length;
|
|
3197
|
+
return 0;
|
|
3198
|
+
}
|
|
3199
|
+
};
|
|
3200
|
+
//#endregion
|
|
3201
|
+
//#region src/core/tools/Tool.ts
|
|
3202
|
+
/**
|
|
3203
|
+
* A registered tool — its {@link import('../types.js').ToolDefinition} schema (the
|
|
3204
|
+
* `name` / `description` / `parameters` the model sees) bound to the `execute`
|
|
3205
|
+
* handler that runs a call.
|
|
3206
|
+
*
|
|
3207
|
+
* @remarks
|
|
3208
|
+
* A thin value object: the constructor stores the schema fields and the handler, and
|
|
3209
|
+
* `execute` delegates to the handler verbatim. `parameters` is kept by reference (the
|
|
3210
|
+
* open JSON-Schema the provider forwards as-is) — not cloned. The handler's `args` is
|
|
3211
|
+
* the model-supplied `unknown` arguments record, narrowed inside the handler (§14);
|
|
3212
|
+
* isolating a throw is the {@link ToolManager}'s job, not this class's. Event-free —
|
|
3213
|
+
* no Emitter, no events.
|
|
3214
|
+
*
|
|
3215
|
+
* @example
|
|
3216
|
+
* ```ts
|
|
3217
|
+
* const tool = new Tool({
|
|
3218
|
+
* name: 'add',
|
|
3219
|
+
* description: 'Add two numbers',
|
|
3220
|
+
* parameters: { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } } },
|
|
3221
|
+
* execute: (args) => Number(args.a) + Number(args.b),
|
|
3222
|
+
* })
|
|
3223
|
+
* ```
|
|
3224
|
+
*/
|
|
3225
|
+
var Tool = class {
|
|
3226
|
+
name;
|
|
3227
|
+
description;
|
|
3228
|
+
parameters;
|
|
3229
|
+
#execute;
|
|
3230
|
+
constructor(options) {
|
|
3231
|
+
this.name = options.name;
|
|
3232
|
+
this.description = options.description;
|
|
3233
|
+
this.parameters = options.parameters;
|
|
3234
|
+
this.#execute = options.execute;
|
|
3235
|
+
}
|
|
3236
|
+
execute(args) {
|
|
3237
|
+
return this.#execute(args);
|
|
3238
|
+
}
|
|
3239
|
+
};
|
|
3240
|
+
//#endregion
|
|
3241
|
+
//#region src/core/workspaces/stores/DatabaseWorkspaceStore.ts
|
|
3242
|
+
/**
|
|
3243
|
+
* A {@link WorkspaceStoreInterface} backed by one table of the `databases` layer — a workspace's
|
|
3244
|
+
* durable state IS a row, so persistence reduces to keyed point-access (`get` / `set` / `delete`)
|
|
3245
|
+
* over a {@link TableInterface}, the driver-pluggable twin of the plain-`Map`
|
|
3246
|
+
* {@link import('./MemoryWorkspaceStore.js').MemoryWorkspaceStore}.
|
|
3247
|
+
*
|
|
3248
|
+
* @remarks
|
|
3249
|
+
* The store is driver-agnostic: it holds a single {@link TableInterface} whose backend (memory,
|
|
3250
|
+
* JSON, SQLite, IndexedDB) is chosen by whoever builds it (the factories), so a JSON / SQLite /
|
|
3251
|
+
* IndexedDB backend swaps in WITHOUT touching the
|
|
3252
|
+
* {@link import('../WorkspaceManager.js').WorkspaceManager} or the
|
|
3253
|
+
* {@link import('../Workspace.js').Workspace} — the same seam as
|
|
3254
|
+
* the analogous `DatabaseWorkflowStore` in `@orkestrel/workflow`. The
|
|
3255
|
+
* driver defaults to memory ({@link import('../../factories.js').createDatabaseWorkspaceStore}
|
|
3256
|
+
* passes `createMemoryDriver()`), so it ALSO works in memory out of the box; you opt into the
|
|
3257
|
+
* durable plumbing by passing a JSON / SQLite / IndexedDB driver.
|
|
3258
|
+
*
|
|
3259
|
+
* The {@link WorkspaceSnapshot} is stored as ONE OPAQUE JSON COLUMN — the table is a row of
|
|
3260
|
+
* `{ id; snapshot }` ({@link WorkspaceSnapshotRow}), the snapshot the whole JSON blob (a `rawShape`
|
|
3261
|
+
* column the factory builds) — exactly as `DatabaseWorkflowStore` stores its snapshot. The snapshot
|
|
3262
|
+
* is already a COMPLETE, self-contained, pure-JSON payload, so storing it whole is lossless AND
|
|
3263
|
+
* keeps the row type flat (`snapshot` reads back as `unknown`).
|
|
3264
|
+
*
|
|
3265
|
+
* - **`set(snapshot)` upserts under the snapshot's OWN `id`** (no separate id param) — it writes
|
|
3266
|
+
* the row `{ id: snapshot.id, snapshot }`.
|
|
3267
|
+
* - **`get(id)` resolves the stored snapshot for an id**, narrowing the opaque JSON column back to
|
|
3268
|
+
* a {@link WorkspaceSnapshot} ({@link import('../../helpers.js').isWorkspaceSnapshot} — the AGENTS
|
|
3269
|
+
* §14 boundary narrow for an untrusted storage read), or `undefined` if none is stored.
|
|
3270
|
+
* - **`delete(id)` drops a snapshot by id**; an absent id is a no-op (no throw).
|
|
3271
|
+
*
|
|
3272
|
+
* UNLIKE a session store there is NO idle-TTL / eviction — a persisted workspace lives until an
|
|
3273
|
+
* explicit `delete`. The public surface is EXACTLY `get` / `set` / `delete` — no extra members (the
|
|
3274
|
+
* §22 method bijection with {@link WorkspaceStoreInterface}). Hydration stays a caller concern: a
|
|
3275
|
+
* {@link import('../WorkspaceManager.js').WorkspaceManager} reads a snapshot back and rebuilds the
|
|
3276
|
+
* live workspace through the constructor `seed` (its `open` / `save`).
|
|
3277
|
+
*
|
|
3278
|
+
* @example
|
|
3279
|
+
* ```ts
|
|
3280
|
+
* import { createDatabaseWorkspaceStore, createMemoryDriver, createWorkspace } from '@src/core'
|
|
3281
|
+
*
|
|
3282
|
+
* const store = createDatabaseWorkspaceStore(createMemoryDriver()) // a durable driver swaps in here
|
|
3283
|
+
* const workspace = createWorkspace()
|
|
3284
|
+
* workspace.write('notes.txt', 'hello')
|
|
3285
|
+
* await store.set(workspace.snapshot()) // persist the workspace (one JSON column)
|
|
3286
|
+
* const snapshot = await store.get(workspace.id)
|
|
3287
|
+
* await store.delete(workspace.id) // drop it
|
|
3288
|
+
* ```
|
|
3289
|
+
*/
|
|
3290
|
+
var DatabaseWorkspaceStore = class {
|
|
3291
|
+
#table;
|
|
3292
|
+
/**
|
|
3293
|
+
* Wrap a table as a workspace store.
|
|
3294
|
+
*
|
|
3295
|
+
* @param table - The {@link TableInterface} holding the snapshots — its row is the
|
|
3296
|
+
* {@link WorkspaceSnapshotRow} `{ id; snapshot }` shape (the snapshot one opaque JSON column)
|
|
3297
|
+
*/
|
|
3298
|
+
constructor(table) {
|
|
3299
|
+
this.#table = table;
|
|
3300
|
+
}
|
|
3301
|
+
/** Resolve the persisted snapshot for `id`, narrowing the opaque JSON column back to a `WorkspaceSnapshot`. */
|
|
3302
|
+
async get(id) {
|
|
3303
|
+
const row = await this.#table.get(id);
|
|
3304
|
+
if (row === void 0) return void 0;
|
|
3305
|
+
return isWorkspaceSnapshot(row.snapshot) ? row.snapshot : void 0;
|
|
3306
|
+
}
|
|
3307
|
+
/** Insert or replace under the snapshot's OWN `id` (no separate id param) — the row is `{ id, snapshot }`. */
|
|
3308
|
+
async set(snapshot) {
|
|
3309
|
+
await this.#table.set({
|
|
3310
|
+
id: snapshot.id,
|
|
3311
|
+
snapshot
|
|
3312
|
+
});
|
|
3313
|
+
}
|
|
3314
|
+
/** Drop a snapshot by id; an absent id is a no-op (no throw). */
|
|
3315
|
+
async delete(id) {
|
|
3316
|
+
await this.#table.remove(id);
|
|
3317
|
+
}
|
|
3318
|
+
};
|
|
3319
|
+
//#endregion
|
|
3320
|
+
//#region src/core/workspaces/stores/MemoryWorkspaceStore.ts
|
|
3321
|
+
/**
|
|
3322
|
+
* The in-memory {@link WorkspaceStoreInterface} — a process-lifetime `Map` of
|
|
3323
|
+
* {@link WorkspaceSnapshot}s keyed by workspace id, the DEFAULT store
|
|
3324
|
+
* {@link import('../../factories.js').createMemoryWorkspaceStore} builds.
|
|
3325
|
+
*
|
|
3326
|
+
* @remarks
|
|
3327
|
+
* A plain `Map<string, WorkspaceSnapshot>` (AGENTS §21 — the snapshot is already pure,
|
|
3328
|
+
* self-contained JSON, so no encoding is needed for the memory tier). Like the
|
|
3329
|
+
* the analogous `MemoryWorkflowStore` in `@orkestrel/workflow` it twins,
|
|
3330
|
+
* there is NO idle-TTL and NO eviction: a persisted workspace lives until an explicit `delete`. A
|
|
3331
|
+
* durable backend (JSON / SQLite / IndexedDB) swaps in through the SAME interface without touching
|
|
3332
|
+
* the {@link import('../WorkspaceManager.js').WorkspaceManager} or the
|
|
3333
|
+
* {@link import('../Workspace.js').Workspace} — its driver-pluggable twin is
|
|
3334
|
+
* {@link import('./DatabaseWorkspaceStore.js').DatabaseWorkspaceStore} (the snapshot as one opaque
|
|
3335
|
+
* JSON column).
|
|
3336
|
+
*
|
|
3337
|
+
* - **`get` resolves the persisted snapshot for an id**, or `undefined` if none is stored.
|
|
3338
|
+
* - **`set` inserts / replaces under the snapshot's OWN `id`** (no separate id param).
|
|
3339
|
+
* - **`delete` drops a snapshot by id**; an absent id is a no-op (no throw).
|
|
3340
|
+
*
|
|
3341
|
+
* The public surface is EXACTLY `get` / `set` / `delete` — no extra members (the §22 method
|
|
3342
|
+
* bijection with {@link WorkspaceStoreInterface}). Hydration is a caller concern: a
|
|
3343
|
+
* {@link import('../WorkspaceManager.js').WorkspaceManager} reads a snapshot back and rebuilds the
|
|
3344
|
+
* live workspace through the constructor `seed` (its `open` / `save`).
|
|
3345
|
+
*
|
|
3346
|
+
* @example
|
|
3347
|
+
* ```ts
|
|
3348
|
+
* import { createMemoryWorkspaceStore, createWorkspace } from '@src/core'
|
|
3349
|
+
*
|
|
3350
|
+
* const store = createMemoryWorkspaceStore()
|
|
3351
|
+
* const workspace = createWorkspace()
|
|
3352
|
+
* workspace.write('notes.txt', 'hello')
|
|
3353
|
+
* await store.set(workspace.snapshot()) // persist the workspace
|
|
3354
|
+
* const snapshot = await store.get(workspace.id)
|
|
3355
|
+
* await store.delete(workspace.id) // drop it
|
|
3356
|
+
* ```
|
|
3357
|
+
*/
|
|
3358
|
+
var MemoryWorkspaceStore = class {
|
|
3359
|
+
#snapshots = /* @__PURE__ */ new Map();
|
|
3360
|
+
get(id) {
|
|
3361
|
+
return Promise.resolve(this.#snapshots.get(id));
|
|
3362
|
+
}
|
|
3363
|
+
set(snapshot) {
|
|
3364
|
+
this.#snapshots.set(snapshot.id, snapshot);
|
|
3365
|
+
return Promise.resolve();
|
|
3366
|
+
}
|
|
3367
|
+
delete(id) {
|
|
3368
|
+
this.#snapshots.delete(id);
|
|
3369
|
+
return Promise.resolve();
|
|
3370
|
+
}
|
|
3371
|
+
};
|
|
3372
|
+
//#endregion
|
|
3373
|
+
//#region src/core/factories.ts
|
|
3374
|
+
/**
|
|
3375
|
+
* Create a tool — a {@link ToolInterface} binding a {@link ToolDefinition} schema
|
|
3376
|
+
* (the `name` / `description` / `parameters` the model sees) to the `execute` handler
|
|
3377
|
+
* that runs a call.
|
|
3378
|
+
*
|
|
3379
|
+
* @remarks
|
|
3380
|
+
* Only `name` is required (it keys the tool in a {@link ToolManagerInterface} and is
|
|
3381
|
+
* what the model calls); `description` / `parameters` are the optional JSON-Schema the
|
|
3382
|
+
* provider advertises (forwarded verbatim). The handler's `args` is the model-supplied
|
|
3383
|
+
* `unknown` arguments record — narrow it inside (§14); a `createToolManager` registry
|
|
3384
|
+
* isolates a throw into a `ToolResult.error`.
|
|
3385
|
+
*
|
|
3386
|
+
* @param options - `name` (required), optional `description` / `parameters`, and the
|
|
3387
|
+
* `execute` handler (see {@link ToolOptions})
|
|
3388
|
+
* @returns A working {@link ToolInterface}
|
|
3389
|
+
*
|
|
3390
|
+
* @example
|
|
3391
|
+
* ```ts
|
|
3392
|
+
* import { createTool } from '@src/core'
|
|
3393
|
+
*
|
|
3394
|
+
* const add = createTool({
|
|
3395
|
+
* name: 'add',
|
|
3396
|
+
* description: 'Add two numbers',
|
|
3397
|
+
* execute: (args) => Number(args.a) + Number(args.b),
|
|
3398
|
+
* })
|
|
3399
|
+
* ```
|
|
3400
|
+
*/
|
|
3401
|
+
function createTool(options) {
|
|
3402
|
+
return new Tool(options);
|
|
3403
|
+
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Create a tool registry — a {@link ToolManagerInterface} that resolves tool names,
|
|
3406
|
+
* lists {@link ToolDefinition}s for the provider, and executes calls with per-call
|
|
3407
|
+
* error isolation.
|
|
3408
|
+
*
|
|
3409
|
+
* @remarks
|
|
3410
|
+
* Starts empty; `add` registers one tool or a batch (§9.2), `definitions()` yields the
|
|
3411
|
+
* schemas to hand a provider, and `execute` runs a {@link ToolCall} (or a batch),
|
|
3412
|
+
* ALWAYS resolving a {@link ToolResult} — a handler throw becomes an `error` result and
|
|
3413
|
+
* an unknown name a not-found `error`, so a tool throw never escapes and a batch never
|
|
3414
|
+
* fails as a whole.
|
|
3415
|
+
*
|
|
3416
|
+
* @returns An empty {@link ToolManagerInterface}
|
|
3417
|
+
*
|
|
3418
|
+
* @example
|
|
3419
|
+
* ```ts
|
|
3420
|
+
* import { createTool, createToolManager } from '@src/core'
|
|
3421
|
+
*
|
|
3422
|
+
* const tools = createToolManager()
|
|
3423
|
+
* tools.add(createTool({ name: 'add', execute: (a) => Number(a.x) + Number(a.y) }))
|
|
3424
|
+
* const result = await tools.execute({ id: '1', name: 'add', arguments: { x: 1, y: 2 } })
|
|
3425
|
+
* ```
|
|
3426
|
+
*/
|
|
3427
|
+
function createToolManager() {
|
|
3428
|
+
return new ToolManager();
|
|
3429
|
+
}
|
|
3430
|
+
/**
|
|
3431
|
+
* Create a conversation — a {@link ConversationInterface} grouping messages above a flat
|
|
3432
|
+
* message store it OWNS DIRECTLY, with compaction into summarized sections, a regenerated
|
|
3433
|
+
* rollup `summary`, on-demand `rehydrate`, and substring `search`, driven by a
|
|
3434
|
+
* provider-agnostic {@link ConversationSummarizer} seam.
|
|
3435
|
+
*
|
|
3436
|
+
* @remarks
|
|
3437
|
+
* Append turns through the conversation's own `add` (the live tail it owns); `view()` is the model input
|
|
3438
|
+
* (each section as a summary message, then the live tail). `compact()` folds the older live
|
|
3439
|
+
* messages into a summarized {@link SectionInterface} and regenerates the rollup — it REQUIRES
|
|
3440
|
+
* a `summarize` (omitted ⇒ `compact()` throws a `ConversationError`); `keep` retains a recent
|
|
3441
|
+
* tail (default `DEFAULT_CONVERSATION_KEEP` — fold ALL). `rehydrate(id)` / `search(query)` read
|
|
3442
|
+
* the retained originals. Observable (`emitter` — `compact` / `summary` / `rehydrate`), wired
|
|
3443
|
+
* via the reserved `on` option (§8); the emitter isolates a listener throw and routes it to
|
|
3444
|
+
* its `error` handler (the `error` option, §13), so it can never corrupt a compaction.
|
|
3445
|
+
*
|
|
3446
|
+
* @param options - Optional `id` / `on` hooks + the `summarize` seam + `keep` (see {@link ConversationOptions})
|
|
3447
|
+
* @returns A working {@link ConversationInterface}
|
|
3448
|
+
*
|
|
3449
|
+
* @example
|
|
3450
|
+
* ```ts
|
|
3451
|
+
* import type { ProviderInterface } from '@src/core'
|
|
3452
|
+
* import { createConversation } from '@src/core'
|
|
3453
|
+
*
|
|
3454
|
+
* declare const provider: ProviderInterface // any concrete implementation supplied by the host app
|
|
3455
|
+
* const conversation = createConversation({
|
|
3456
|
+
* // Append the instruction as the FINAL user turn — a chat model emits nothing when the
|
|
3457
|
+
* // prompt ends on an assistant turn, so a leading-system instruction is unreliable.
|
|
3458
|
+
* summarize: async (messages) =>
|
|
3459
|
+
* (await provider.generate([...messages, { id: 's', role: 'user', content: 'Summarize the conversation so far concisely.' }], AbortSignal.timeout(30_000))).content,
|
|
3460
|
+
* })
|
|
3461
|
+
* conversation.add({ role: 'user', content: 'Hello' })
|
|
3462
|
+
* await conversation.compact() // folds the live tail into a summarized section
|
|
3463
|
+
* ```
|
|
3464
|
+
*/
|
|
3465
|
+
function createConversation(options) {
|
|
3466
|
+
return new Conversation(options);
|
|
3467
|
+
}
|
|
3468
|
+
/**
|
|
3469
|
+
* Create a conversation registry — a {@link ConversationManagerInterface} holding
|
|
3470
|
+
* {@link ConversationInterface}s keyed by their `id` (in insertion order) WITH an active pointer:
|
|
3471
|
+
* the §9 store over the conversation layer plus the `active` / `switch` seam the context renders.
|
|
3472
|
+
*
|
|
3473
|
+
* @remarks
|
|
3474
|
+
* Starts empty; `add(input?)` mints a {@link ConversationInterface} (its `id` from the input
|
|
3475
|
+
* or a random UUID), flowing the manager's default `summarize` / `keep` in unless the input
|
|
3476
|
+
* overrides them, and stores it (an already-present `id` overwrites — last write wins) — and
|
|
3477
|
+
* AUTO-ACTIVATES the FIRST one (a registry with conversations always has one `active`); a later
|
|
3478
|
+
* `add` leaves `active` unchanged. `switch(id)` re-points `active` (an unknown `id` returns
|
|
3479
|
+
* `undefined`, leaving `active` unchanged — lenient, never throws); `conversation(id)` /
|
|
3480
|
+
* `conversations()` look up; `remove` (one or a batch, §9.2) reports whether any was removed AND
|
|
3481
|
+
* clears `active` if it was the removed one; `clear` empties it and clears `active`. Event-free
|
|
3482
|
+
* (each conversation owns its own observable `emitter`). A conversation created with NEITHER a
|
|
3483
|
+
* manager default nor a per-`add` `summarize` cannot `compact` (it throws a `ConversationError`).
|
|
3484
|
+
*
|
|
3485
|
+
* @param options - Optional default `summarize` / `keep` (see {@link ConversationManagerOptions})
|
|
3486
|
+
* @returns An empty {@link ConversationManagerInterface}
|
|
3487
|
+
*
|
|
3488
|
+
* @example
|
|
3489
|
+
* ```ts
|
|
3490
|
+
* import { createConversationManager } from '@src/core'
|
|
3491
|
+
*
|
|
3492
|
+
* const conversations = createConversationManager({ summarize: async (m) => `recap of ${m.length}` })
|
|
3493
|
+
* const chat = conversations.add() // auto-activates — conversations.active === chat
|
|
3494
|
+
* chat.add({ role: 'user', content: 'Hello' })
|
|
3495
|
+
* ```
|
|
3496
|
+
*/
|
|
3497
|
+
function createConversationManager(options) {
|
|
3498
|
+
return new ConversationManager(options);
|
|
3499
|
+
}
|
|
3500
|
+
/**
|
|
3501
|
+
* Create the in-memory conversation store — a {@link ConversationStoreInterface} backed by a
|
|
3502
|
+
* process-lifetime `Map` of {@link import('./types.js').ConversationSnapshot}s keyed by conversation
|
|
3503
|
+
* id, the DEFAULT backing for the durable {@link ConversationManagerInterface.open} /
|
|
3504
|
+
* {@link ConversationManagerInterface.save} seam. The EXACT twin of {@link createMemoryWorkspaceStore}.
|
|
3505
|
+
*
|
|
3506
|
+
* @remarks
|
|
3507
|
+
* A plain `Map` (the snapshot is already pure JSON, so no encoding is needed for the memory tier),
|
|
3508
|
+
* the structural twin of {@link createMemoryWorkspaceStore}. `get` / `set` / `delete` are async (the
|
|
3509
|
+
* same shape a durable backend fits); UNLIKE a session store there is NO idle-TTL / eviction — a
|
|
3510
|
+
* persisted conversation lives until an explicit `delete`. Its driver-pluggable twin is
|
|
3511
|
+
* {@link createDatabaseConversationStore} (the snapshot as one opaque JSON column over a `databases`
|
|
3512
|
+
* table) — for a DURABLE store pass it a JSON / SQLite / IndexedDB driver, and it swaps in WITHOUT
|
|
3513
|
+
* touching the manager or the conversation. Hydration stays a manager concern: read a snapshot back
|
|
3514
|
+
* and rebuild the live conversation through the constructor `seed` (re-supplying the live
|
|
3515
|
+
* `summarize` / `keep`).
|
|
3516
|
+
*
|
|
3517
|
+
* @returns A memory-backed {@link ConversationStoreInterface}
|
|
3518
|
+
*
|
|
3519
|
+
* @example
|
|
3520
|
+
* ```ts
|
|
3521
|
+
* import { createConversationManager, createMemoryConversationStore } from '@src/core'
|
|
3522
|
+
*
|
|
3523
|
+
* const store = createMemoryConversationStore()
|
|
3524
|
+
* const manager = createConversationManager({ store })
|
|
3525
|
+
* const conversation = manager.add()
|
|
3526
|
+
* conversation.add({ role: 'user', content: 'hello' })
|
|
3527
|
+
* await manager.save(conversation.id) // persist the conversation
|
|
3528
|
+
* ```
|
|
3529
|
+
*/
|
|
3530
|
+
function createMemoryConversationStore() {
|
|
3531
|
+
return new MemoryConversationStore();
|
|
3532
|
+
}
|
|
3533
|
+
/**
|
|
3534
|
+
* Create a {@link DatabaseConversationStore} over any {@link DriverInterface} — the durable,
|
|
3535
|
+
* driver-pluggable backing for the conversation persistence seam, the opt-in twin of
|
|
3536
|
+
* {@link createMemoryConversationStore}. The EXACT twin of {@link createDatabaseWorkspaceStore}.
|
|
3537
|
+
*
|
|
3538
|
+
* @remarks
|
|
3539
|
+
* Builds a one-table database (`conversations`, keyed by `id`) over the supplied driver, the snapshot
|
|
3540
|
+
* held as ONE OPAQUE JSON COLUMN — the column map is `{ id; snapshot }` where `snapshot` is a
|
|
3541
|
+
* `rawShape` (a JSON blob), exactly as {@link createDatabaseWorkspaceStore} stores its snapshot. The
|
|
3542
|
+
* snapshot is already a COMPLETE, self-contained, pure-JSON payload, so storing it whole is lossless
|
|
3543
|
+
* AND keeps the row type FLAT (the column reads back as `unknown`, narrowed on `get` by
|
|
3544
|
+
* {@link import('./helpers.js').isConversationSnapshot}). The `driver` DEFAULTS to
|
|
3545
|
+
* {@link createMemoryDriver}, so the store ALSO works in memory out of the box; pass a server
|
|
3546
|
+
* `createJSONDriver` / `createSQLiteDriver` (or a browser IndexedDB driver) for a persistent one —
|
|
3547
|
+
* the durability is the driver's job, the store engine is shared. It swaps in behind
|
|
3548
|
+
* {@link ConversationStoreInterface} WITHOUT touching the manager or the conversation.
|
|
3549
|
+
*
|
|
3550
|
+
* @param driver - The storage backend the snapshots persist to (defaults to {@link createMemoryDriver})
|
|
3551
|
+
* @returns A {@link ConversationStoreInterface} over the driver
|
|
3552
|
+
*
|
|
3553
|
+
* @example
|
|
3554
|
+
* ```ts
|
|
3555
|
+
* import { createConversationManager, createDatabaseConversationStore, createMemoryDriver } from '@src/core'
|
|
3556
|
+
*
|
|
3557
|
+
* const store = createDatabaseConversationStore(createMemoryDriver()) // a durable driver swaps in here
|
|
3558
|
+
* const manager = createConversationManager({ store })
|
|
3559
|
+
* const conversation = manager.add()
|
|
3560
|
+
* conversation.add({ role: 'user', content: 'hello' })
|
|
3561
|
+
* await manager.save(conversation.id) // persist the conversation (one JSON column)
|
|
3562
|
+
* ```
|
|
3563
|
+
*/
|
|
3564
|
+
function createDatabaseConversationStore(driver = (0, _orkestrel_database.createMemoryDriver)()) {
|
|
3565
|
+
return new DatabaseConversationStore((0, _orkestrel_database.createDatabase)({
|
|
3566
|
+
driver,
|
|
3567
|
+
tables: { conversations: {
|
|
3568
|
+
id: (0, _orkestrel_contract.stringShape)(),
|
|
3569
|
+
snapshot: (0, _orkestrel_contract.rawShape)({})
|
|
3570
|
+
} }
|
|
3571
|
+
}).table("conversations"));
|
|
3572
|
+
}
|
|
3573
|
+
/**
|
|
3574
|
+
* Create an instruction — an immutable {@link InstructionInterface} (a named directive)
|
|
3575
|
+
* from its `name` / `content` and optional `priority`, the `id` minted at construction.
|
|
3576
|
+
*
|
|
3577
|
+
* @remarks
|
|
3578
|
+
* Only `name` / `content` are required; `priority` orders the instruction in an
|
|
3579
|
+
* {@link InstructionManagerInterface}'s rendered list (higher first) and defaults to `0`.
|
|
3580
|
+
* Stored immutable — never mutated after creation.
|
|
3581
|
+
*
|
|
3582
|
+
* @param input - `name` / `content` (required) and an optional `priority` (see
|
|
3583
|
+
* {@link InstructionInput})
|
|
3584
|
+
* @returns A working {@link InstructionInterface}
|
|
3585
|
+
*
|
|
3586
|
+
* @example
|
|
3587
|
+
* ```ts
|
|
3588
|
+
* import { createInstruction } from '@src/core'
|
|
3589
|
+
*
|
|
3590
|
+
* const instruction = createInstruction({ name: 'tone', content: 'Be concise.', priority: 5 })
|
|
3591
|
+
* ```
|
|
3592
|
+
*/
|
|
3593
|
+
function createInstruction(input) {
|
|
3594
|
+
return new Instruction(input);
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* Create an instruction registry — an {@link InstructionManagerInterface} holding
|
|
3598
|
+
* immutable instructions keyed by `name`, listed by descending `priority`.
|
|
3599
|
+
*
|
|
3600
|
+
* @remarks
|
|
3601
|
+
* Starts empty; `add` (one or a batch, §9.2) MINTS each `id` and OVERWRITES a same-name
|
|
3602
|
+
* instruction (last write wins); `instructions()` lists them sorted by descending
|
|
3603
|
+
* `priority` (stable for ties); `format` / `description` are the build contract a richer
|
|
3604
|
+
* context renders an instructions block with; `remove` (one or a batch) reports whether
|
|
3605
|
+
* any was removed; `clear` empties it. Carries an observable `emitter`
|
|
3606
|
+
* ({@link import('./types.js').InstructionManagerEventMap}) wired via the reserved `on`
|
|
3607
|
+
* option (§8); the emitter isolates a listener throw and routes it to its `error` handler
|
|
3608
|
+
* (the `error` option, §13), so it can never corrupt a mutation. An optional `format`
|
|
3609
|
+
* override is the manager-options level of the `AgentContext` build cascade (consulted by
|
|
3610
|
+
* `description` / `format`, beating the provider default + built-in; a per-item
|
|
3611
|
+
* `InstructionInput.format` still beats it).
|
|
3612
|
+
*
|
|
3613
|
+
* @param options - Optional `on` hooks + a `format` override (see {@link InstructionManagerOptions})
|
|
3614
|
+
* @returns An empty {@link InstructionManagerInterface}
|
|
3615
|
+
*
|
|
3616
|
+
* @example
|
|
3617
|
+
* ```ts
|
|
3618
|
+
* import { createInstructionManager } from '@src/core'
|
|
3619
|
+
*
|
|
3620
|
+
* const instructions = createInstructionManager()
|
|
3621
|
+
* instructions.add({ name: 'tone', content: 'Be concise.', priority: 5 })
|
|
3622
|
+
* ```
|
|
3623
|
+
*/
|
|
3624
|
+
function createInstructionManager(options) {
|
|
3625
|
+
return new InstructionManager(options);
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Create a named scope — an immutable {@link ScopeInterface} from its `name` and the four
|
|
3629
|
+
* optional per-category allow-lists, the `id` minted at construction.
|
|
3630
|
+
*
|
|
3631
|
+
* @remarks
|
|
3632
|
+
* Each list is THREE-WAY: `undefined` ⇒ NO constraint on that category (all pass), `[]` ⇒
|
|
3633
|
+
* NONE pass, a non-empty list ⇒ only the listed keys pass. `narrow(config)` composes a
|
|
3634
|
+
* tighter child by set-INTERSECTION (an `undefined` side imposing no constraint). Stored
|
|
3635
|
+
* immutable — never mutated after creation (`narrow` returns a new scope).
|
|
3636
|
+
*
|
|
3637
|
+
* @param input - `name` (required) and the optional `instructions` / `tools` / `messages` /
|
|
3638
|
+
* `files` allow-lists (see {@link ScopeInput})
|
|
3639
|
+
* @returns A working {@link ScopeInterface}
|
|
3640
|
+
*
|
|
3641
|
+
* @example
|
|
3642
|
+
* ```ts
|
|
3643
|
+
* import { createScope } from '@src/core'
|
|
3644
|
+
*
|
|
3645
|
+
* const reader = createScope({ name: 'reader', tools: ['search', 'read'] })
|
|
3646
|
+
* reader.narrow({ tools: ['read', 'write'] }).tools // ['read'] — intersection tightens
|
|
3647
|
+
* ```
|
|
3648
|
+
*/
|
|
3649
|
+
function createScope(input) {
|
|
3650
|
+
return new Scope(input);
|
|
3651
|
+
}
|
|
3652
|
+
/**
|
|
3653
|
+
* Create a scope registry — a {@link ScopeManagerInterface} holding immutable scopes keyed
|
|
3654
|
+
* by their minted `id`, in insertion order.
|
|
3655
|
+
*
|
|
3656
|
+
* @remarks
|
|
3657
|
+
* Starts empty; `create` mints each scope's `id` and stores it (keyed by `id`, so it
|
|
3658
|
+
* always adds — two scopes may share a `name`); `scopes()` lists them in insertion order;
|
|
3659
|
+
* `remove` (one or a batch, §9.2) reports whether any was removed; `clear` empties it.
|
|
3660
|
+
* Carries an observable `emitter` ({@link import('./types.js').ScopeManagerEventMap}) wired
|
|
3661
|
+
* via the reserved `on` option (§8); the emitter isolates a listener throw and routes it to
|
|
3662
|
+
* its `error` handler (the `error` option, §13), so it can never corrupt a mutation.
|
|
3663
|
+
*
|
|
3664
|
+
* @param options - Optional `on` hooks (see {@link ScopeManagerOptions})
|
|
3665
|
+
* @returns An empty {@link ScopeManagerInterface}
|
|
3666
|
+
*
|
|
3667
|
+
* @example
|
|
3668
|
+
* ```ts
|
|
3669
|
+
* import { createScopeManager } from '@src/core'
|
|
3670
|
+
*
|
|
3671
|
+
* const scopes = createScopeManager()
|
|
3672
|
+
* const reader = scopes.create({ name: 'reader', tools: ['search'] })
|
|
3673
|
+
* ```
|
|
3674
|
+
*/
|
|
3675
|
+
function createScopeManager(options) {
|
|
3676
|
+
return new ScopeManager(options?.on, options?.error);
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* Create a richer turn context — an {@link AgentContextInterface} assembling a provider
|
|
3680
|
+
* request from the optional system prompt, the instruction registry, the workspace registry,
|
|
3681
|
+
* the conversation store, the tool registry, and the active scope.
|
|
3682
|
+
*
|
|
3683
|
+
* @remarks
|
|
3684
|
+
* `system` is the optional system prompt; `tools` / `instructions` / `workspaces` are pre-built
|
|
3685
|
+
* managers to reuse (empty ones are created when omitted, so `context.workspaces` is ALWAYS
|
|
3686
|
+
* present); `scope` is the initial active filter (`undefined` ⇒ no filtering, mutable afterwards
|
|
3687
|
+
* via `context.scope`). The `messages` store is always fresh. `build()` folds the scoped
|
|
3688
|
+
* instructions — PLUS the ACTIVE workspace's scope-filtered text files (fenced) — into ONE leading
|
|
3689
|
+
* `system` message and appends the scoped conversation (attaching the active workspace's
|
|
3690
|
+
* scope-filtered image files' `data` to the last user message), built fresh each call; the active
|
|
3691
|
+
* workspace is the SOLE document/image context. Tools are advertised STRUCTURALLY (via
|
|
3692
|
+
* `tools.definitions()`, scope-filtered by the loop), never serialized into the prompt.
|
|
3693
|
+
*
|
|
3694
|
+
* @param options - Optional `system` / `tools` / `instructions` / `workspaces` / `scope`
|
|
3695
|
+
* (see {@link AgentContextOptions})
|
|
3696
|
+
* @returns A working {@link AgentContextInterface}
|
|
3697
|
+
*
|
|
3698
|
+
* @example
|
|
3699
|
+
* ```ts
|
|
3700
|
+
* import { createAgentContext } from '@src/core'
|
|
3701
|
+
*
|
|
3702
|
+
* const context = createAgentContext({ system: 'You are concise.' })
|
|
3703
|
+
* context.instructions.add({ name: 'tone', content: 'Be terse.' })
|
|
3704
|
+
* context.messages.add({ role: 'user', content: 'Hi' })
|
|
3705
|
+
* context.build() // [{ role: 'system', content: 'You are concise.\n\n## Instructions\n\nBe terse.' }, { role: 'user', content: 'Hi' }]
|
|
3706
|
+
* ```
|
|
3707
|
+
*/
|
|
3708
|
+
function createAgentContext(options) {
|
|
3709
|
+
return new AgentContext(options);
|
|
3710
|
+
}
|
|
3711
|
+
/**
|
|
3712
|
+
* Create an agent loop — an {@link AgentInterface} composing a
|
|
3713
|
+
* {@link ProviderInterface}, its {@link AgentContextInterface}, and a tool registry
|
|
3714
|
+
* into a bounded context → provider → tools → repeat turn, exposed as a one-shot
|
|
3715
|
+
* `generate` and a live `stream`.
|
|
3716
|
+
*
|
|
3717
|
+
* @remarks
|
|
3718
|
+
* One private loop drives the turn; `generate` DRAINS the same stream `stream`
|
|
3719
|
+
* exposes, so they can never diverge. Each turn is bounded by one cancel folded from
|
|
3720
|
+
* `signal` + `timeout` + `budget` (via `AbortSignal.any`) — any trip (or `abort()`)
|
|
3721
|
+
* commits a PARTIAL result (the stream's `result` RESOLVES on a cancel, rejects only
|
|
3722
|
+
* on a genuine provider / tool error). The `scheduler` paces between turns; tool
|
|
3723
|
+
* iteration is capped at `limit` (default `DEFAULT_AGENT_LIMIT`). Tools are advertised
|
|
3724
|
+
* structurally via `context.tools.definitions()`. Two observation surfaces: the
|
|
3725
|
+
* {@link AgentChunk} stream (pull — per-token content) and a typed `emitter` (push —
|
|
3726
|
+
* lifecycle + `usage` / `tool` / `deny` for fire-and-forget observers).
|
|
3727
|
+
*
|
|
3728
|
+
* @param provider - The {@link ProviderInterface} the loop drives each turn
|
|
3729
|
+
* @param options - Optional `system` / `tools` / `limit` / `timeout` / `budget` /
|
|
3730
|
+
* `scheduler` / `signal` (see {@link AgentOptions})
|
|
3731
|
+
* @returns A working {@link AgentInterface}
|
|
3732
|
+
*
|
|
3733
|
+
* @example
|
|
3734
|
+
* ```ts
|
|
3735
|
+
* import type { ProviderInterface } from '@src/core'
|
|
3736
|
+
* import { createAgent, createTokenBudget } from '@src/core'
|
|
3737
|
+
*
|
|
3738
|
+
* declare const provider: ProviderInterface // any concrete implementation supplied by the host app
|
|
3739
|
+
* const agent = createAgent(provider, {
|
|
3740
|
+
* system: 'You are concise.',
|
|
3741
|
+
* budget: createTokenBudget({ max: 50_000, scope: 'total' }),
|
|
3742
|
+
* })
|
|
3743
|
+
* agent.context.messages.add({ role: 'user', content: 'Say hi.' })
|
|
3744
|
+
*
|
|
3745
|
+
* const stream = agent.stream()
|
|
3746
|
+
* for await (const chunk of stream.events) {
|
|
3747
|
+
* if (chunk.type === 'token') process.stdout.write(chunk.content)
|
|
3748
|
+
* }
|
|
3749
|
+
* const result = await stream.result // { content, usage?, partial }
|
|
3750
|
+
* ```
|
|
3751
|
+
*/
|
|
3752
|
+
function createAgent(provider, options) {
|
|
3753
|
+
return new Agent(provider, options);
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* Create a stream-stateful `<think>` separator — a {@link ThinkSplitterInterface} that
|
|
3757
|
+
* splits a thinking model's in-content `<think>…</think>` reasoning spans away from
|
|
3758
|
+
* the answer, delta by delta, so a provider yields ONLY clean content and surfaces the
|
|
3759
|
+
* accumulated reasoning as {@link import('./types.js').ProviderResult.thinking}.
|
|
3760
|
+
*
|
|
3761
|
+
* @remarks
|
|
3762
|
+
* Feed each raw wire delta through `split(delta)` (it returns the clean content to
|
|
3763
|
+
* surface — possibly `''` mid-think) and settle the stream end with `flush()` (a held
|
|
3764
|
+
* partial open tag that never completed returns as final content; an UNCLOSED think
|
|
3765
|
+
* span lands on `thinking`). Tags split ACROSS deltas are held back until
|
|
3766
|
+
* disambiguated, multiple spans accumulate in order, and a nested-looking `<think>`
|
|
3767
|
+
* inside an open span is just thinking text. One splitter serves ONE stream — create
|
|
3768
|
+
* a fresh one per provider call.
|
|
3769
|
+
*
|
|
3770
|
+
* @returns A fresh {@link ThinkSplitterInterface} (state empty, outside any span)
|
|
3771
|
+
*
|
|
3772
|
+
* @example
|
|
3773
|
+
* ```ts
|
|
3774
|
+
* import { createThinkSplitter } from '@src/core'
|
|
3775
|
+
*
|
|
3776
|
+
* const splitter = createThinkSplitter()
|
|
3777
|
+
* const clean = splitter.split('<think>plan the answer</think>Here it is.')
|
|
3778
|
+
* clean // 'Here it is.'
|
|
3779
|
+
* splitter.thinking // 'plan the answer'
|
|
3780
|
+
* ```
|
|
3781
|
+
*/
|
|
3782
|
+
function createThinkSplitter() {
|
|
3783
|
+
return new ThinkSplitter();
|
|
3784
|
+
}
|
|
3785
|
+
/**
|
|
3786
|
+
* Create a policy gate — an {@link AuthorityInterface} the agent loop consults before
|
|
3787
|
+
* each tool call runs, evaluating the ordered rules first-match-wins and falling back
|
|
3788
|
+
* to the configured default when none match.
|
|
3789
|
+
*
|
|
3790
|
+
* @remarks
|
|
3791
|
+
* `rules` are evaluated in order — the FIRST whose `match` is true decides (a matched
|
|
3792
|
+
* rule ALLOWS unless its `allowed` is explicitly `false`). When no rule matches, the
|
|
3793
|
+
* `fallback` decides; it defaults to `{ zone: DEFAULT_AUTHORITY_ZONE, allowed: true }`
|
|
3794
|
+
* (allow-unmatched — a rules list of denials acts as a DENYLIST). Pass an
|
|
3795
|
+
* `allowed: false` `fallback` to flip the gate to deny-by-default (an ALLOWLIST). Wire
|
|
3796
|
+
* the result into `createAgent` via `AgentOptions.authority`: a denied call is fed back
|
|
3797
|
+
* to the model as a denial `ToolResult` (not executed, no budget cost), so the model
|
|
3798
|
+
* can react. Synchronous now — the async human-approval handshake is deferred.
|
|
3799
|
+
*
|
|
3800
|
+
* @param options - Optional `rules` (ordered) and `fallback` (see {@link AuthorityOptions})
|
|
3801
|
+
* @returns A working {@link AuthorityInterface}
|
|
3802
|
+
*
|
|
3803
|
+
* @example
|
|
3804
|
+
* ```ts
|
|
3805
|
+
* import { createAgent, createAuthority } from '@src/core'
|
|
3806
|
+
*
|
|
3807
|
+
* // Deny the `delete` tool, allow everything else.
|
|
3808
|
+
* const authority = createAuthority({
|
|
3809
|
+
* rules: [{ match: (c) => c.call.name === 'delete', zone: 'restricted', allowed: false }],
|
|
3810
|
+
* })
|
|
3811
|
+
* const agent = createAgent(provider, { tools, authority })
|
|
3812
|
+
* ```
|
|
3813
|
+
*/
|
|
3814
|
+
function createAuthority(options) {
|
|
3815
|
+
return new Authority(options);
|
|
3816
|
+
}
|
|
3817
|
+
/**
|
|
3818
|
+
* Create an agent registry — an {@link AgentRegistryInterface} holding the named pools of
|
|
3819
|
+
* live, non-serializable pieces (providers, tools, authorities, schedulers) that a
|
|
3820
|
+
* serializable {@link AgentJobInput}'s names resolve against, and `build`ing a seeded,
|
|
3821
|
+
* signal-wired {@link AgentInterface} from a job.
|
|
3822
|
+
*
|
|
3823
|
+
* @remarks
|
|
3824
|
+
* `providers` is required; `tools` / `authorities` / `schedulers` are optional pools.
|
|
3825
|
+
* The accessors (`provider` / `tool` / `authority` / `scheduler`) THROW `unknown <category>:
|
|
3826
|
+
* <name>` on an unregistered name (§9.1 + §12) — a misconfigured or crash-restored job
|
|
3827
|
+
* fails loudly rather than running with a missing dependency. `build(input, signal)`
|
|
3828
|
+
* resolves the names, rebuilds the token budget from its ceiling, seeds the agent's
|
|
3829
|
+
* context with the job's messages, and threads `signal` so a queue / runner abort
|
|
3830
|
+
* propagates. This is the bridge that makes durable, serializable agent jobs runnable.
|
|
3831
|
+
*
|
|
3832
|
+
* @param options - The named pools (see {@link AgentRegistryOptions})
|
|
3833
|
+
* @returns A working {@link AgentRegistryInterface}
|
|
3834
|
+
*
|
|
3835
|
+
* @example
|
|
3836
|
+
* ```ts
|
|
3837
|
+
* import type { ProviderInterface } from '@src/core'
|
|
3838
|
+
* import { createAgentRegistry, createTool } from '@src/core'
|
|
3839
|
+
*
|
|
3840
|
+
* declare const provider: ProviderInterface // any concrete implementation supplied by the host app
|
|
3841
|
+
* const registry = createAgentRegistry({
|
|
3842
|
+
* providers: { main: provider },
|
|
3843
|
+
* tools: { add: createTool({ name: 'add', execute: (a) => Number(a.x) + Number(a.y) }) },
|
|
3844
|
+
* })
|
|
3845
|
+
* const agent = registry.build({ provider: 'main', messages: [{ role: 'user', content: 'Hi.' }] })
|
|
3846
|
+
* ```
|
|
3847
|
+
*/
|
|
3848
|
+
function createAgentRegistry(options) {
|
|
3849
|
+
return new AgentRegistry(options);
|
|
3850
|
+
}
|
|
3851
|
+
/**
|
|
3852
|
+
* Create a durable, bounded-concurrency agent-job queue — a {@link QueueInterface} over
|
|
3853
|
+
* serializable {@link AgentJobInput}s that COMPOSES `createQueue`: each job is rehydrated
|
|
3854
|
+
* through the `registry` into a live {@link AgentInterface}, run to its {@link AgentResult},
|
|
3855
|
+
* and subjected to the partial-as-configurable-failure policy.
|
|
3856
|
+
*
|
|
3857
|
+
* @remarks
|
|
3858
|
+
* - **Composes the substrate (no new engine).** The handler is the only new logic;
|
|
3859
|
+
* bounded `concurrency`, `retries`, the per-attempt `timeout`, and durable persistence
|
|
3860
|
+
* via `store` (+ `restore()` after a crash) are all the backing Queue's. `enqueue`
|
|
3861
|
+
* returns a per-job promise.
|
|
3862
|
+
* - **Durable + serializable.** Because `AgentJobInput` is JSON-serializable, a `store`
|
|
3863
|
+
* (e.g. `createMemoryQueueStore` / `createDatabaseQueueStore`) persists outstanding
|
|
3864
|
+
* jobs; `restore()` re-enqueues them after a restart and the `registry` rehydrates the
|
|
3865
|
+
* live pieces from the names — so a job survives a crash.
|
|
3866
|
+
* - **Partial policy.** A partial result THROWS an
|
|
3867
|
+
* {@link import('./errors.js').AgentJobError} by default, so a job cancelled by its
|
|
3868
|
+
* attempt deadline / a queue abort RETRIES while attempts remain; `allowPartial: true`
|
|
3869
|
+
* resolves the partial as success instead.
|
|
3870
|
+
* - **Cancellation threads through.** The handler passes `execution.signal` into
|
|
3871
|
+
* `registry.build`, so a queue `abort()` or a per-attempt timeout cancels the in-flight
|
|
3872
|
+
* agent (which commits a partial → throws → retries / fails per policy).
|
|
3873
|
+
*
|
|
3874
|
+
* @param options - The `registry`, the `allowPartial` policy, and the substrate knobs
|
|
3875
|
+
* (`concurrency` / `retries` / `timeout` / `store`) (see {@link AgentQueueOptions})
|
|
3876
|
+
* @returns A {@link QueueInterface} of {@link AgentJobInput} → {@link AgentResult}
|
|
3877
|
+
*
|
|
3878
|
+
* @example
|
|
3879
|
+
* ```ts
|
|
3880
|
+
* import { createAgentQueue, createAgentRegistry, createMemoryQueueStore } from '@src/core'
|
|
3881
|
+
*
|
|
3882
|
+
* const registry = createAgentRegistry({ providers: { main: provider } })
|
|
3883
|
+
* const store = createMemoryQueueStore(agentJobShape) // survives a restart via restore()
|
|
3884
|
+
* const queue = createAgentQueue({ registry, concurrency: 2, retries: 1, store })
|
|
3885
|
+
* const result = await queue.enqueue({ provider: 'main', messages: [{ role: 'user', content: 'ok?' }] })
|
|
3886
|
+
* ```
|
|
3887
|
+
*/
|
|
3888
|
+
function createAgentQueue(options) {
|
|
3889
|
+
const { registry, allowPartial = false, concurrency, retries, timeout, store } = options;
|
|
3890
|
+
return (0, _orkestrel_queue.createQueue)({
|
|
3891
|
+
concurrency,
|
|
3892
|
+
retries,
|
|
3893
|
+
timeout,
|
|
3894
|
+
store,
|
|
3895
|
+
handler: (input, execution) => settleAgentJob(registry.build(input, execution.signal), allowPartial)
|
|
3896
|
+
});
|
|
3897
|
+
}
|
|
3898
|
+
/**
|
|
3899
|
+
* Create an agent-job runner — a {@link RunnerInterface} over serializable
|
|
3900
|
+
* {@link AgentJobInput}s that COMPOSES `createRunner` (one-shot, ordered, fail-fast), each
|
|
3901
|
+
* unit rehydrated through the `registry` and subjected to the partial policy. The runner
|
|
3902
|
+
* enables **sub-agent fan-out**: a parent job's handler can `controller.spawn(childJob)`.
|
|
3903
|
+
*
|
|
3904
|
+
* @remarks
|
|
3905
|
+
* - **Composes the substrate (no new engine).** Bounded `concurrency`, `retries`, the
|
|
3906
|
+
* per-attempt `timeout`, ordered results, and fail-fast are all the backing Runner's;
|
|
3907
|
+
* the handler adds only rehydration + the partial policy.
|
|
3908
|
+
* - **Sub-agent fan-out.** Each unit's handler receives a `ControllerInterface` whose
|
|
3909
|
+
* `spawn(childJob)` launches a CHILD agent job through the same bounded queue (the
|
|
3910
|
+
* child's result joins the run after the declared units, in spawn order). On a bounded
|
|
3911
|
+
* runner, FAN OUT and return — do NOT inline-`await` a spawn from within the handler (a
|
|
3912
|
+
* slot-holding handler awaiting its own spawn can deadlock; see `ControllerInterface`).
|
|
3913
|
+
* - **Partial policy + cancellation.** Same as `createAgentQueue`: a partial result
|
|
3914
|
+
* THROWS by default (the run's fail-fast engages), `allowPartial: true` resolves it; the
|
|
3915
|
+
* handler threads `controller.signal` into `registry.build`, so a runner abort / a
|
|
3916
|
+
* per-attempt timeout cancels the agent.
|
|
3917
|
+
*
|
|
3918
|
+
* @param options - The `registry`, the `allowPartial` policy, and the substrate knobs
|
|
3919
|
+
* (`concurrency` / `retries` / `timeout`) (see {@link AgentRunnerOptions})
|
|
3920
|
+
* @returns A {@link RunnerInterface} of {@link AgentJobInput} → {@link AgentResult}
|
|
3921
|
+
*
|
|
3922
|
+
* @example
|
|
3923
|
+
* ```ts
|
|
3924
|
+
* import { createAgentRunner, createAgentRegistry } from '@src/core'
|
|
3925
|
+
*
|
|
3926
|
+
* const registry = createAgentRegistry({ providers: { main: provider } })
|
|
3927
|
+
* const runner = createAgentRunner({ registry, concurrency: 2 })
|
|
3928
|
+
* // Run two jobs; the first fans out a child sub-agent then returns.
|
|
3929
|
+
* const child = { provider: 'main', messages: [{ role: 'user', content: 'child' }] }
|
|
3930
|
+
* const parent = { provider: 'main', messages: [{ role: 'user', content: 'parent' }] }
|
|
3931
|
+
* const results = await runner.execute([parent, child]) // declared first, then any spawns
|
|
3932
|
+
* ```
|
|
3933
|
+
*/
|
|
3934
|
+
function createAgentRunner(options) {
|
|
3935
|
+
const { registry, allowPartial = false, concurrency, retries, timeout } = options;
|
|
3936
|
+
return (0, _orkestrel_workflow.createRunner)({
|
|
3937
|
+
concurrency,
|
|
3938
|
+
retries,
|
|
3939
|
+
timeout,
|
|
3940
|
+
handler: (controller) => {
|
|
3941
|
+
const children = controller.input.children;
|
|
3942
|
+
if (children !== void 0) for (const child of children) controller.spawn(child);
|
|
3943
|
+
return settleAgentJob(registry.build(controller.input, controller.signal), allowPartial);
|
|
3944
|
+
}
|
|
3945
|
+
});
|
|
3946
|
+
}
|
|
3947
|
+
/**
|
|
3948
|
+
* Create a file — an immutable {@link FileInterface} from its `path` + {@link FileContent}
|
|
3949
|
+
* and optional {@link import('./types.js').FileState}, with `size` / `lines` DERIVED from the
|
|
3950
|
+
* content.
|
|
3951
|
+
*
|
|
3952
|
+
* @remarks
|
|
3953
|
+
* Returns a PLAIN `Object.freeze`d record (NOT a class instance) — the `path` IS its identity
|
|
3954
|
+
* (there is no `id`). Only `path` / `content` are required; `state` defaults to `'created'`
|
|
3955
|
+
* when omitted. `size` (via {@link computeSize}) and `lines` (via {@link countLines}) are
|
|
3956
|
+
* computed from the content here (so they never drift from it). Frozen + plain, so it
|
|
3957
|
+
* `structuredClone`s losslessly and is never mutated after creation.
|
|
3958
|
+
*
|
|
3959
|
+
* @param input - `path` / `content` (required) and an optional `state` (see {@link FileInput})
|
|
3960
|
+
* @returns A frozen {@link FileInterface} record
|
|
3961
|
+
*
|
|
3962
|
+
* @example
|
|
3963
|
+
* ```ts
|
|
3964
|
+
* import { createFile, createTextContent } from '@src/core'
|
|
3965
|
+
*
|
|
3966
|
+
* const file = createFile({ path: 'src/main.ts', content: createTextContent('const x = 1', 'typescript') })
|
|
3967
|
+
* file.lines // 1
|
|
3968
|
+
* ```
|
|
3969
|
+
*/
|
|
3970
|
+
function createFile(input) {
|
|
3971
|
+
return Object.freeze({
|
|
3972
|
+
path: input.path,
|
|
3973
|
+
content: input.content,
|
|
3974
|
+
state: input.state ?? "created",
|
|
3975
|
+
size: computeSize(input.content),
|
|
3976
|
+
lines: countLines(input.content)
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
/**
|
|
3980
|
+
* Build the TEXT {@link FileContent} arm — the §4.2.3 split constructor for text (a separate
|
|
3981
|
+
* function per arm, not one constructor dispatching on a discriminator parameter).
|
|
3982
|
+
*
|
|
3983
|
+
* @param text - The literal text body
|
|
3984
|
+
* @param language - The fenced-code language the text renders as (e.g. `'typescript'`)
|
|
3985
|
+
* @returns A `{ text; language }` content arm
|
|
3986
|
+
*
|
|
3987
|
+
* @example
|
|
3988
|
+
* ```ts
|
|
3989
|
+
* import { createTextContent } from '@src/core'
|
|
3990
|
+
*
|
|
3991
|
+
* createTextContent('const x = 1', 'typescript') // { text: 'const x = 1', language: 'typescript' }
|
|
3992
|
+
* ```
|
|
3993
|
+
*/
|
|
3994
|
+
function createTextContent(text, language) {
|
|
3995
|
+
return {
|
|
3996
|
+
text,
|
|
3997
|
+
language
|
|
3998
|
+
};
|
|
3999
|
+
}
|
|
4000
|
+
/**
|
|
4001
|
+
* Build the BINARY {@link FileContent} arm — the §4.2.3 split constructor for binary (a
|
|
4002
|
+
* separate function per arm, not one constructor dispatching on a discriminator parameter).
|
|
4003
|
+
* An image is just a binary with an image {@link BinaryMIME}.
|
|
4004
|
+
*
|
|
4005
|
+
* @param data - The base64-encoded binary payload
|
|
4006
|
+
* @param mime - The {@link BinaryMIME} that labels the payload
|
|
4007
|
+
* @returns A `{ data; mime }` content arm
|
|
4008
|
+
*
|
|
4009
|
+
* @example
|
|
4010
|
+
* ```ts
|
|
4011
|
+
* import { createBinaryContent } from '@src/core'
|
|
4012
|
+
*
|
|
4013
|
+
* createBinaryContent('<base64>', 'image/png') // { data: '<base64>', mime: 'image/png' }
|
|
4014
|
+
* ```
|
|
4015
|
+
*/
|
|
4016
|
+
function createBinaryContent(data, mime) {
|
|
4017
|
+
return {
|
|
4018
|
+
data,
|
|
4019
|
+
mime
|
|
4020
|
+
};
|
|
4021
|
+
}
|
|
4022
|
+
/**
|
|
4023
|
+
* Create a workspace — a mutable, `path`-keyed working set of immutable
|
|
4024
|
+
* {@link FileInterface}s with the in-memory edit surface (read / write / search / replace /
|
|
4025
|
+
* move / remove), observable through its `EmitterInterface` (from `@orkestrel/emitter`).
|
|
4026
|
+
*
|
|
4027
|
+
* @param options - Optional initial {@link import('./types.js').WorkspaceEventMap} listeners (`on`) and the emitter's `error` handler (see {@link WorkspaceOptions})
|
|
4028
|
+
* @returns A working {@link WorkspaceInterface}
|
|
4029
|
+
*
|
|
4030
|
+
* @example
|
|
4031
|
+
* ```ts
|
|
4032
|
+
* import { createWorkspace } from '@src/core'
|
|
4033
|
+
*
|
|
4034
|
+
* const workspace = createWorkspace({ on: { write: (file) => console.log(file.path) } })
|
|
4035
|
+
* workspace.write('src/main.ts', 'const x = 1')
|
|
4036
|
+
* workspace.file('src/main.ts')?.state // 'created'
|
|
4037
|
+
* ```
|
|
4038
|
+
*/
|
|
4039
|
+
function createWorkspace(options) {
|
|
4040
|
+
return new Workspace(options);
|
|
4041
|
+
}
|
|
4042
|
+
/**
|
|
4043
|
+
* Create the in-memory workspace store — a {@link WorkspaceStoreInterface} backed by a
|
|
4044
|
+
* process-lifetime `Map` of {@link import('./types.js').WorkspaceSnapshot}s keyed by workspace id,
|
|
4045
|
+
* the DEFAULT backing for the durable {@link WorkspaceManagerInterface.open} /
|
|
4046
|
+
* {@link WorkspaceManagerInterface.save} seam.
|
|
4047
|
+
*
|
|
4048
|
+
* @remarks
|
|
4049
|
+
* A plain `Map` (the snapshot is already pure JSON, so no encoding is needed for the memory tier),
|
|
4050
|
+
* the structural twin of the analogous `createMemoryWorkflowStore` in `@orkestrel/workflow`.
|
|
4051
|
+
* `get` / `set` / `delete` are async (the same shape a durable backend fits); UNLIKE a session
|
|
4052
|
+
* store there is NO idle-TTL / eviction — a persisted workspace lives until an explicit `delete`.
|
|
4053
|
+
* Its driver-pluggable twin is {@link createDatabaseWorkspaceStore} (the snapshot as one opaque
|
|
4054
|
+
* JSON column over a `databases` table) — for a DURABLE store pass it a JSON / SQLite / IndexedDB
|
|
4055
|
+
* driver, and it swaps in WITHOUT touching the manager or the workspace. Hydration stays a manager
|
|
4056
|
+
* concern: read a snapshot back and rebuild the live workspace through the constructor `seed`.
|
|
4057
|
+
*
|
|
4058
|
+
* @returns A memory-backed {@link WorkspaceStoreInterface}
|
|
4059
|
+
*
|
|
4060
|
+
* @example
|
|
4061
|
+
* ```ts
|
|
4062
|
+
* import { createMemoryWorkspaceStore, createWorkspaceManager } from '@src/core'
|
|
4063
|
+
*
|
|
4064
|
+
* const store = createMemoryWorkspaceStore()
|
|
4065
|
+
* const manager = createWorkspaceManager({ store })
|
|
4066
|
+
* const workspace = manager.add()
|
|
4067
|
+
* workspace.write('notes.txt', 'hello')
|
|
4068
|
+
* await manager.save(workspace.id) // persist the workspace
|
|
4069
|
+
* ```
|
|
4070
|
+
*/
|
|
4071
|
+
function createMemoryWorkspaceStore() {
|
|
4072
|
+
return new MemoryWorkspaceStore();
|
|
4073
|
+
}
|
|
4074
|
+
/**
|
|
4075
|
+
* Create a {@link DatabaseWorkspaceStore} over any {@link DriverInterface} — the durable,
|
|
4076
|
+
* driver-pluggable backing for the workspace persistence seam, the opt-in twin of
|
|
4077
|
+
* {@link createMemoryWorkspaceStore}.
|
|
4078
|
+
*
|
|
4079
|
+
* @remarks
|
|
4080
|
+
* Builds a one-table database (`workspaces`, keyed by `id`) over the supplied driver, the snapshot
|
|
4081
|
+
* held as ONE OPAQUE JSON COLUMN — the column map is `{ id; snapshot }` where `snapshot` is a
|
|
4082
|
+
* `rawShape` (a JSON blob), exactly as
|
|
4083
|
+
* the analogous `createDatabaseWorkflowStore` in `@orkestrel/workflow` stores its snapshot. The
|
|
4084
|
+
* snapshot is already a COMPLETE, self-contained, pure-JSON payload, so storing it whole is lossless
|
|
4085
|
+
* AND keeps the row type FLAT (the column reads back as `unknown`, narrowed on `get` by
|
|
4086
|
+
* {@link import('./helpers.js').isWorkspaceSnapshot}). The `driver` DEFAULTS to
|
|
4087
|
+
* {@link createMemoryDriver}, so the store ALSO works in memory out of the box; pass a server
|
|
4088
|
+
* `createJSONDriver` / `createSQLiteDriver` (or a browser IndexedDB driver) for a persistent one —
|
|
4089
|
+
* the durability is the driver's job, the store engine is shared. It swaps in behind
|
|
4090
|
+
* {@link WorkspaceStoreInterface} WITHOUT touching the manager or the workspace.
|
|
4091
|
+
*
|
|
4092
|
+
* @param driver - The storage backend the snapshots persist to (defaults to {@link createMemoryDriver})
|
|
4093
|
+
* @returns A {@link WorkspaceStoreInterface} over the driver
|
|
4094
|
+
*
|
|
4095
|
+
* @example
|
|
4096
|
+
* ```ts
|
|
4097
|
+
* import { createDatabaseWorkspaceStore, createMemoryDriver, createWorkspaceManager } from '@src/core'
|
|
4098
|
+
*
|
|
4099
|
+
* const store = createDatabaseWorkspaceStore(createMemoryDriver()) // a durable driver swaps in here
|
|
4100
|
+
* const manager = createWorkspaceManager({ store })
|
|
4101
|
+
* const workspace = manager.add()
|
|
4102
|
+
* workspace.write('notes.txt', 'hello')
|
|
4103
|
+
* await manager.save(workspace.id) // persist the workspace (one JSON column)
|
|
4104
|
+
* ```
|
|
4105
|
+
*/
|
|
4106
|
+
function createDatabaseWorkspaceStore(driver = (0, _orkestrel_database.createMemoryDriver)()) {
|
|
4107
|
+
return new DatabaseWorkspaceStore((0, _orkestrel_database.createDatabase)({
|
|
4108
|
+
driver,
|
|
4109
|
+
tables: { workspaces: {
|
|
4110
|
+
id: (0, _orkestrel_contract.stringShape)(),
|
|
4111
|
+
snapshot: (0, _orkestrel_contract.rawShape)({})
|
|
4112
|
+
} }
|
|
4113
|
+
}).table("workspaces"));
|
|
4114
|
+
}
|
|
4115
|
+
/**
|
|
4116
|
+
* Create a workspace registry — a {@link WorkspaceManagerInterface} holding
|
|
4117
|
+
* {@link WorkspaceInterface}s keyed by their `id` (in insertion order) WITH an active pointer:
|
|
4118
|
+
* the §9 store over the workspace layer plus the `active` / `switch` seam the context renders.
|
|
4119
|
+
*
|
|
4120
|
+
* @remarks
|
|
4121
|
+
* Starts empty; `add(input?)` mints a {@link WorkspaceInterface} (its `id` from the input or a
|
|
4122
|
+
* random UUID), flowing the manager's default `on` / `error` in unless the input overrides them,
|
|
4123
|
+
* and stores it (an already-present `id` overwrites — last write wins) — and AUTO-ACTIVATES the
|
|
4124
|
+
* FIRST one (a registry with workspaces always has one `active`); a later `add` leaves `active`
|
|
4125
|
+
* unchanged. `switch(id)` re-points `active` (an unknown `id` returns `undefined`, leaving
|
|
4126
|
+
* `active` unchanged — lenient, never throws); `workspace(id)` / `workspaces()` look up;
|
|
4127
|
+
* `remove` (one or a batch, §9.2) reports whether any was removed AND clears `active` if it was
|
|
4128
|
+
* the removed one; `clear` empties it and clears `active`. Event-free (each workspace owns its
|
|
4129
|
+
* own observable `emitter`).
|
|
4130
|
+
*
|
|
4131
|
+
* With the optional `store` ({@link WorkspaceStoreInterface}, e.g. `createMemoryWorkspaceStore` /
|
|
4132
|
+
* `createDatabaseWorkspaceStore`), `open(id)` HYDRATES a workspace on a registry miss (rebuilding it
|
|
4133
|
+
* through the constructor `seed` from the snapshot's `files`, then activating it) and `save(id)`
|
|
4134
|
+
* PERSISTS a registered workspace's `snapshot()`. Both are LENIENT without a store (open resolves
|
|
4135
|
+
* only registered ids, save is a no-op `false`).
|
|
4136
|
+
*
|
|
4137
|
+
* @param options - Optional default `on` / `error` for created workspaces + the durable `store`
|
|
4138
|
+
* (see {@link WorkspaceManagerOptions})
|
|
4139
|
+
* @returns An empty {@link WorkspaceManagerInterface}
|
|
4140
|
+
*
|
|
4141
|
+
* @example
|
|
4142
|
+
* ```ts
|
|
4143
|
+
* import { createWorkspaceManager } from '@src/core'
|
|
4144
|
+
*
|
|
4145
|
+
* const workspaces = createWorkspaceManager()
|
|
4146
|
+
* const scratch = workspaces.add() // auto-activates — workspaces.active === scratch
|
|
4147
|
+
* scratch.write('notes.txt', 'hello')
|
|
4148
|
+
* ```
|
|
4149
|
+
*/
|
|
4150
|
+
function createWorkspaceManager(options) {
|
|
4151
|
+
return new WorkspaceManager(options);
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Wrap a {@link WorkspaceManagerInterface} as an LLM-callable {@link ToolInterface} — it ADVERTISES
|
|
4155
|
+
* the `operation`-discriminated 13-op union ({@link import('./shapers.js').workspaceToolShape}) as
|
|
4156
|
+
* its `parameters`, and its handler PARSES the model-supplied args against that contract and
|
|
4157
|
+
* DISPATCHES the matched operation against the manager's ACTIVE workspace (the registry ops drive
|
|
4158
|
+
* the manager itself), returning the plain result (throwing a typed {@link WorkspaceError} on
|
|
4159
|
+
* failure).
|
|
4160
|
+
*
|
|
4161
|
+
* @remarks
|
|
4162
|
+
* MANAGER-DRIVEN: every edit / read op (read / list / has / search / replace / write / splice /
|
|
4163
|
+
* prepend / append / move / remove) targets `manager.active`, so the model edits whichever workspace
|
|
4164
|
+
* is active and a host can re-point it ({@link WorkspaceManagerInterface.switch}) between turns —
|
|
4165
|
+
* wire the agent's registry in via `createWorkspaceTool(agent.context.workspaces)`. Two REGISTRY ops
|
|
4166
|
+
* make the model self-sufficient: `workspaces` LISTS the registered workspaces (each
|
|
4167
|
+
* `{ id, files, active }`) so it can discover an id, and `switch` re-points the active workspace by
|
|
4168
|
+
* id (lenient — an unknown id is a no-op reporting `switched: false`, never a throw).
|
|
4169
|
+
*
|
|
4170
|
+
* NO-ACTIVE RULE (the ergonomic seam): a WRITING op (write / splice / prepend / append / move /
|
|
4171
|
+
* remove / replace) run when `manager.active` is `undefined` AUTO-CREATES + activates a default
|
|
4172
|
+
* workspace (`manager.add()`) so the model can just start writing; a pure-READ op (read / list / has
|
|
4173
|
+
* / search) against no active workspace returns the EMPTY result (`undefined` / `[]` / `false`),
|
|
4174
|
+
* never creating one and never throwing.
|
|
4175
|
+
*
|
|
4176
|
+
* A plain {@link ToolManagerInterface}-compatible tool (so `createMCPServer` / `createMCPRoutes`
|
|
4177
|
+
* expose it over MCP for free — nothing MCP is wired here), built as a factory + dispatch closure
|
|
4178
|
+
* exactly like the analogous `createWorkflowTool` in `@orkestrel/workflow` (NOT a class). The
|
|
4179
|
+
* contract is compiled ONCE and its JSON Schema is narrowed to the open
|
|
4180
|
+
* `Readonly<Record<string, unknown>>` a tool advertises via the shared
|
|
4181
|
+
* {@link schemaToParameters} contracts helper, never an assertion
|
|
4182
|
+
* (§14) — a compiled contract schema is always a record, so it passes; the `undefined` fallback only
|
|
4183
|
+
* satisfies the type's optionality.
|
|
4184
|
+
*
|
|
4185
|
+
* The handler conforms to the universal tool-handler contract (AGENTS §14): it `contract.parse`s the
|
|
4186
|
+
* args, THROWS a `TOOL` {@link WorkspaceError} when no operation arm matched (a malformed / unknown
|
|
4187
|
+
* operation), else `switch`es on `op.operation` and RETURNS the plain result — letting a
|
|
4188
|
+
* `WorkspaceError` raised by the live workspace (`MODALITY` / `PATTERN` / `RANGE`) PROPAGATE
|
|
4189
|
+
* unCAUGHT. The {@link import('./tools/ToolManager.js').ToolManager} performs the ONE
|
|
4190
|
+
* canonical wrap (`{ id, name, value }` on a return; `{ id, name, error }` on a throw, ISOLATED so
|
|
4191
|
+
* nothing escapes the run), so the outcome appears EXACTLY ONCE over BOTH the agent loop and MCP (a
|
|
4192
|
+
* throw → MCP `isError: true`). There is no `{ ok, output, duration }` envelope. The range edit is
|
|
4193
|
+
* the FLAT `'splice'` op: its four flat caret integers are reassembled into a {@link import('./types.js').Range}
|
|
4194
|
+
* by {@link rangeOf} and fed to the workspace's ranged `write`.
|
|
4195
|
+
*
|
|
4196
|
+
* @param manager - The workspace registry the tool reads + edits (its ACTIVE workspace; closed over by the handler)
|
|
4197
|
+
* @param options - Optional advertised `name` / `description` overrides (see {@link WorkspaceToolOptions})
|
|
4198
|
+
* @returns A {@link ToolInterface} (named {@link import('./constants.js').WORKSPACE_TOOL_NAME} by
|
|
4199
|
+
* default) whose `parameters` advertise the `operation`-discriminated union schema
|
|
4200
|
+
*
|
|
4201
|
+
* @example
|
|
4202
|
+
* ```ts
|
|
4203
|
+
* import { createWorkspaceManager, createWorkspaceTool, createToolManager } from '@src/core'
|
|
4204
|
+
*
|
|
4205
|
+
* const manager = createWorkspaceManager()
|
|
4206
|
+
* const tool = createWorkspaceTool(manager) // edits the manager's ACTIVE workspace
|
|
4207
|
+
* const tools = createToolManager()
|
|
4208
|
+
* tools.add(tool) // a model can now read + edit the active workspace by calling `workspace`
|
|
4209
|
+
* ```
|
|
4210
|
+
*/
|
|
4211
|
+
function createWorkspaceTool(manager, options) {
|
|
4212
|
+
const contract = (0, _orkestrel_contract.createContract)(workspaceToolShape);
|
|
4213
|
+
const parameters = (0, _orkestrel_contract.schemaToParameters)(contract.schema);
|
|
4214
|
+
return createTool({
|
|
4215
|
+
name: options?.name ?? "workspace",
|
|
4216
|
+
description: options?.description ?? WORKSPACE_TOOL_DESCRIPTION,
|
|
4217
|
+
parameters,
|
|
4218
|
+
execute: (args) => {
|
|
4219
|
+
const op = contract.parse(args);
|
|
4220
|
+
if (op === void 0) throw new WorkspaceError("TOOL", `unknown or malformed operation`, { args });
|
|
4221
|
+
if (op.operation === "workspaces") {
|
|
4222
|
+
const activeId = manager.active?.id;
|
|
4223
|
+
return manager.workspaces().map((workspace) => ({
|
|
4224
|
+
id: workspace.id,
|
|
4225
|
+
files: workspace.count,
|
|
4226
|
+
active: workspace.id === activeId
|
|
4227
|
+
}));
|
|
4228
|
+
}
|
|
4229
|
+
if (op.operation === "switch") {
|
|
4230
|
+
const switched = manager.switch(op.id);
|
|
4231
|
+
return switched === void 0 ? {
|
|
4232
|
+
id: op.id,
|
|
4233
|
+
switched: false
|
|
4234
|
+
} : {
|
|
4235
|
+
id: switched.id,
|
|
4236
|
+
switched: true,
|
|
4237
|
+
files: switched.count
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
const active = manager.active;
|
|
4241
|
+
switch (op.operation) {
|
|
4242
|
+
case "read": return active?.read(op.path);
|
|
4243
|
+
case "list": return (active?.files() ?? []).map((file) => ({
|
|
4244
|
+
path: file.path,
|
|
4245
|
+
state: file.state,
|
|
4246
|
+
size: file.size,
|
|
4247
|
+
lines: file.lines,
|
|
4248
|
+
kind: isText(file.content) ? "text" : "binary"
|
|
4249
|
+
}));
|
|
4250
|
+
case "has": return active?.has(op.path) ?? false;
|
|
4251
|
+
case "search": return active?.search(op.query, {
|
|
4252
|
+
regex: op.regex,
|
|
4253
|
+
exact: op.exact,
|
|
4254
|
+
limit: op.limit
|
|
4255
|
+
}) ?? [];
|
|
4256
|
+
case "replace": return (active ?? manager.add()).replace(op.query, op.replacement, {
|
|
4257
|
+
regex: op.regex,
|
|
4258
|
+
exact: op.exact,
|
|
4259
|
+
limit: op.limit
|
|
4260
|
+
});
|
|
4261
|
+
case "write": {
|
|
4262
|
+
const workspace = active ?? manager.add();
|
|
4263
|
+
workspace.write(op.path, op.content);
|
|
4264
|
+
return {
|
|
4265
|
+
path: op.path,
|
|
4266
|
+
state: workspace.file(op.path)?.state
|
|
4267
|
+
};
|
|
4268
|
+
}
|
|
4269
|
+
case "splice": {
|
|
4270
|
+
const workspace = active ?? manager.add();
|
|
4271
|
+
workspace.write(op.path, op.content, rangeOf(op.fromLine, op.fromColumn, op.toLine, op.toColumn));
|
|
4272
|
+
return {
|
|
4273
|
+
path: op.path,
|
|
4274
|
+
state: workspace.file(op.path)?.state
|
|
4275
|
+
};
|
|
4276
|
+
}
|
|
4277
|
+
case "prepend": {
|
|
4278
|
+
const workspace = active ?? manager.add();
|
|
4279
|
+
workspace.prepend(op.path, op.content);
|
|
4280
|
+
return {
|
|
4281
|
+
path: op.path,
|
|
4282
|
+
state: workspace.file(op.path)?.state
|
|
4283
|
+
};
|
|
4284
|
+
}
|
|
4285
|
+
case "append": {
|
|
4286
|
+
const workspace = active ?? manager.add();
|
|
4287
|
+
workspace.append(op.path, op.content);
|
|
4288
|
+
return {
|
|
4289
|
+
path: op.path,
|
|
4290
|
+
state: workspace.file(op.path)?.state
|
|
4291
|
+
};
|
|
4292
|
+
}
|
|
4293
|
+
case "move": {
|
|
4294
|
+
const workspace = active ?? manager.add();
|
|
4295
|
+
return {
|
|
4296
|
+
from: op.from,
|
|
4297
|
+
to: op.to,
|
|
4298
|
+
moved: workspace.move(op.from, op.to)
|
|
4299
|
+
};
|
|
4300
|
+
}
|
|
4301
|
+
case "remove": {
|
|
4302
|
+
const workspace = active ?? manager.add();
|
|
4303
|
+
return {
|
|
4304
|
+
path: op.path,
|
|
4305
|
+
removed: workspace.remove(op.path)
|
|
4306
|
+
};
|
|
4307
|
+
}
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
4310
|
+
});
|
|
4311
|
+
}
|
|
4312
|
+
//#endregion
|
|
4313
|
+
exports.Agent = Agent;
|
|
4314
|
+
exports.AgentContext = AgentContext;
|
|
4315
|
+
exports.AgentJobError = AgentJobError;
|
|
4316
|
+
exports.AgentRegistry = AgentRegistry;
|
|
4317
|
+
exports.Authority = Authority;
|
|
4318
|
+
exports.CONVERSATION_RECAP_PREFIX = CONVERSATION_RECAP_PREFIX;
|
|
4319
|
+
exports.Channel = Channel;
|
|
4320
|
+
exports.Conversation = Conversation;
|
|
4321
|
+
exports.ConversationError = ConversationError;
|
|
4322
|
+
exports.ConversationManager = ConversationManager;
|
|
4323
|
+
exports.DEFAULT_AGENT_LIMIT = DEFAULT_AGENT_LIMIT;
|
|
4324
|
+
exports.DEFAULT_AUTHORITY_ZONE = DEFAULT_AUTHORITY_ZONE;
|
|
4325
|
+
exports.DEFAULT_CONVERSATION_KEEP = DEFAULT_CONVERSATION_KEEP;
|
|
4326
|
+
exports.DatabaseConversationStore = DatabaseConversationStore;
|
|
4327
|
+
exports.DatabaseWorkspaceStore = DatabaseWorkspaceStore;
|
|
4328
|
+
exports.EXTENSION_TO_LANGUAGE = EXTENSION_TO_LANGUAGE;
|
|
4329
|
+
exports.Instruction = Instruction;
|
|
4330
|
+
exports.InstructionManager = InstructionManager;
|
|
4331
|
+
exports.MemoryConversationStore = MemoryConversationStore;
|
|
4332
|
+
exports.MemoryWorkspaceStore = MemoryWorkspaceStore;
|
|
4333
|
+
exports.ProviderAbortError = ProviderAbortError;
|
|
4334
|
+
exports.Scope = Scope;
|
|
4335
|
+
exports.ScopeManager = ScopeManager;
|
|
4336
|
+
exports.THINK_CLOSE = THINK_CLOSE;
|
|
4337
|
+
exports.THINK_OPEN = THINK_OPEN;
|
|
4338
|
+
exports.ThinkSplitter = ThinkSplitter;
|
|
4339
|
+
exports.Tool = Tool;
|
|
4340
|
+
exports.ToolManager = ToolManager;
|
|
4341
|
+
exports.WORKSPACE_SECTION_HEADER = WORKSPACE_SECTION_HEADER;
|
|
4342
|
+
exports.WORKSPACE_TOOL_DESCRIPTION = WORKSPACE_TOOL_DESCRIPTION;
|
|
4343
|
+
exports.WORKSPACE_TOOL_EXAMPLE = WORKSPACE_TOOL_EXAMPLE;
|
|
4344
|
+
exports.WORKSPACE_TOOL_NAME = WORKSPACE_TOOL_NAME;
|
|
4345
|
+
exports.Workspace = Workspace;
|
|
4346
|
+
exports.WorkspaceError = WorkspaceError;
|
|
4347
|
+
exports.WorkspaceManager = WorkspaceManager;
|
|
4348
|
+
exports.buildToolResult = buildToolResult;
|
|
4349
|
+
exports.clampPosition = clampPosition;
|
|
4350
|
+
exports.clampRange = clampRange;
|
|
4351
|
+
exports.computeSize = computeSize;
|
|
4352
|
+
exports.countLines = countLines;
|
|
4353
|
+
exports.createAgent = createAgent;
|
|
4354
|
+
exports.createAgentContext = createAgentContext;
|
|
4355
|
+
exports.createAgentQueue = createAgentQueue;
|
|
4356
|
+
exports.createAgentRegistry = createAgentRegistry;
|
|
4357
|
+
exports.createAgentRunner = createAgentRunner;
|
|
4358
|
+
exports.createAuthority = createAuthority;
|
|
4359
|
+
exports.createBinaryContent = createBinaryContent;
|
|
4360
|
+
exports.createConversation = createConversation;
|
|
4361
|
+
exports.createConversationManager = createConversationManager;
|
|
4362
|
+
exports.createDatabaseConversationStore = createDatabaseConversationStore;
|
|
4363
|
+
exports.createDatabaseWorkspaceStore = createDatabaseWorkspaceStore;
|
|
4364
|
+
exports.createFile = createFile;
|
|
4365
|
+
exports.createInstruction = createInstruction;
|
|
4366
|
+
exports.createInstructionManager = createInstructionManager;
|
|
4367
|
+
exports.createMemoryConversationStore = createMemoryConversationStore;
|
|
4368
|
+
exports.createMemoryWorkspaceStore = createMemoryWorkspaceStore;
|
|
4369
|
+
exports.createScope = createScope;
|
|
4370
|
+
exports.createScopeManager = createScopeManager;
|
|
4371
|
+
exports.createTextContent = createTextContent;
|
|
4372
|
+
exports.createThinkSplitter = createThinkSplitter;
|
|
4373
|
+
exports.createTool = createTool;
|
|
4374
|
+
exports.createToolManager = createToolManager;
|
|
4375
|
+
exports.createWorkspace = createWorkspace;
|
|
4376
|
+
exports.createWorkspaceManager = createWorkspaceManager;
|
|
4377
|
+
exports.createWorkspaceTool = createWorkspaceTool;
|
|
4378
|
+
exports.decodedSize = decodedSize;
|
|
4379
|
+
exports.escapeRegExp = escapeRegExp;
|
|
4380
|
+
exports.estimateMessages = estimateMessages;
|
|
4381
|
+
exports.estimateTokens = estimateTokens;
|
|
4382
|
+
exports.fencedFile = fencedFile;
|
|
4383
|
+
exports.filterAllowList = filterAllowList;
|
|
4384
|
+
exports.inferLanguage = inferLanguage;
|
|
4385
|
+
exports.isAgentJobError = isAgentJobError;
|
|
4386
|
+
exports.isBinary = isBinary;
|
|
4387
|
+
exports.isConversationError = isConversationError;
|
|
4388
|
+
exports.isConversationSnapshot = isConversationSnapshot;
|
|
4389
|
+
exports.isFile = isFile;
|
|
4390
|
+
exports.isImage = isImage;
|
|
4391
|
+
exports.isMessage = isMessage;
|
|
4392
|
+
exports.isProviderAbortError = isProviderAbortError;
|
|
4393
|
+
exports.isSection = isSection;
|
|
4394
|
+
exports.isText = isText;
|
|
4395
|
+
exports.isToolCall = isToolCall;
|
|
4396
|
+
exports.isValidRange = isValidRange;
|
|
4397
|
+
exports.isWorkspaceError = isWorkspaceError;
|
|
4398
|
+
exports.isWorkspaceSnapshot = isWorkspaceSnapshot;
|
|
4399
|
+
exports.offsetAt = offsetAt;
|
|
4400
|
+
exports.rangeOf = rangeOf;
|
|
4401
|
+
exports.settleAgentJob = settleAgentJob;
|
|
4402
|
+
exports.sliceRange = sliceRange;
|
|
4403
|
+
exports.spliceRange = spliceRange;
|
|
4404
|
+
exports.workspaceToolShape = workspaceToolShape;
|
|
4405
|
+
|
|
4406
|
+
//# sourceMappingURL=index.cjs.map
|