@getfamiliar/shared 0.1.4 → 0.1.7

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.
@@ -108,7 +108,7 @@ export declare class EventBus {
108
108
  listSince(since: Date): Promise<EventRow[]>;
109
109
  /**
110
110
  * Fetch the most recently created N event rows, newest first. Used by
111
- * `./cli.sh events list` to surface recent bus activity for operators
111
+ * `familiar events list` to surface recent bus activity for operators
112
112
  * looking for an event id to inspect with `events report <id>`.
113
113
  *
114
114
  * @param limit Maximum number of rows to return. Must be a positive integer.
@@ -118,7 +118,7 @@ export declare class EventBus {
118
118
  /**
119
119
  * Fetch the most recently created N event rows whose topic, start_handler,
120
120
  * prompt, or payload (cast to text) case-insensitively contains `needle`.
121
- * Used by `./cli.sh events list <search>` so the operator can grep the
121
+ * Used by `familiar events list <search>` so the operator can grep the
122
122
  * recent bus without piping through `grep`.
123
123
  *
124
124
  * Substring match via `ILIKE '%needle%'`; the needle is escaped for the
package/build/EventBus.js CHANGED
@@ -229,7 +229,7 @@ export class EventBus {
229
229
  }
230
230
  /**
231
231
  * Fetch the most recently created N event rows, newest first. Used by
232
- * `./cli.sh events list` to surface recent bus activity for operators
232
+ * `familiar events list` to surface recent bus activity for operators
233
233
  * looking for an event id to inspect with `events report <id>`.
234
234
  *
235
235
  * @param limit Maximum number of rows to return. Must be a positive integer.
@@ -244,7 +244,7 @@ export class EventBus {
244
244
  /**
245
245
  * Fetch the most recently created N event rows whose topic, start_handler,
246
246
  * prompt, or payload (cast to text) case-insensitively contains `needle`.
247
- * Used by `./cli.sh events list <search>` so the operator can grep the
247
+ * Used by `familiar events list <search>` so the operator can grep the
248
248
  * recent bus without piping through `grep`.
249
249
  *
250
250
  * Substring match via `ILIKE '%needle%'`; the needle is escaped for the
package/build/Mail.d.ts CHANGED
@@ -20,6 +20,8 @@
20
20
  * (e.g. ms365 → `inbox`, `archive`, `deleteditems`; gmail eventually →
21
21
  * `INBOX`, `[Gmail]/All Mail`, `[Gmail]/Trash`).
22
22
  */
23
+ import type { NewEvent } from "./Event.js";
24
+ import type { EmitHandle } from "./Plugin.js";
23
25
  export type MailFolder = "inbox" | "archive" | "trash" | "sent";
24
26
  /**
25
27
  * One downloaded attachment. The core tool stages these into the
@@ -211,8 +213,8 @@ export interface MailProvider {
211
213
  /**
212
214
  * Capabilities the host exposes to plugins for mail data. Reached via
213
215
  * `ctx.mail` on a plugin's `HostContext`. Tiny by design: there is no
214
- * mail cache in core, so the only thing plugins do here is register
215
- * their provider during `start()`.
216
+ * mail cache in core, so plugins only register their provider during
217
+ * `start()` and hand new-mail events to the host for emission.
216
218
  */
217
219
  export interface MailApi {
218
220
  /**
@@ -221,6 +223,20 @@ export interface MailApi {
221
223
  * wiring bug, not a feature.
222
224
  */
223
225
  registerProvider(provider: MailProvider): void;
226
+ /**
227
+ * Emit a new-mail bus event. Mail pollers must use this instead of
228
+ * `ctx.events.emit` so the host can apply cross-provider policy:
229
+ * on dev instances the event is dropped unless
230
+ * `mail.emitEventsInDev` is set, so a fresh dev database doesn't
231
+ * replay the whole inbox. Otherwise behaves exactly like
232
+ * `ctx.events.emit` (idempotency, `files` staging).
233
+ *
234
+ * @param event - Event to emit; `topic` must be `mail` or start with `mail:`.
235
+ * @returns The emit handle, or `null` when the host suppressed the event.
236
+ * @throws If `topic` is not a mail topic, or whatever `ctx.events.emit`
237
+ * throws (e.g. `DuplicateIdempotencyKeyError`).
238
+ */
239
+ emitMailEvent(event: NewEvent): Promise<EmitHandle | null>;
224
240
  }
225
241
  /**
226
242
  * Build a self-routing mail id from the three segments. Format:
package/build/Plugin.d.ts CHANGED
@@ -11,6 +11,7 @@ import type { MailApi } from "./Mail.js";
11
11
  import type { MailStyleTemplate } from "./MailStyleTemplate.js";
12
12
  import type { ModelMetaData, ModelProviderDescriptor } from "./ModelMetaData.js";
13
13
  import type { StepResultRow } from "./StepResult.js";
14
+ import type { StorageApi } from "./Storage.js";
14
15
  import type { ToolLevel } from "./ToolLevel.js";
15
16
  import type { ToolRunContext } from "./ToolRunner.js";
16
17
  import type { WorkspaceWatcherApi } from "./WorkspaceFile.js";
@@ -266,7 +267,7 @@ export interface HostContext {
266
267
  */
267
268
  readonly tmpDir: string;
268
269
  /**
269
- * Cheap synchronous probe for whether the host daemon (`./cli.sh
270
+ * Cheap synchronous probe for whether the host daemon (`familiar
270
271
  * start`) is currently running on this machine. Inspects the
271
272
  * daemon's pidfile under `<tmpDir>/.daemon.pid` and confirms the
272
273
  * recorded pid is still alive — no network calls, no MCP traffic.
@@ -349,10 +350,21 @@ export interface HostContext {
349
350
  *
350
351
  * Unlike `calendar`, there is no read-side surface here — the
351
352
  * core does not cache mail bodies or metadata. Pollers emit
352
- * `mail:<plugin>` events with a prefixed `mail_id` in the payload;
353
- * the agent reaches the body via `mail_fetch_body` on demand.
353
+ * `mail:<plugin>` events with a prefixed `mail_id` in the payload
354
+ * through `ctx.mail.emitMailEvent` (not `ctx.events.emit`, so the
355
+ * dev-instance gate applies); the agent reaches the body via
356
+ * `mail_fetch_body` on demand.
354
357
  */
355
358
  readonly mail: MailApi;
359
+ /**
360
+ * Shared cloud-storage layer. Plugins that act as storage providers
361
+ * register a {@link StorageProvider} during **`prepare()`** (not
362
+ * `start()`) via `ctx.storage.registerProvider(provider)`, so the
363
+ * `familiar storage` CLI can reach them without the daemon. The core
364
+ * `storage_*` agent tools route through the host `StorageService`,
365
+ * which maps the user's configured mounts onto providers.
366
+ */
367
+ readonly storage: StorageApi;
356
368
  /**
357
369
  * Read the per-mailbox style template the user (or the
358
370
  * extract-style handler) wrote at `data/mail/templates/<mailbox>/<name>.json`.
@@ -376,7 +388,7 @@ export interface HostContext {
376
388
  * {@link WorkspaceWatcherApi} for the full contract.
377
389
  *
378
390
  * Only available inside the daemon: one-shot CLI invocations
379
- * (`./cli.sh <plugin> …`) do not spin up the watcher, so calling
391
+ * (`familiar <plugin> …`) do not spin up the watcher, so calling
380
392
  * either method from a CLI command throws synchronously. Daemon
381
393
  * plugins are the intended consumer.
382
394
  */
@@ -403,11 +415,11 @@ export interface HostContext {
403
415
  };
404
416
  /**
405
417
  * Aborts when the host daemon is no longer running — i.e. when the
406
- * `./cli.sh start` process backing this context has stopped or
418
+ * `familiar start` process backing this context has stopped or
407
419
  * crashed. Always present; consumers don't need to null-check.
408
420
  *
409
- * Intended use: one-shot CLI commands (`./cli.sh <plugin> ...`,
410
- * `./cli.sh cli-chat`, etc.) race long waits (`emit().settled`,
421
+ * Intended use: one-shot CLI commands (`familiar <plugin> ...`,
422
+ * `familiar cli-chat`, etc.) race long waits (`emit().settled`,
411
423
  * polling loops, interactive prompts) against this signal and exit
412
424
  * cleanly when it fires instead of hanging on a postgres connection
413
425
  * the dying daemon took with it.
@@ -644,20 +656,20 @@ export interface PluginHostManifest {
644
656
  getModelProviders?(ctx: HostContext): readonly ModelProviderDescriptor[];
645
657
  /**
646
658
  * Default command for the plugin's CLI root. When set, invoking
647
- * the plugin id with no subcommand (e.g. `cli.sh cli-chat`) runs
659
+ * the plugin id with no subcommand (e.g. `familiar cli-chat`) runs
648
660
  * this command's `run`. Its `args` are also lifted onto the root.
649
661
  * Useful for plugins whose primary mode is interactive (REPL-like)
650
662
  * — the user shouldn't have to remember a subcommand name.
651
663
  *
652
664
  * Subcommands declared in {@link commands} are still available
653
- * under the same root, so `cli.sh cli-chat send "hi"` keeps
665
+ * under the same root, so `familiar cli-chat send "hi"` keeps
654
666
  * working alongside the bare-root invocation.
655
667
  */
656
668
  main?(ctx: HostContext): AnyCommandDef;
657
669
  /**
658
670
  * Citty commands contributed by this plugin. They are mounted
659
671
  * under `subCommands[<plugin.id>]` in the root CLI by default
660
- * (e.g. `cli.sh cli-chat send "hi"`).
672
+ * (e.g. `familiar cli-chat send "hi"`).
661
673
  */
662
674
  commands?(ctx: HostContext): readonly AnyCommandDef[];
663
675
  }
@@ -673,7 +685,7 @@ export interface PluginHostManifest {
673
685
  export interface PluginManifest {
674
686
  /**
675
687
  * Plugin id, matching `[a-z0-9-]+`. Used to namespace the
676
- * plugin's CLI commands (`cli.sh <id> <subcommand>`). A plugin
688
+ * plugin's CLI commands (`familiar <id> <subcommand>`). A plugin
677
689
  * that contributes {@link PluginHostManifest.tools | tools} must
678
690
  * additionally satisfy {@link IDENT_PATTERN} so the id is usable
679
691
  * as a tool group name; the tools registry enforces that at
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Cloud-storage provider SPI. Lives in `shared/` because the host (which
3
+ * owns the `StorageService`, the `storage_*` agent tools and the
4
+ * `familiar storage` CLI) and the provider plugins (ms365, later gdrive,
5
+ * dropbox, …) trade in the same shapes.
6
+ *
7
+ * Division of labour:
8
+ *
9
+ * - **Providers** speak their API: they address items by their native
10
+ * id (`realId`), stream bytes between the provider and a host-side
11
+ * file, and map API failures onto {@link StorageError} codes. They
12
+ * never see mounts, refs, policy, quotas or the agent.
13
+ * - **The host `StorageService`** owns everything that must behave the
14
+ * same for every provider: mount aliases, ref parsing, path
15
+ * resolution for id-addressed providers, write policy, search
16
+ * fan-out, cross-mount copies and the per-run byte quota.
17
+ *
18
+ * Credentials never leave the provider: the agent only ever sees mount
19
+ * aliases, refs, metadata and files placed in its scratch directory.
20
+ * Hard delete is deliberately absent from the SPI.
21
+ */
22
+ /** Kind of a storage item. `native` = provider document without bytes (Google Docs, …). */
23
+ export type StorageItemKind = "file" | "folder" | "native";
24
+ /** Hash algorithms a provider may report for file content. */
25
+ export type StorageHashAlgo = "sha1" | "sha256" | "md5" | "quickXor" | "dropbox";
26
+ /** Provider-reported content hash. */
27
+ export interface StorageContentHash {
28
+ readonly algo: StorageHashAlgo;
29
+ readonly value: string;
30
+ }
31
+ /**
32
+ * Normalized item metadata. `realId` / `parentRealId` are provider-native
33
+ * and only ever reach the agent wrapped inside an id ref (`mount#<id>`).
34
+ */
35
+ export interface StorageItem {
36
+ readonly realId: string;
37
+ /** `null` for the drive root and for parentless items (gdrive "shared with me"). */
38
+ readonly parentRealId: string | null;
39
+ readonly name: string;
40
+ /**
41
+ * Absolute path within the drive (`/folder/file.ext`, root = `/`),
42
+ * or `null` when the provider cannot tell cheaply. The service fills
43
+ * it in where it can.
44
+ */
45
+ readonly path: string | null;
46
+ readonly kind: StorageItemKind;
47
+ readonly mimeType: string | null;
48
+ /** Bytes; `null` for folders and native docs. */
49
+ readonly size: number | null;
50
+ /** UTC ISO-8601. */
51
+ readonly createdUtc: string | null;
52
+ /** UTC ISO-8601. */
53
+ readonly modifiedUtc: string | null;
54
+ readonly modifiedBy: string | null;
55
+ /** Opaque concurrency token (OneDrive eTag, Dropbox rev, gdrive version). */
56
+ readonly revision: string | null;
57
+ readonly contentHash: StorageContentHash | null;
58
+ readonly webUrl: string | null;
59
+ readonly isShared: boolean;
60
+ }
61
+ /** Feature flags the service adapts to. Reported to the agent in summary form. */
62
+ export interface StorageCapabilities {
63
+ /** `path` = the provider resolves `/a/b` natively; `id` = the service walks the tree. */
64
+ readonly addressing: "path" | "id";
65
+ /** `false` when siblings may share a name (Google Drive). */
66
+ readonly uniqueNames: boolean;
67
+ /** Full-text search over file contents. */
68
+ readonly contentSearch: boolean;
69
+ /** Search hits can carry a snippet. */
70
+ readonly searchSnippets: boolean;
71
+ /** Soft delete into a recoverable trash / recycle bin. */
72
+ readonly trash: boolean;
73
+ /** Byte-less provider documents exist and are exported on download. */
74
+ readonly nativeDocs: boolean;
75
+ /** Same-drive copies happen server-side. */
76
+ readonly serverSideCopy: boolean;
77
+ }
78
+ /** Kind of a drive as offered to the user in `familiar storage list`. */
79
+ export type StorageDriveKind = "personal" | "shared" | "team";
80
+ /**
81
+ * One drive of one account: a OneDrive, a SharePoint document library,
82
+ * a Google shared drive, … Opaque to the service beyond these fields.
83
+ */
84
+ export interface StorageDrive {
85
+ /** Account key the drive belongs to (UPN, e-mail, dbid, …). */
86
+ readonly account: string;
87
+ /**
88
+ * Value to put into a mount's `drive:` field. `null` = the account's
89
+ * primary drive (the default when `drive:` is omitted).
90
+ */
91
+ readonly selector: string | null;
92
+ /** Provider-native drive id. Never shown to the agent. */
93
+ readonly id: string;
94
+ /** Human-readable name, e.g. "OneDrive" or "Verwaltung / Dokumente". */
95
+ readonly name: string;
96
+ readonly kind: StorageDriveKind;
97
+ }
98
+ /** Search criteria. The service post-filters kind / mime / modified / subtree, so providers may ignore them. */
99
+ export interface StorageSearchQuery {
100
+ /** Plain keywords; providers must escape their own query syntax. */
101
+ readonly text: string;
102
+ /** Folder to scope the search to (provider-native id). */
103
+ readonly underRealId?: string;
104
+ /** Absolute path of that folder, when known. */
105
+ readonly underPath?: string;
106
+ readonly kind?: StorageItemKind;
107
+ readonly mimePrefix?: string;
108
+ /** Inclusive lower bound, UTC ISO. */
109
+ readonly modifiedFromUtc?: string;
110
+ /** Exclusive upper bound, UTC ISO. */
111
+ readonly modifiedToUtc?: string;
112
+ }
113
+ /** Where a search matched. */
114
+ export type StorageMatchedIn = "name" | "content" | "unknown";
115
+ /** One search hit. */
116
+ export interface StorageSearchHit {
117
+ readonly item: StorageItem;
118
+ readonly matchedIn: StorageMatchedIn;
119
+ readonly snippet?: string;
120
+ }
121
+ /** One page of results with an opaque continuation cursor (`null` on the last page). */
122
+ export interface StoragePage<T> {
123
+ readonly entries: readonly T[];
124
+ readonly nextCursor: string | null;
125
+ }
126
+ /** Paging options. */
127
+ export interface StoragePageOptions {
128
+ readonly cursor?: string;
129
+ readonly limit: number;
130
+ readonly signal?: AbortSignal;
131
+ }
132
+ /** Order for folder listings. Providers that cannot sort leave it to the service. */
133
+ export type StorageListOrder = "name" | "modified_desc";
134
+ /** Conflict behaviour on create. */
135
+ export type StorageConflict = "fail" | "rename" | "replace";
136
+ /** Host-side file to upload. */
137
+ export interface StorageUploadSource {
138
+ /** Absolute host path of a regular file. */
139
+ readonly path: string;
140
+ readonly size: number;
141
+ readonly mimeType?: string;
142
+ }
143
+ /** Result of a download. */
144
+ export interface StorageDownloadResult {
145
+ readonly bytes: number;
146
+ readonly mimeType: string | null;
147
+ /** File name to store under; for native docs it carries the export extension. */
148
+ readonly fileName: string;
149
+ }
150
+ /**
151
+ * The provider contract. One instance per plugin, covering every
152
+ * account the plugin is logged into. Implementations must be **lazy**:
153
+ * providers register during `prepare()` (so the CLI can use them
154
+ * without the daemon), which forbids network access, so the first
155
+ * network call happens inside a method.
156
+ *
157
+ * Every method may throw {@link StorageError}; anything else is treated
158
+ * as `ProviderUnavailable` by the service.
159
+ */
160
+ export interface StorageProvider {
161
+ readonly pluginId: string;
162
+ /** Capabilities for one drive (content search may depend on the tenant / plan). */
163
+ capabilities(drive: StorageDrive): StorageCapabilities;
164
+ /** Accounts this plugin is logged into (lower-cased keys). */
165
+ listAccounts(): Promise<readonly string[]>;
166
+ /** CLI hint telling the user how to (re-)login `account`. */
167
+ loginCommand(account: string): string;
168
+ /** Drives the account can reach: personal, shared and team drives / libraries. */
169
+ listDrives(account: string, options: {
170
+ readonly limit: number;
171
+ readonly search?: string;
172
+ }): Promise<readonly StorageDrive[]>;
173
+ /**
174
+ * Open one drive by its config selector (`null` = primary drive).
175
+ *
176
+ * @throws StorageError `NotFound` when the selector matches nothing.
177
+ */
178
+ openDrive(account: string, selector: string | null): Promise<StorageDrive>;
179
+ /** Provider id of the drive's root folder. */
180
+ rootRealId(drive: StorageDrive): Promise<string>;
181
+ /** Metadata of one item, or `null` if it does not exist. */
182
+ stat(drive: StorageDrive, realId: string): Promise<StorageItem | null>;
183
+ /**
184
+ * Resolve an absolute path natively. Only required when
185
+ * `capabilities.addressing === "path"`. Returns `[]` when not found,
186
+ * several items when names are ambiguous.
187
+ */
188
+ resolvePath?(drive: StorageDrive, path: string): Promise<readonly StorageItem[]>;
189
+ /** One page of the direct children of a folder. */
190
+ list(drive: StorageDrive, folderRealId: string, options: StoragePageOptions & {
191
+ readonly order?: StorageListOrder;
192
+ }): Promise<StoragePage<StorageItem>>;
193
+ /** One page of search hits. */
194
+ search(drive: StorageDrive, query: StorageSearchQuery, options: StoragePageOptions): Promise<StoragePage<StorageSearchHit>>;
195
+ /**
196
+ * Stream the item's bytes into `destPath` (created / truncated). Native
197
+ * docs are exported server-side in a provider-chosen format; a native
198
+ * type without an export mapping throws `Unsupported`.
199
+ */
200
+ download(drive: StorageDrive, realId: string, destPath: string, options?: {
201
+ readonly signal?: AbortSignal;
202
+ }): Promise<StorageDownloadResult>;
203
+ /**
204
+ * Upload a host-side file as `name` into `parentRealId`. The provider
205
+ * picks simple vs. resumable upload.
206
+ *
207
+ * - `conflict: "fail"` — throw `NameConflict` if the name is taken
208
+ * (only meaningful with `uniqueNames`).
209
+ * - `conflict: "rename"` — let the provider pick a free name.
210
+ * - `conflict: "replace"` — overwrite the content of the item
211
+ * `replaceRealId` (required; replacing by name is ambiguous on
212
+ * id-addressed providers). With `ifRevision`, the replace must be
213
+ * guarded by that revision (atomically where the API allows) and
214
+ * throw `RevisionMismatch` otherwise.
215
+ */
216
+ upload(drive: StorageDrive, parentRealId: string, name: string, source: StorageUploadSource, options: {
217
+ readonly conflict: StorageConflict;
218
+ readonly replaceRealId?: string;
219
+ readonly ifRevision?: string;
220
+ readonly signal?: AbortSignal;
221
+ }): Promise<StorageItem>;
222
+ /** Create a folder. `reuse` returns an existing folder of that name. */
223
+ createFolder(drive: StorageDrive, parentRealId: string, name: string, options: {
224
+ readonly conflict: "fail" | "rename" | "reuse";
225
+ }): Promise<StorageItem>;
226
+ /** Move and / or rename within one drive. */
227
+ move(drive: StorageDrive, realId: string, target: {
228
+ readonly parentRealId?: string;
229
+ readonly name?: string;
230
+ }, options: {
231
+ readonly ifRevision?: string;
232
+ }): Promise<StorageItem>;
233
+ /** Server-side copy within one drive; resolves once the copy is complete. */
234
+ copy(drive: StorageDrive, realId: string, target: {
235
+ readonly parentRealId: string;
236
+ readonly name?: string;
237
+ }, options: {
238
+ readonly conflict: "fail" | "rename";
239
+ }): Promise<StorageItem>;
240
+ /** Soft delete into the provider's trash. Folders go with their contents. */
241
+ trash(drive: StorageDrive, realId: string, options: {
242
+ readonly ifRevision?: string;
243
+ }): Promise<void>;
244
+ }
245
+ /** Normalized provider failure codes. */
246
+ export type StorageErrorCode = "NotFound" | "NameConflict" | "RevisionMismatch" | "Unsupported" | "AuthExpired" | "RateLimited" | "QuotaExceeded" | "ProviderUnavailable";
247
+ /**
248
+ * Error a provider throws with a normalized code. The service maps it
249
+ * onto an agent-readable `ToolError`.
250
+ */
251
+ export declare class StorageError extends Error {
252
+ readonly code: StorageErrorCode;
253
+ constructor(code: StorageErrorCode, message: string);
254
+ }
255
+ /** Plugin-facing registration surface (`ctx.storage`). */
256
+ export interface StorageApi {
257
+ /**
258
+ * Register the plugin's storage provider. Call from `prepare()` so
259
+ * CLI commands reach it too. Throws if the same `pluginId`
260
+ * registers twice.
261
+ */
262
+ registerProvider(provider: StorageProvider): void;
263
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Cloud-storage provider SPI. Lives in `shared/` because the host (which
3
+ * owns the `StorageService`, the `storage_*` agent tools and the
4
+ * `familiar storage` CLI) and the provider plugins (ms365, later gdrive,
5
+ * dropbox, …) trade in the same shapes.
6
+ *
7
+ * Division of labour:
8
+ *
9
+ * - **Providers** speak their API: they address items by their native
10
+ * id (`realId`), stream bytes between the provider and a host-side
11
+ * file, and map API failures onto {@link StorageError} codes. They
12
+ * never see mounts, refs, policy, quotas or the agent.
13
+ * - **The host `StorageService`** owns everything that must behave the
14
+ * same for every provider: mount aliases, ref parsing, path
15
+ * resolution for id-addressed providers, write policy, search
16
+ * fan-out, cross-mount copies and the per-run byte quota.
17
+ *
18
+ * Credentials never leave the provider: the agent only ever sees mount
19
+ * aliases, refs, metadata and files placed in its scratch directory.
20
+ * Hard delete is deliberately absent from the SPI.
21
+ */
22
+ /**
23
+ * Error a provider throws with a normalized code. The service maps it
24
+ * onto an agent-readable `ToolError`.
25
+ */
26
+ export class StorageError extends Error {
27
+ code;
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.code = code;
31
+ this.name = "StorageError";
32
+ }
33
+ }
@@ -84,6 +84,17 @@ export interface OffloadedJson {
84
84
  * the runner does not catch.
85
85
  */
86
86
  export declare function runJsonTool(body: () => Promise<object>, ctx: ToolRunContext): Promise<object>;
87
+ /**
88
+ * Optional presentation settings for {@link runJsonLinesTool} and
89
+ * {@link runTextTool}.
90
+ *
91
+ * - `header`: one line prepended to the result (provenance,
92
+ * pagination, …). It is always kept inline, also when the body gets
93
+ * truncated, and is included at the top of the spilled file.
94
+ */
95
+ export interface ToolRunOptions {
96
+ readonly header?: string;
97
+ }
87
98
  /**
88
99
  * Run a tool whose result is a stream of JSON objects, serialized as
89
100
  * newline-delimited JSON. If the total fits within the limit, returns
@@ -97,7 +108,7 @@ export declare function runJsonTool(body: () => Promise<object>, ctx: ToolRunCon
97
108
  * limit, only the marker is returned (`omittedLines` then equals the
98
109
  * total line count).
99
110
  */
100
- export declare function runJsonLinesTool(body: () => Promise<Iterable<object> | AsyncIterable<object>>, ctx: ToolRunContext): Promise<string>;
111
+ export declare function runJsonLinesTool(body: () => Promise<Iterable<object> | AsyncIterable<object>>, ctx: ToolRunContext, options?: ToolRunOptions | (() => ToolRunOptions)): Promise<string>;
101
112
  /**
102
113
  * Run a tool whose result is free text. If the text fits the limit,
103
114
  * returns it verbatim. Otherwise spills the full text to scratch and
@@ -109,7 +120,7 @@ export declare function runJsonLinesTool(body: () => Promise<Iterable<object> |
109
120
  * The truncation respects UTF-8 code-point boundaries so multi-byte
110
121
  * characters never get split.
111
122
  */
112
- export declare function runTextTool(body: () => Promise<string>, ctx: ToolRunContext): Promise<string>;
123
+ export declare function runTextTool(body: () => Promise<string>, ctx: ToolRunContext, options?: ToolRunOptions | (() => ToolRunOptions)): Promise<string>;
113
124
  /**
114
125
  * Truncate `str` to at most `maxBytes` UTF-8 bytes without splitting a
115
126
  * code point. Surrogate pairs (code points beyond the BMP) are kept
@@ -89,15 +89,17 @@ export async function runJsonTool(body, ctx) {
89
89
  * limit, only the marker is returned (`omittedLines` then equals the
90
90
  * total line count).
91
91
  */
92
- export async function runJsonLinesTool(body, ctx) {
92
+ export async function runJsonLinesTool(body, ctx, options) {
93
93
  const source = await body();
94
94
  const serialized = [];
95
95
  for await (const item of asAsyncIterable(source)) {
96
96
  serialized.push(JSON.stringify(item));
97
97
  }
98
- const inline = serialized.join("\n");
98
+ const header = resolveOptions(options).header;
99
+ const headerPrefix = header !== undefined ? `${header}\n` : "";
100
+ const inline = headerPrefix + serialized.join("\n");
99
101
  if (estimateTokens(inline) <= ctx.limit) {
100
- return inline;
102
+ return header !== undefined && serialized.length === 0 ? header : inline;
101
103
  }
102
104
  const fullText = inline;
103
105
  const fullResultAt = await ctx.spill("result.jsonl", Buffer.from(fullText, "utf8"));
@@ -111,7 +113,7 @@ export async function runJsonLinesTool(body, ctx) {
111
113
  });
112
114
  const markerBudget = estimateTokens(upperBoundMarker);
113
115
  const kept = [];
114
- let usedTokens = 0;
116
+ let usedTokens = estimateTokens(headerPrefix);
115
117
  for (const line of serialized) {
116
118
  const separator = kept.length > 0 ? 1 : 0; // newline before this line (~1 token)
117
119
  const newlineBeforeMarker = 1; // newline between last kept line and marker
@@ -125,9 +127,9 @@ export async function runJsonLinesTool(body, ctx) {
125
127
  const omittedLines = serialized.length - kept.length;
126
128
  const marker = JSON.stringify({ truncated: true, fullResultAt, omittedLines });
127
129
  if (kept.length === 0) {
128
- return marker;
130
+ return `${headerPrefix}${marker}`;
129
131
  }
130
- return `${kept.join("\n")}\n${marker}`;
132
+ return `${headerPrefix}${kept.join("\n")}\n${marker}`;
131
133
  }
132
134
  /**
133
135
  * Run a tool whose result is free text. If the text fits the limit,
@@ -140,22 +142,25 @@ export async function runJsonLinesTool(body, ctx) {
140
142
  * The truncation respects UTF-8 code-point boundaries so multi-byte
141
143
  * characters never get split.
142
144
  */
143
- export async function runTextTool(body, ctx) {
144
- const text = await body();
145
+ export async function runTextTool(body, ctx, options) {
146
+ const bodyText = await body();
147
+ const header = resolveOptions(options).header;
148
+ const headerPrefix = header !== undefined ? `${header}\n` : "";
149
+ const text = headerPrefix + bodyText;
145
150
  if (estimateTokens(text) <= ctx.limit) {
146
- return text;
151
+ return header !== undefined && bodyText.length === 0 ? header : text;
147
152
  }
148
153
  const fullResultAt = await ctx.spill("result.txt", Buffer.from(text, "utf8"));
149
154
  const footer = `\n\n[truncated; full result at ${fullResultAt}]`;
150
- const footerTokens = estimateTokens(footer);
155
+ const footerTokens = estimateTokens(footer) + estimateTokens(headerPrefix);
151
156
  // The budget is in tokens; `truncateUtf8` bounds by character count.
152
157
  // estimateTokens(s) = ceil(len/4), so the char budget for the prefix
153
158
  // is the remaining token budget × 4. Bounding `truncateUtf8` by that
154
159
  // char count is slightly conservative for multi-byte text — fine for
155
160
  // a placeholder estimator backing a truncation marker.
156
161
  const prefixCharBudget = Math.max(0, (ctx.limit - footerTokens) * 4);
157
- const prefix = truncateUtf8(text, prefixCharBudget);
158
- return `${prefix}${footer}`;
162
+ const prefix = truncateUtf8(bodyText, prefixCharBudget);
163
+ return `${headerPrefix}${prefix}${footer}`;
159
164
  }
160
165
  /**
161
166
  * Truncate `str` to at most `maxBytes` UTF-8 bytes without splitting a
@@ -182,6 +187,17 @@ export function truncateUtf8(str, maxBytes) {
182
187
  }
183
188
  return str.slice(0, charIdx);
184
189
  }
190
+ /**
191
+ * Normalize the options argument. A function form lets a tool compute
192
+ * its header *after* the body ran (e.g. from pagination state the body
193
+ * produced).
194
+ */
195
+ function resolveOptions(options) {
196
+ if (options === undefined) {
197
+ return {};
198
+ }
199
+ return typeof options === "function" ? options() : options;
200
+ }
185
201
  /** Lift a sync iterable into an async one so we can `for await` over both. */
186
202
  async function* asAsyncIterable(source) {
187
203
  if (Symbol.asyncIterator in source) {
package/build/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export { buildMailId, type ForwardInput, type MailAddress, type MailApi, type Ma
18
18
  export { MAIL_STYLE_TEMPLATE_DEFAULTS, type MailStyleTemplate, mailStyleTemplatePath, signatureToPlainText, } from "./MailStyleTemplate.js";
19
19
  export { type ModelMetaData, ModelNotSupported, type ModelProviderDescriptor, } from "./ModelMetaData.js";
20
20
  export { renderMarkdownToHtml } from "./markdownHtml.js";
21
+ export { escapeTableCell, markdownTable, writeMarkdown } from "./markdownOutput.js";
21
22
  export { renderMarkdown } from "./markdownTerminal.js";
22
23
  export { matchesAnyGlob, matchesGlob } from "./PathGlob.js";
23
24
  export type { AnyCommandDef, EmitHandle, EmitOptions, EventContextProvider, HostContext, McpClient, McpInfo, PluginHostManifest, PluginManifest, PluginTool, PluginToolCallContext, } from "./Plugin.js";
@@ -28,10 +29,11 @@ export { ScheduledSubagentBus, type ScheduledSubagentNotification, type Schedule
28
29
  export { AGENTRUNS_CHANNEL, CHATMESSAGES_NEW_CHANNEL, EVENT_TERMINAL_UPDATE_SQL, EVENTS_NEW_CHANNEL, EVENTS_STATE_CHANNEL, SCHEDULED_SUBAGENTS_CHANNEL, SCHEMA_SQL, STEPRESULTS_NEW_CHANNEL, TOPIC_PATTERN, } from "./Schema.js";
29
30
  export type { NewStepResult, StepResultRow } from "./StepResult.js";
30
31
  export { StepResultBus, type StepResultUnsubscribe } from "./StepResultBus.js";
32
+ export { type StorageApi, type StorageCapabilities, type StorageConflict, type StorageContentHash, type StorageDownloadResult, type StorageDrive, type StorageDriveKind, StorageError, type StorageErrorCode, type StorageHashAlgo, type StorageItem, type StorageItemKind, type StorageListOrder, type StorageMatchedIn, type StoragePage, type StoragePageOptions, type StorageProvider, type StorageSearchHit, type StorageSearchQuery, type StorageUploadSource, } from "./Storage.js";
31
33
  export { dayBoundsInZone, type ParseInZoneResult, parseInZone, renderInZone, } from "./Timezone.js";
32
34
  export type { NewToolCall, ToolCallRow, ToolUsageCount } from "./ToolCall.js";
33
35
  export { ToolCallBus } from "./ToolCallBus.js";
34
36
  export { DEFAULT_TOOL_LEVEL, type ToolLevel } from "./ToolLevel.js";
35
- export { DEFAULT_TOOL_CALL_OFFLOADING_LIMIT, type OffloadedJson, runJsonLinesTool, runJsonTool, runTextTool, ToolError, type ToolRunContext, truncateUtf8, } from "./ToolRunner.js";
37
+ export { DEFAULT_TOOL_CALL_OFFLOADING_LIMIT, type OffloadedJson, runJsonLinesTool, runJsonTool, runTextTool, ToolError, type ToolRunContext, type ToolRunOptions, truncateUtf8, } from "./ToolRunner.js";
36
38
  export { ALL_GROUP_NAME, CORE_PLUGIN_ID, IDENT_PATTERN, MCP_GROUP_NAME, matchesAnyToolPattern, NONE_GROUP_NAME, RESERVED_GROUP_NAMES, toolPatternMatches, validateGroupName, } from "./ToolsExpressionUtils.js";
37
39
  export type { WorkspaceFile, WorkspaceFileFilter, WorkspaceWatcherApi, } from "./WorkspaceFile.js";
package/build/index.js CHANGED
@@ -12,6 +12,7 @@ export { buildMailId, parseMailId, } from "./Mail.js";
12
12
  export { MAIL_STYLE_TEMPLATE_DEFAULTS, mailStyleTemplatePath, signatureToPlainText, } from "./MailStyleTemplate.js";
13
13
  export { ModelNotSupported, } from "./ModelMetaData.js";
14
14
  export { renderMarkdownToHtml } from "./markdownHtml.js";
15
+ export { escapeTableCell, markdownTable, writeMarkdown } from "./markdownOutput.js";
15
16
  export { renderMarkdown } from "./markdownTerminal.js";
16
17
  export { matchesAnyGlob, matchesGlob } from "./PathGlob.js";
17
18
  export { DaemonStoppedError, definePlugin } from "./Plugin.js";
@@ -19,6 +20,7 @@ export { POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, PostgresConne
19
20
  export { ScheduledSubagentBus, } from "./ScheduledSubagentBus.js";
20
21
  export { AGENTRUNS_CHANNEL, CHATMESSAGES_NEW_CHANNEL, EVENT_TERMINAL_UPDATE_SQL, EVENTS_NEW_CHANNEL, EVENTS_STATE_CHANNEL, SCHEDULED_SUBAGENTS_CHANNEL, SCHEMA_SQL, STEPRESULTS_NEW_CHANNEL, TOPIC_PATTERN, } from "./Schema.js";
21
22
  export { StepResultBus } from "./StepResultBus.js";
23
+ export { StorageError, } from "./Storage.js";
22
24
  export { dayBoundsInZone, parseInZone, renderInZone, } from "./Timezone.js";
23
25
  export { ToolCallBus } from "./ToolCallBus.js";
24
26
  export { DEFAULT_TOOL_LEVEL } from "./ToolLevel.js";