@repogo/plugins 0.1.32 → 0.1.34

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/README.md CHANGED
@@ -1,8 +1,21 @@
1
1
  # @repogo/plugins
2
2
 
3
- Write plugins for a [RepoGo](https://repogo.app) project: small apps on your
4
- environment with their own SQLite data, that agents call as tools and cards
5
- draw live on your phone.
3
+ Plugins are small apps that run on your environment, the machine the RepoGo
4
+ host runs on. A plugin keeps its own data, does work with your tools and APIs,
5
+ and is reached two ways: agents call its methods as tools, and cards on your
6
+ phone draw its data live and run its methods when you tap.
7
+
8
+ - [Quick start](#quick-start)
9
+ - [Where plugins live](#where-plugins-live)
10
+ - [Methods](#methods): queries, mutations, streams
11
+ - [Data](#data): namespaces, collections, kv, streams, buckets
12
+ - [Crons](#crons)
13
+ - [The context](#the-context)
14
+ - [Events](#events)
15
+ - [How cards and agents reach a plugin](#how-cards-and-agents-reach-a-plugin)
16
+ - [Errors and confirmations](#errors-and-confirmations)
17
+
18
+ ## Quick start
6
19
 
7
20
  ```ts
8
21
  // .repogo/plugins/notes/app.ts
@@ -10,21 +23,75 @@ import { definePlugin, method, z } from "@repogo/plugins";
10
23
 
11
24
  export default definePlugin({
12
25
  description: "Notes for this project",
13
- db: "myproject.notes",
14
- collections: { notes: z.object({ text: z.string() }) },
26
+ namespace: "myproject.notes",
27
+ collections: { notes: z.object({ text: z.string(), done: z.boolean().default(false) }) },
15
28
  methods: {
16
- search: method(z.object({ text: z.string() }), async ({ text }, ctx) =>
17
- (await ctx.db.notes.list()).filter((note) => note.text.includes(text)), { kind: "query" }),
29
+ open: method(z.object({}), async (_, ctx) => ctx.collections.notes.list({ where: { done: false } }), {
30
+ kind: "query",
31
+ description: "Notes not done yet, newest first",
32
+ }),
18
33
  clear: method(z.object({}), async (_, ctx) => {
19
- for (const note of await ctx.db.notes.list()) await ctx.db.notes.delete(note.id);
34
+ for (const note of await ctx.collections.notes.list({ limit: 500 })) await ctx.collections.notes.delete(note.id);
35
+ return null;
20
36
  }, { confirm: "Delete every note?" }),
21
37
  },
22
38
  });
23
39
  ```
24
40
 
25
- A `stream` sends items as they happen, for as long as a card shows it. Each
26
- `yield` is one item; `ctx.signal` aborts when the card goes, so hand it to what
27
- you start:
41
+ That's a whole plugin. The host loads it on its next look at the folder (within
42
+ a minute; at once when something calls it), and it gets:
43
+
44
+ - `notes.open` and `notes.clear`, the methods it wrote;
45
+ - `notes.notes.list`, `.get`, `.create`, `.update` and `.delete`, generated for its collection;
46
+ - a `data.db` in `~/.repogo/plugin-data/myproject.notes/`.
47
+
48
+ Add the package to `.repogo/plugins/package.json` (`bun add @repogo/plugins`) for
49
+ types and editor help. The host runs plugins with its own copy of this package,
50
+ the version it shipped with, so a plugin always matches its host. Plugins run on
51
+ [Bun](https://bun.sh); install it on the environment.
52
+
53
+ ## Where plugins live
54
+
55
+ | Folder | For | `ctx.root` |
56
+ | --- | --- | --- |
57
+ | `<project>/.repogo/plugins/<name>/app.ts` | One project | The project's checkout |
58
+ | `~/.repogo/plugins/<name>/app.ts` | Every project (Vercel, Gmail, builds) | The project that called it |
59
+
60
+ A plugin's name is its folder's: lowercase letters, digits and hyphens. A
61
+ project plugin hides an environment one of the same name. Each folder's plugins
62
+ run together in one Worker, so a plugin's blocking code stalls the others in its
63
+ folder: use async APIs.
64
+
65
+ Pages beside an environment plugin (`pages/*.md`) open from any project as
66
+ `~/.repogo/plugins/<name>/pages/<page>.md`, and `~/.repogo/plugins/tab.json`
67
+ names the tab of a project without its own.
68
+
69
+ ## Methods
70
+
71
+ ```ts
72
+ method(input, run, { kind?, description?, output?, confirm? })
73
+ stream(input, async function* run, { description? })
74
+ ```
75
+
76
+ `input` is a zod schema; the call's input is checked against it and defaults
77
+ filled in before `run` sees it. `run(input, ctx)` returns the result: anything
78
+ JSON can carry.
79
+
80
+ | Kind | Does | Who runs it |
81
+ | --- | --- | --- |
82
+ | `query` | Only reads; writing throws | Cards on their own, and again whenever data it read changes |
83
+ | `mutation` (the default) | Reads and writes | Only when someone acts: a tap, an agent, a cron, other code |
84
+ | `stream` | Only reads; yields items as they happen | A card while it shows it |
85
+
86
+ Mark every method that only reads as `kind: "query"`: that's what lets a card
87
+ show it and keep it current. Give each a `description`; agents read it to choose
88
+ a method. `output`, a zod schema of the result, tells agents and cards its
89
+ field names.
90
+
91
+ A **stream** sends each `yield` to the card as it happens, for as long as the
92
+ card shows it. `ctx.signal` aborts when the card goes, so hand it to what you
93
+ start. Give items an `id`. An agent calling a stream gets what arrives within
94
+ 5 seconds, at most 200 items.
28
95
 
29
96
  ```ts
30
97
  tail: stream(z.object({ file: z.string() }), async function* ({ file }, ctx) {
@@ -38,24 +105,258 @@ tail: stream(z.object({ file: z.string() }), async function* ({ file }, ctx) {
38
105
  }),
39
106
  ```
40
107
 
41
- `db` names the SQLite file the collections live in, `~/.repogo/plugin-data/<db>.db`
42
- on the environment; left out, it is the plugin's folder name. Name a project's own
43
- data `<project>.<name>`. A bare name (`todos`) is shared by every plugin that
44
- names it, in any project, so they must agree on their collections' schemas.
45
- Data stays in place when the project moves. A plugin without collections has no file.
108
+ ## Data
46
109
 
47
- A plugin for every project goes in `~/.repogo/plugins/<name>/app.ts` instead
48
- of a project's folder: the same shape, run with `ctx.root` set to whichever
49
- project calls it. A project plugin of the same name takes precedence. Pages
50
- beside it (`pages/*.md`) are reachable from any project as
51
- `~/.repogo/plugins/<name>/pages/<page>.md`, and `~/.repogo/plugins/tab.json`
52
- lists the pages every project's tab shows.
110
+ A plugin declares its data in four kinds, each with a schema, and gets typed
111
+ access in `ctx` and generated methods for agents and cards:
112
+
113
+ | Kind | Holds | `ctx` | On disk |
114
+ | --- | --- | --- | --- |
115
+ | `collections` | Records: tasks, notes, settings rows | `ctx.collections.<name>` | Rows in `data.db` |
116
+ | `kv` | Keys to values, which may expire | `ctx.kv.<name>` | Rows in `data.db` |
117
+ | `streams` | Append-only lines per key: a run's log, a job's output | `ctx.streams.<name>` | `streams/<name>/<key>/*.jsonl` |
118
+ | `buckets` | Files, each with its meta | `ctx.buckets.<name>` | `buckets/<name>/<id>/` |
119
+
120
+ Every write is checked against its schema, and every read and write is tracked,
121
+ so a card showing a query refreshes when a write touches what it read: in this
122
+ plugin, another plugin, or another project.
123
+
124
+ ### Namespaces
125
+
126
+ `namespace` names where a plugin's data lives: the folder
127
+ `~/.repogo/plugin-data/<namespace>/`, holding `data.db` and its streams' and
128
+ buckets' files. Left out, it's the plugin's name. Plugins naming the same
129
+ namespace share its data, in any project, so they must declare the same schemas.
130
+
131
+ By convention a project's own data is `<project>.<name>` (`myproject.notes`),
132
+ and data meant for every project a bare name (`todos`). That's only a
133
+ convention: the same namespace is the same data. Data stays put when a project
134
+ moves. A plugin with no data has no folder.
135
+
136
+ ### Collections
137
+
138
+ ```ts
139
+ collections: { tasks: z.object({ title: z.string(), status: z.enum(["open", "done"]).default("open") }) }
140
+ ```
141
+
142
+ ```ts
143
+ const task = await ctx.collections.tasks.create({ title: "Ship it" }); // { id, title, status, createdAt, updatedAt }
144
+ await ctx.collections.tasks.get(task.id); // or null
145
+ await ctx.collections.tasks.update(task.id, { status: "done" }); // only the fields given change
146
+ await ctx.collections.tasks.list({ where: { status: "open" }, orderBy: "updatedAt", order: "desc", limit: 50 });
147
+ await ctx.collections.tasks.delete(task.id);
148
+ ```
149
+
150
+ `list` is newest first by default, at most 100 unless `limit` says (up to 500).
151
+ `where` matches top-level fields exactly; `null` matches a missing one.
152
+
153
+ ### Kv
154
+
155
+ ```ts
156
+ kv: { state: z.object({ step: z.string() }) }
157
+ ```
158
+
159
+ ```ts
160
+ await ctx.kv.state.set("run-42", { step: "build" }, { ttl: "1h" }); // ttl: "30s", "5m", "1h", "7d" or ms
161
+ await ctx.kv.state.get("run-42"); // { step: "build" }, or null
162
+ await ctx.kv.state.entry("run-42"); // { key, value, expiresAt, createdAt, updatedAt }
163
+ await ctx.kv.state.list({ prefix: "run-", limit: 100 }); // entries in key order
164
+ await ctx.kv.state.delete("run-42");
165
+ ```
166
+
167
+ The value's schema may be any zod type, not only an object. A key past its
168
+ `ttl` reads as missing at once; a sweep every few seconds deletes it and
169
+ refreshes cards. Setting a key again without a `ttl` keeps it until deleted.
170
+
171
+ ### Streams
172
+
173
+ ```ts
174
+ streams: {
175
+ runs: z.object({ level: z.string(), text: z.string() }),
176
+ audit: { item: z.object({ text: z.string() }), retain: { age: "90d", bytes: 500_000_000 } },
177
+ }
178
+ ```
179
+
180
+ A stream is append-only JSON lines, split by key: one key per workflow run, job
181
+ or day. Each line on disk is `{ offset, at, data }`, in
182
+ `streams/<name>/<key>/<first offset>.jsonl`, a new file each megabyte, so you
183
+ can `tail` and grep them.
184
+
185
+ ```ts
186
+ await ctx.streams.runs.append("run-42", { level: "info", text: "built" }); // { id: "run-42:0", key, offset, at, data }
187
+ await ctx.streams.runs.read("run-42", { from: 0, limit: 100 });
188
+ for await (const line of ctx.streams.runs.follow("run-42", { signal: ctx.signal })) { /* … */ }
189
+ await ctx.streams.runs.end("run-42"); // followers stop; appends fail
190
+ await ctx.streams.runs.keys(); // [{ key, lines, bytes, endedAt, updatedAt }], newest first
191
+ await ctx.streams.runs.delete("run-42");
192
+ ```
193
+
194
+ `follow` replays the last 100 lines (or from `from`, or the last `last`), then
195
+ yields each new one, until the key ends or the signal aborts. Keys are letters,
196
+ digits, dots, dashes and underscores.
197
+
198
+ A key is kept 7 days after its last line or its end, and past 50 MB its oldest
199
+ files go. `retain` sets `{ age?, bytes? }` per stream, or `"forever"`. Offsets
200
+ are never reused: after a crash between writing a line and recording it, the
201
+ line is cut off before the next append.
202
+
203
+ ### Buckets
204
+
205
+ ```ts
206
+ buckets: { photos: z.object({ caption: z.string().optional(), tags: z.array(z.string()).optional() }) }
207
+ ```
208
+
209
+ A bucket keeps files, one folder per file named by its id, holding the file
210
+ under its own name and its `meta.json`:
211
+
212
+ ```
213
+ buckets/photos/
214
+ 01a14cd8-ad7a-7000-8773-aa8bff079ece/
215
+ beach.jpg
216
+ meta.json {"caption": "Sunset", "folder": "2026"}
217
+ ```
218
+
219
+ The folders are the truth. Anyone may add a file by making a folder with the
220
+ file in it, and change its meta by writing `meta.json`: an agent with its file
221
+ tools, a script, Finder. The host indexes the bucket for `list` and `search` when
222
+ the folder changes, and rescans it each minute. Meta may be missing or `{}`;
223
+ its one reserved field, `folder`, places the file in a folder of the bucket's
224
+ own. Each file reads with a `status`:
225
+
226
+ | `status` | Meta | `problem` |
227
+ | --- | --- | --- |
228
+ | `ok` | Matches the schema | null |
229
+ | `empty` | None, or `{}` | null |
230
+ | `invalid` | Fails the schema; kept as written | Why |
231
+ | `malformed` | Not a JSON object, or the folder doesn't hold exactly one file | Why |
232
+
233
+ ```ts
234
+ const file = await ctx.buckets.photos.put({ name: "beach.jpg", body: bytes, folder: "2026", meta: { caption: "Sunset" } });
235
+ // { id, name, folder, path, size, type, meta, status, problem, revision, createdAt, updatedAt }
236
+ await ctx.buckets.photos.get(file.id);
237
+ await ctx.buckets.photos.list({ folder: "2026", status: "ok", limit: 100 }); // newest first
238
+ await ctx.buckets.photos.search("sunset trip"); // name, folder and meta; words as prefixes
239
+ await ctx.buckets.photos.update(file.id, { tags: ["trip"], caption: null }, { revision: file.revision });
240
+ await ctx.buckets.photos.move(file.id, { name: "sunset.jpg", folder: "best" });
241
+ const text = await (await ctx.buckets.photos.file(file.id)).text(); // a BunFile
242
+ await ctx.buckets.photos.delete(file.id);
243
+ ```
244
+
245
+ `body` is a string, bytes or a `Blob` (`Bun.file(path)` copies a file). `path`
246
+ is the file on disk. `update` merges into the meta, `null` removing a field, and
247
+ checks it against the schema; with `revision`, it fails if the file changed
248
+ since you read it. Writes through `ctx` are checked; files written by hand are
249
+ indexed with their `status` instead.
250
+
251
+ ### Generated methods
252
+
253
+ Every collection, kv, stream and bucket gets methods named
254
+ `<plugin>.<name>.<method>`, so a plugin that only declares data can be read and
255
+ changed by agents and cards with no code of its own:
256
+
257
+ | Kind | Queries | Mutations | Stream |
258
+ | --- | --- | --- | --- |
259
+ | Collection | `list`, `get` | `create`, `update`, `delete` (asks) | |
260
+ | Kv | `get`, `list` | `set`, `delete` (asks) | |
261
+ | Stream | `read`, `keys` | `append`, `end`, `delete` (asks) | `tail` |
262
+ | Bucket | `list`, `search`, `get` | `put`, `update`, `move`, `delete` (asks) | |
263
+
264
+ A bucket's `put` takes `text`, or `from`, an absolute path on the environment to
265
+ copy: bytes never travel in a call. A method of your own with the same name as a
266
+ generated one fails the plugin's load.
267
+
268
+ ## Crons
269
+
270
+ ```ts
271
+ export default definePlugin({
272
+ methods: { watch: method(z.object({}), async (_, ctx) => { /* … */ }) },
273
+ crons: [
274
+ { method: "watch", schedule: "*/5 * * * *" },
275
+ { method: "digest", schedule: "0 9 * * 1-5", timezone: "America/New_York" },
276
+ ],
277
+ });
278
+ ```
279
+
280
+ The host calls the method with `{}` when it comes due, as a tap would, whether
281
+ or not anyone is looking. `schedule` is five fields (minute hour day-of-month
282
+ month day-of-week), or six with seconds first (`*/5 * * * * *`); each is `*`, a
283
+ number, a range, a list or a step. `timezone` defaults to the environment's.
284
+
285
+ A job still running isn't started again, a time the environment slept through
286
+ is skipped, and nothing is retried. A cron naming a missing method, a stream, a
287
+ method that needs input or asks first, or a schedule never due fails the
288
+ plugin's load. The manifest shows each cron's `nextRunAt`, `lastRunAt`,
289
+ `lastError` and `runs`.
290
+
291
+ ## The context
292
+
293
+ Every method, handler and cron gets `ctx`:
294
+
295
+ | Field | What |
296
+ | --- | --- |
297
+ | `plugin` | The plugin's name |
298
+ | `root` | The calling project's checkout |
299
+ | `collections`, `kv`, `streams`, `buckets` | The plugin's data |
300
+ | `call(method, input)` | Runs a method of a plugin in the same folder; `"open"` is this plugin's own |
301
+ | `plugins()` | The plugins in the same folder, with their methods |
302
+ | `host(method, params)` | Runs one of the host's methods that agents and cards may call (`git.status`, `chats.list`, `actions.run`, …); others fail with `FORBIDDEN` |
303
+ | `notify({ title, body, page?, collapse? })` | Alerts every paired phone; a tap opens `page`, and alerts sharing `collapse` replace each other |
304
+ | `emit(event, payload)` | Sends `<plugin>.<event>` to handlers |
305
+ | `signal` | Aborts when a stream's card goes |
306
+ | `log(...)` | Writes to the host's plugin log |
307
+
308
+ A query doesn't re-run when what a `ctx.host` method read changes.
309
+
310
+ ## Events
311
+
312
+ Handlers in `on` run after the events they name:
313
+
314
+ ```ts
315
+ on: {
316
+ "photos.created": async (file, ctx) => { /* caption it */ },
317
+ "tasks.updated": async ({ before, after }, ctx) => { /* … */ },
318
+ }
319
+ ```
320
+
321
+ | Source | Events |
322
+ | --- | --- |
323
+ | Collection | `<name>.created` (the record), `<name>.updated` (`{ before, after }`), `<name>.deleted` (the record) |
324
+ | Kv | `<name>.created`, `<name>.updated`, `<name>.deleted` (the entry) |
325
+ | Bucket | `<name>.created`, `<name>.changed` (the file's bytes), `<name>.metaChanged`, `<name>.deleted` (the file) |
326
+ | Any method | `<method>` (`{ input, result }`) |
327
+
328
+ Changes to a bucket's folder made by hand emit too, once the host sees them.
329
+ Events run in memory: none is retried, and a handler that throws is logged.
330
+
331
+ ## How cards and agents reach a plugin
332
+
333
+ **Agents** use two tools: `plugins` lists every plugin with its methods, input
334
+ schemas, crons and `storage` (each bucket's and stream's folder), and
335
+ `plugin_call` runs one. Call a new method once before a card names it.
336
+
337
+ **Cards** name methods as `plugin:<plugin>.<method>`. A `Data` element runs a
338
+ query, or follows a stream, and redraws when its data changes; a `Button` or
339
+ `Item` action runs a mutation:
340
+
341
+ ```xml
342
+ <Data name="photos" method="plugin:gallery.photos.search">
343
+ <Param name="query" value="{{q}}"/>
344
+ <Each in="photos" as="p"><Item label="{{p.name}}" subtitle="{{p.meta.caption}}"/></Each>
345
+ </Data>
346
+ <Button label="Mark done" action="plugin:notes.notes.update">
347
+ <Param name="id" value="{{note.id}}"/>
348
+ <Param name="done" value="true" type="boolean"/>
349
+ </Button>
350
+ ```
351
+
352
+ ## Errors and confirmations
53
353
 
54
- A method may alert the user's phones: `ctx.notify({ title, body, page })`, where
55
- `page` is a page target a tap opens (`".repogo/tabs/vercel.md?project=a"`), and
56
- `collapse` lets a repeated alert replace itself.
354
+ Throw `new MethodError(code, message)` for a failure the caller should read:
355
+ `VALIDATION`, `NOT_FOUND`, `FORBIDDEN` or `INTERNAL`. Input that fails its
356
+ schema answers `VALIDATION` with a message per field. Anything else thrown is
357
+ `INTERNAL` with its message.
57
358
 
58
- Add it to `.repogo/plugins/package.json` (`bun add @repogo/plugins`) for types
59
- and editor help. The RepoGo host runs plugins with its own copy of this
60
- package, the version it shipped with, so a plugin always matches the host.
61
- Plugins run on [Bun](https://bun.sh); install it on the environment.
359
+ A method's `confirm` is asked before a card's tap runs it. An agent's
360
+ `plugin_call` gets it back as a `CONFIRM` error until it calls again with
361
+ `confirmed: true`, once the user agrees. Only `ctx.call` from plugin code skips
362
+ it. Give one to anything that can't be undone.
@@ -0,0 +1,88 @@
1
+ import type { z } from "zod";
2
+ import { Store, type StoreOptions } from "./store";
3
+ /**
4
+ * How a file's meta reads: `ok` matches the bucket's schema, `empty` has none,
5
+ * `invalid` fails the schema, and `malformed` isn't JSON or its folder doesn't
6
+ * hold exactly one file. `problem` says why for the last two.
7
+ */
8
+ export type FileStatus = "ok" | "empty" | "invalid" | "malformed";
9
+ /** One file in a bucket. `path` is the file on disk; `meta` is its `meta.json` without `folder`. */
10
+ export type BucketFile<T> = {
11
+ id: string;
12
+ name: string;
13
+ folder: string;
14
+ path: string;
15
+ size: number;
16
+ type: string;
17
+ meta: Partial<T>;
18
+ status: FileStatus;
19
+ problem: string | null;
20
+ revision: number;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ };
24
+ /** What `put` writes: the file's name, its bytes, and optionally where it sits and its meta. */
25
+ export type PutFile<T> = {
26
+ name: string;
27
+ body: string | Uint8Array | ArrayBuffer | Blob;
28
+ folder?: string;
29
+ meta?: T;
30
+ };
31
+ export declare class Bucket<S extends z.ZodObject> extends Store {
32
+ private schema;
33
+ private table;
34
+ private searchTable;
35
+ private folder;
36
+ private watcher;
37
+ private settling;
38
+ private scanning;
39
+ private lastScan;
40
+ constructor(options: StoreOptions, name: string, schema: S);
41
+ /** The bucket's folder, for agents to add and read files. */
42
+ get path(): string;
43
+ setup(): Promise<void>;
44
+ put(file: PutFile<z.input<S>>): Promise<BucketFile<z.infer<S>>>;
45
+ get(id: string): Promise<BucketFile<z.infer<S>> | null>;
46
+ /** The file's bytes, to read as text, JSON, a stream or an ArrayBuffer. */
47
+ file(id: string): Promise<Bun.BunFile>;
48
+ /** Newest first; `folder` keeps one folder's files, `status` one status's. */
49
+ list(options?: {
50
+ folder?: string;
51
+ status?: FileStatus;
52
+ limit?: number;
53
+ }): Promise<BucketFile<z.infer<S>>[]>;
54
+ /** Files whose name, folder or meta text has every word of `query`, each as a prefix, best first. */
55
+ search(query: string, options?: {
56
+ limit?: number;
57
+ }): Promise<BucketFile<z.infer<S>>[]>;
58
+ /**
59
+ * Merges `meta` into the file's meta.json (`null` removes a field), checked
60
+ * against the schema. With `revision`, fails if the file changed since it was read.
61
+ */
62
+ update(id: string, meta: Partial<{
63
+ [K in keyof z.input<S>]: z.input<S>[K] | null;
64
+ }>, options?: {
65
+ revision?: number;
66
+ }): Promise<BucketFile<z.core.output<S>>>;
67
+ /** Renames the file, or moves it to another folder; its id stays. */
68
+ move(id: string, to: {
69
+ name?: string;
70
+ folder?: string;
71
+ }, options?: {
72
+ revision?: number;
73
+ }): Promise<BucketFile<z.core.output<S>>>;
74
+ delete(id: string): Promise<void>;
75
+ tick(now: number): Promise<void>;
76
+ close(): void;
77
+ /**
78
+ * Brings the table in line with the folders, one scan at a time. A folder
79
+ * whose prints match its row is skipped; rows go only once the listing worked.
80
+ */
81
+ scan(): Promise<void>;
82
+ private scanNow;
83
+ private reindex;
84
+ /** One file's folder as a row would hold it, or null when it's gone. */
85
+ private read;
86
+ private rewrite;
87
+ private toFile;
88
+ }
@@ -1,5 +1,5 @@
1
- import type { SQL } from "bun";
2
1
  import type { z } from "zod";
2
+ import type { Database } from "./database";
3
3
  import type { EventBus } from "./events";
4
4
  export type Record_<T> = T & {
5
5
  id: string;
@@ -13,19 +13,16 @@ export type ListOptions = {
13
13
  limit?: number;
14
14
  };
15
15
  export declare class Collection<S extends z.ZodObject> {
16
- private sql;
16
+ private database;
17
17
  private events;
18
18
  private plugin;
19
19
  private name;
20
20
  private schema;
21
21
  private table;
22
22
  readonly tag: string;
23
- /** This Worker's writes run one at a time; the transaction holds off other Workers. */
24
- private writes;
25
- constructor(sql: SQL, events: EventBus, plugin: string, db: string, name: string, schema: S);
23
+ constructor(database: Database, events: EventBus, plugin: string, namespace: string, name: string, schema: S);
26
24
  setup(): Promise<void>;
27
- private serial;
28
- private transaction;
25
+ private get sql();
29
26
  create(input: z.input<S>): Promise<Record_<z.infer<S>>>;
30
27
  get(id: string): Promise<Record_<z.infer<S>> | null>;
31
28
  update(id: string, patch: Partial<z.input<S>>): Promise<Record_<z.infer<S>>>;
package/dist/context.d.ts CHANGED
@@ -1,4 +1,8 @@
1
+ import type { Bucket } from "./buckets";
1
2
  import type { Collection } from "./collections";
3
+ import type { Kv } from "./kv";
4
+ import type { Stream } from "./streams";
5
+ import type { MethodKind } from "./define";
2
6
  /** An alert for the user's phones: what it says, and the page a tap opens. */
3
7
  export type Notice = {
4
8
  title: string;
@@ -8,13 +12,72 @@ export type Notice = {
8
12
  /** Alerts sharing a collapse id replace one another; defaults to the plugin and title. */
9
13
  collapse?: string;
10
14
  };
15
+ /** One method as `ctx.plugins()` and the agent's `plugins` tool list it. */
16
+ export type ManifestMethod = {
17
+ /** `<plugin>.<method>`, the name `ctx.call` takes. */
18
+ name: string;
19
+ kind: MethodKind;
20
+ description: string | null;
21
+ /** What a card asks before running it; null runs on tap. */
22
+ confirm: string | null;
23
+ /** JSON Schema of the input, and of the output when the method declares one. */
24
+ input: unknown;
25
+ output: unknown;
26
+ };
27
+ /** One cron and how it's going: times in ms, and what its last run threw. */
28
+ export type ManifestCron = {
29
+ method: string;
30
+ schedule: string;
31
+ timezone: string;
32
+ nextRunAt: number | null;
33
+ lastRunAt: number | null;
34
+ lastError: string | null;
35
+ running: boolean;
36
+ runs: number;
37
+ };
38
+ /** Where a plugin's buckets and streams sit on disk, for an agent's own file tools, and its kv names. */
39
+ export type ManifestStorage = {
40
+ buckets: {
41
+ name: string;
42
+ path: string;
43
+ }[];
44
+ kv: {
45
+ name: string;
46
+ }[];
47
+ streams: {
48
+ name: string;
49
+ path: string;
50
+ }[];
51
+ };
52
+ export type Manifest = {
53
+ plugins: {
54
+ name: string;
55
+ description: string | null;
56
+ methods: ManifestMethod[];
57
+ crons: ManifestCron[];
58
+ storage: ManifestStorage;
59
+ }[];
60
+ };
11
61
  export type Context = {
12
62
  plugin: string;
13
63
  /** The project's checkout, for a plugin that reads the project's files. */
14
64
  root: string;
15
- db: Record<string, Collection<any>>;
65
+ collections: Record<string, Collection<any>>;
66
+ buckets: Record<string, Bucket<any>>;
67
+ kv: Record<string, Kv<any>>;
68
+ streams: Record<string, Stream<any>>;
16
69
  call: (method: string, input?: unknown) => Promise<unknown>;
17
70
  emit: (name: string, payload: unknown) => Promise<void>;
71
+ /**
72
+ * Runs one of the host's methods, the ones agents and cards can call:
73
+ * `ctx.host("git.status", { path: ctx.root })`. Its answer doesn't re-run a query.
74
+ */
75
+ host: (method: string, params?: unknown) => Promise<unknown>;
76
+ /**
77
+ * The plugins beside this one, in the same folder (the project's, or the
78
+ * environment's), with their methods: what `call` can reach.
79
+ */
80
+ plugins: () => Manifest["plugins"];
18
81
  /** Aborts when a stream's last watcher leaves; a query or mutation's never does. */
19
82
  signal: AbortSignal;
20
83
  /** Sends an alert to every phone paired with this environment. */
@@ -0,0 +1,11 @@
1
+ import { SQL } from "bun";
2
+ export declare class Database {
3
+ readonly sql: SQL;
4
+ private writes;
5
+ private constructor();
6
+ static open(path: string): Promise<Database>;
7
+ /** Runs `work` alone in a transaction; it commits when `work` returns and rolls back when it throws. */
8
+ transaction<T>(work: () => Promise<T>): Promise<T>;
9
+ close(): void;
10
+ private run;
11
+ }
package/dist/define.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { z } from "zod";
2
2
  import type { Context } from "./context";
3
+ import type { Retain } from "./streams";
3
4
  export type Collections = Record<string, z.ZodObject>;
5
+ /** A stream's lines' schema, or that and how long it keeps them (7 days or 50 MB a key unless it says). */
6
+ export type StreamDefinition = z.ZodType | {
7
+ item: z.ZodType;
8
+ retain: Retain;
9
+ };
4
10
  /**
5
11
  * A query only reads, so cards may run it on their own and again when its data
6
12
  * changes. A mutation writes, and runs only when the user acts. A stream only
@@ -22,18 +28,36 @@ export type Method<I extends z.ZodType = z.ZodType, O extends z.ZodType = z.ZodT
22
28
  run: (input: z.infer<I>, ctx: Context) => unknown | Promise<unknown>;
23
29
  };
24
30
  export type Handler = (payload: any, ctx: Context) => unknown | Promise<unknown>;
31
+ /**
32
+ * Runs one of the plugin's methods on a schedule, called with `{}` as a tap
33
+ * would: `{ method: "watch", schedule: "*\/5 * * * *" }`. `schedule` is a cron,
34
+ * five fields or six with seconds first; `timezone` defaults to the environment's.
35
+ */
36
+ export type Cron = {
37
+ method: string;
38
+ schedule: string;
39
+ timezone?: string;
40
+ };
25
41
  export type PluginDefinition = {
26
42
  /** One line, shown to the agent in the index of plugins. */
27
43
  description?: string;
28
44
  /**
29
- * The data file the collections live in, `<db>.db` in the data dir; the
30
- * plugin's folder name when left out. A project's own data is `<project>.<name>`
31
- * (`repogo.plans`); a bare name (`todos`) is shared by every plugin that names it.
45
+ * Where the plugin's data lives: `<namespace>/` in the data dir, holding `data.db`
46
+ * and its buckets' and streams' files. Plugins naming the same namespace share its
47
+ * data. The plugin's folder name when left out; by convention a project's own is
48
+ * `<project>.<name>` (`repogo.plans`) and data for every project a bare name (`todos`).
32
49
  */
33
- db?: string;
50
+ namespace?: string;
34
51
  collections?: Collections;
52
+ /** Files with a `meta.json` each, in `<namespace>/buckets/<name>/`; the schema is the meta's. */
53
+ buckets?: Record<string, z.ZodObject>;
54
+ /** Keys to values, in the namespace's `data.db`; a key may expire. */
55
+ kv?: Record<string, z.ZodType>;
56
+ /** Append-only JSONL per key, in `<namespace>/streams/<name>/`, kept as `retain` says. */
57
+ streams?: Record<string, StreamDefinition>;
35
58
  methods?: Record<string, Method<any, any>>;
36
59
  on?: Record<string, Handler>;
60
+ crons?: Cron[];
37
61
  };
38
62
  export declare function definePlugin<T extends PluginDefinition>(plugin: T): T;
39
63
  export declare function method<I extends z.ZodType, O extends z.ZodType = z.ZodType>(input: I, run: (input: z.infer<I>, ctx: Context) => unknown | Promise<unknown>, options?: {
@@ -0,0 +1,3 @@
1
+ /** A length of time as a plugin writes it: milliseconds, or a number and a unit (`"30s"`, `"5m"`, `"1h"`, `"7d"`). */
2
+ export type Duration = number | `${number}${"s" | "m" | "h" | "d"}`;
3
+ export declare function parseDuration(duration: Duration): number;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  export { definePlugin, method, stream } from "./define";
2
- export type { PluginDefinition, Method, MethodKind } from "./define";
3
- export type { Context } from "./context";
2
+ export type { PluginDefinition, Method, MethodKind, Cron, StreamDefinition } from "./define";
3
+ export type { Context, Manifest, ManifestCron, ManifestMethod, ManifestStorage } from "./context";
4
+ export type { BucketFile, FileStatus, PutFile } from "./buckets";
5
+ export type { KvEntry, SetOptions } from "./kv";
6
+ export type { Retain, StreamEntry, StreamKey } from "./streams";
7
+ export type { Duration } from "./duration";
4
8
  export { MethodError, toCallError, type CallError } from "./errors";
5
9
  export { z } from "zod";
package/dist/kv.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { z } from "zod";
2
+ import { type Duration } from "./duration";
3
+ import { Store, type StoreOptions } from "./store";
4
+ /** One key and its value, as `get` sees it and the generated methods send it. */
5
+ export type KvEntry<T> = {
6
+ key: string;
7
+ value: T;
8
+ expiresAt: string | null;
9
+ createdAt: string;
10
+ updatedAt: string;
11
+ };
12
+ /** `ttl`: how long the key lives; setting a key without one keeps it until deleted. */
13
+ export type SetOptions = {
14
+ ttl?: Duration;
15
+ };
16
+ export declare class Kv<S extends z.ZodType> extends Store {
17
+ private schema;
18
+ private table;
19
+ constructor(options: StoreOptions, name: string, schema: S);
20
+ setup(): Promise<void>;
21
+ get(key: string): Promise<z.infer<S> | null>;
22
+ entry(key: string): Promise<KvEntry<z.infer<S>> | null>;
23
+ set(key: string, value: z.input<S>, options?: SetOptions): Promise<KvEntry<z.infer<S>>>;
24
+ delete(key: string): Promise<void>;
25
+ /** Live keys in key order, those starting with `prefix` when it's given. */
26
+ list(options?: {
27
+ prefix?: string;
28
+ limit?: number;
29
+ }): Promise<KvEntry<z.infer<S>>[]>;
30
+ tick(now: number): Promise<void>;
31
+ private live;
32
+ }
@@ -0,0 +1,32 @@
1
+ import type { Database } from "./database";
2
+ import type { EventBus } from "./events";
3
+ /** What every kv, stream and bucket is built with: its namespace's file and folder, and the runtime's bus. */
4
+ export type StoreOptions = {
5
+ database: Database;
6
+ events: EventBus;
7
+ namespace: string;
8
+ /** `<dataDir>/<namespace>`, the folder for what isn't rows. */
9
+ dir: string;
10
+ /** Tells cards a tag changed, for writes no call is tracking (the sweep, a scan). */
11
+ announce: (tags: string[]) => void;
12
+ };
13
+ export type StoreKind = "kv" | "streams" | "buckets";
14
+ export declare abstract class Store {
15
+ protected options: StoreOptions;
16
+ readonly kind: StoreKind;
17
+ readonly name: string;
18
+ readonly tag: string;
19
+ readonly plugins: Set<string>;
20
+ constructor(options: StoreOptions, kind: StoreKind, name: string);
21
+ /** Brings the store's files and table up; called once, when the first plugin declares it. */
22
+ abstract setup(): Promise<void>;
23
+ /** Called every few seconds by the Worker: expiry, retention, reconciling with the disk. */
24
+ abstract tick(now: number): Promise<void>;
25
+ close(): void;
26
+ protected get sql(): Bun.SQL;
27
+ protected emit(event: string, payload: unknown): Promise<void>;
28
+ }
29
+ /** A table name for a store: its kind and name, letters, digits and underscores only. */
30
+ export declare function tableName(kind: StoreKind, name: string): string;
31
+ /** A key or id that is also a file name: no slashes, no leading dot, at most 128 characters. */
32
+ export declare const safeName: RegExp;
@@ -0,0 +1,79 @@
1
+ import type { z } from "zod";
2
+ import { type Duration } from "./duration";
3
+ import { Store, type StoreOptions } from "./store";
4
+ /** One line of a stream. `id` is `<key>:<offset>`, for cards; on disk a line is `{ offset, at, data }`. */
5
+ export type StreamEntry<T> = {
6
+ id: string;
7
+ key: string;
8
+ offset: number;
9
+ at: string;
10
+ data: T;
11
+ };
12
+ /** One key of a stream: a run, a job, a day. */
13
+ export type StreamKey = {
14
+ key: string;
15
+ lines: number;
16
+ bytes: number;
17
+ endedAt: string | null;
18
+ updatedAt: string;
19
+ };
20
+ /**
21
+ * How long a stream keeps what it was sent: a key untouched for `age` goes
22
+ * whole, and past `bytes` its oldest segments go. `"forever"` keeps everything.
23
+ */
24
+ export type Retain = {
25
+ age?: Duration;
26
+ bytes?: number;
27
+ } | "forever";
28
+ export declare const defaultRetain: {
29
+ readonly age: "7d";
30
+ readonly bytes: 50000000;
31
+ };
32
+ export declare class Stream<S extends z.ZodType> extends Store {
33
+ private schema;
34
+ private table;
35
+ private folder;
36
+ private ageMs;
37
+ private maxBytes;
38
+ private lastRetain;
39
+ /** Followers in this Worker waiting on a key's next line. */
40
+ private waiting;
41
+ constructor(options: StoreOptions, name: string, schema: S, retain?: Retain);
42
+ /** The stream's folder, for agents to `tail`. */
43
+ get path(): string;
44
+ setup(): Promise<void>;
45
+ append(key: string, data: z.input<S>): Promise<StreamEntry<z.infer<S>>>;
46
+ /** Lines from `from` on (the oldest kept when it's older), at most `limit`. */
47
+ read(key: string, options?: {
48
+ from?: number;
49
+ limit?: number;
50
+ }): Promise<StreamEntry<z.infer<S>>[]>;
51
+ /**
52
+ * Replays from `from`, then yields each line as it's appended, until the key
53
+ * ends or `signal` aborts. Defaults to the last `last` lines (100).
54
+ */
55
+ follow(key: string, options: {
56
+ from?: number;
57
+ last?: number;
58
+ signal: AbortSignal;
59
+ }): AsyncGenerator<StreamEntry<z.core.output<S>>, void, unknown>;
60
+ /** Marks a key finished: followers stop, appends fail, and it goes once it's older than the retention age. */
61
+ end(key: string): Promise<void>;
62
+ keys(options?: {
63
+ limit?: number;
64
+ }): Promise<StreamKey[]>;
65
+ delete(key: string): Promise<void>;
66
+ tick(now: number): Promise<void>;
67
+ close(): void;
68
+ private retain;
69
+ private lines;
70
+ private cutUncommitted;
71
+ private start;
72
+ private row;
73
+ /** A key's segment files by first offset, oldest first. */
74
+ private segments;
75
+ private segmentPath;
76
+ /** Settles on this Worker's next append to `key`, after a poll interval for other Workers', or on abort. */
77
+ private nextAppend;
78
+ private wake;
79
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@repogo/plugins",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Write RepoGo plugins: collections and methods over SQLite that agents and cards call",
5
5
  "type": "module",
6
6
  "exports": {