@repogo/plugins 0.1.34 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +380 -26
  2. package/dist/cache.d.ts +27 -0
  3. package/dist/collections.d.ts +11 -1
  4. package/dist/context.d.ts +117 -1
  5. package/dist/cron.d.ts +21 -0
  6. package/dist/define.d.ts +36 -11
  7. package/dist/generated.d.ts +16 -0
  8. package/dist/index.d.ts +10 -3
  9. package/dist/index.js +100 -5
  10. package/dist/logs.d.ts +55 -0
  11. package/dist/runtime.d.ts +173 -0
  12. package/dist/self.d.ts +1 -0
  13. package/dist/testing.d.ts +53 -0
  14. package/dist/testing.js +3492 -0
  15. package/dist/tracking.d.ts +5 -0
  16. package/dist/uploads.d.ts +24 -0
  17. package/dist/workflow/attempts.d.ts +25 -0
  18. package/dist/workflow/availability.d.ts +8 -0
  19. package/dist/workflow/catalog.d.ts +21 -0
  20. package/dist/workflow/clock.d.ts +6 -0
  21. package/dist/workflow/contracts.d.ts +121 -0
  22. package/dist/workflow/definition.d.ts +35 -0
  23. package/dist/workflow/errors.d.ts +19 -0
  24. package/dist/workflow/events.d.ts +132 -0
  25. package/dist/workflow/interrupted.d.ts +3 -0
  26. package/dist/workflow/journal.d.ts +28 -0
  27. package/dist/workflow/lease.d.ts +2 -0
  28. package/dist/workflow/lifetime.d.ts +27 -0
  29. package/dist/workflow/policy.d.ts +36 -0
  30. package/dist/workflow/register.d.ts +11 -0
  31. package/dist/workflow/retry.d.ts +14 -0
  32. package/dist/workflow/runner.d.ts +29 -0
  33. package/dist/workflow/scope.d.ts +4 -0
  34. package/dist/workflow/service.d.ts +48 -0
  35. package/dist/workflow/sleep.d.ts +6 -0
  36. package/dist/workflow/state.d.ts +41 -0
  37. package/dist/workflow/storage.d.ts +22 -0
  38. package/dist/workflow/summary.d.ts +80 -0
  39. package/dist/workflow/testing.d.ts +12 -0
  40. package/dist/workflow-executor.js +22917 -0
  41. package/package.json +7 -3
package/README.md CHANGED
@@ -7,13 +7,16 @@ phone draw its data live and run its methods when you tap.
7
7
 
8
8
  - [Quick start](#quick-start)
9
9
  - [Where plugins live](#where-plugins-live)
10
- - [Methods](#methods): queries, mutations, streams
11
- - [Data](#data): namespaces, collections, kv, streams, buckets
10
+ - [Methods](#methods): queries, mutations, streams, cached queries
11
+ - [Data](#data): namespaces, collections, kv, streams, buckets, uploads
12
+ - [Workflows](#workflows)
12
13
  - [Crons](#crons)
14
+ - [Logging](#logging)
13
15
  - [The context](#the-context)
14
16
  - [Events](#events)
15
17
  - [How cards and agents reach a plugin](#how-cards-and-agents-reach-a-plugin)
16
18
  - [Errors and confirmations](#errors-and-confirmations)
19
+ - [Testing](#testing)
17
20
 
18
21
  ## Quick start
19
22
 
@@ -62,9 +65,9 @@ project plugin hides an environment one of the same name. Each folder's plugins
62
65
  run together in one Worker, so a plugin's blocking code stalls the others in its
63
66
  folder: use async APIs.
64
67
 
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
+ Pages beside an environment plugin (`pages/*.ui`, or `pages/*.md`) open from
69
+ any project as `~/.repogo/plugins/<name>/pages/<page>.ui`. A
70
+ `.ui` page is card markup with no fence; a `.md` page is writing with cards.
68
71
 
69
72
  ## Methods
70
73
 
@@ -88,6 +91,18 @@ show it and keep it current. Give each a `description`; agents read it to choose
88
91
  a method. `output`, a zod schema of the result, tells agents and cards its
89
92
  field names.
90
93
 
94
+ A query that returns a list takes `limit`, with a default and a maximum in its
95
+ schema, and returns only what a card or the voice agent shows of each item:
96
+ ids, titles, status. A `get` returns one in full, and long text (a body, a log)
97
+ is cut short in the list. Answers stay small, so cards draw fast and an agent
98
+ or the voice agent reads the whole of one.
99
+
100
+ ```ts
101
+ open: method(z.object({ limit: z.number().int().min(1).max(100).default(20) }), async ({ limit }, ctx) =>
102
+ (await ctx.collections.notes.list({ where: { done: false }, limit })).map(({ id, text }) => ({ id, text: text.slice(0, 200) })),
103
+ { kind: "query", description: "Notes not done yet, newest first" }),
104
+ ```
105
+
91
106
  A **stream** sends each `yield` to the card as it happens, for as long as the
92
107
  card shows it. `ctx.signal` aborts when the card goes, so hand it to what you
93
108
  start. Give items an `id`. An agent calling a stream gets what arrives within
@@ -105,6 +120,27 @@ tail: stream(z.object({ file: z.string() }), async function* ({ file }, ctx) {
105
120
  }),
106
121
  ```
107
122
 
123
+ A **cached** query is for answers that are slow to get, such as a CLI's list
124
+ or another service's API. It answers at once with what it saved last time.
125
+ When that answer is older than `fresh`, `run` goes again in the background,
126
+ and a card showing it redraws only if the answer changed. With nothing saved
127
+ yet, the first call waits for `run`, and its error is the card's.
128
+
129
+ ```ts
130
+ projects: cached(z.object({ limit: z.number().int().min(1).max(200).default(100) }),
131
+ async ({ limit }) => listProjects(limit),
132
+ { fresh: "1m", shared: true, description: "Every project the login can see" }),
133
+ ```
134
+
135
+ - Answers are kept per method, input and project (`ctx.root`). `shared: true`
136
+ keeps one for every project, for answers that don't depend on it.
137
+ - `run` is read-only, like a query's, and has `timeout` (a minute unless it says).
138
+ - A background check that fails keeps the saved answer, goes to the plugin's
139
+ logs, and is tried again after another `fresh`.
140
+ - Answers live in the namespace's `data.db`, so a plugin with a cached method
141
+ needs a `namespace`. An answer over 1 MB is returned but not saved, and one
142
+ nobody has asked for in 30 days is deleted.
143
+
108
144
  ## Data
109
145
 
110
146
  A plugin declares its data in four kinds, each with a schema, and gets typed
@@ -125,8 +161,9 @@ plugin, another plugin, or another project.
125
161
 
126
162
  `namespace` names where a plugin's data lives: the folder
127
163
  `~/.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.
164
+ buckets' files. A plugin that keeps data (any collection, kv, stream or bucket)
165
+ must name one, or it fails to load. Plugins naming the same namespace share its
166
+ data, in any project, so they must declare the same schemas.
130
167
 
131
168
  By convention a project's own data is `<project>.<name>` (`myproject.notes`),
132
169
  and data meant for every project a bare name (`todos`). That's only a
@@ -145,10 +182,14 @@ await ctx.collections.tasks.get(task.id); // or n
145
182
  await ctx.collections.tasks.update(task.id, { status: "done" }); // only the fields given change
146
183
  await ctx.collections.tasks.list({ where: { status: "open" }, orderBy: "updatedAt", order: "desc", limit: 50 });
147
184
  await ctx.collections.tasks.delete(task.id);
185
+ await ctx.collections.tasks.deleteMany({ where: { status: "done" }, offset: 100, limit: 500 }); // done ones past the newest 100, 500 at a time
148
186
  ```
149
187
 
150
188
  `list` is newest first by default, at most 100 unless `limit` says (up to 500).
151
189
  `where` matches top-level fields exactly; `null` matches a missing one.
190
+ `orderBy` is `id`, `createdAt`, `updatedAt` or one of the record's own fields, and
191
+ `offset` passes over that many first. `deleteMany` takes the same options and
192
+ deletes exactly what `list` would return, in one transaction, returning how many.
152
193
 
153
194
  ### Kv
154
195
 
@@ -248,6 +289,37 @@ checks it against the schema; with `revision`, it fails if the file changed
248
289
  since you read it. Writes through `ctx` are checked; files written by hand are
249
290
  indexed with their `status` instead.
250
291
 
292
+ ### Uploads
293
+
294
+ A card's `<Upload>` lets the user take a photo, record a video, or pick
295
+ photos or files on their phone, and hands them to one of your methods as
296
+ `files`. Declare the field with `upload()`:
297
+
298
+ ```ts
299
+ import { definePlugin, method, upload, z } from "@repogo/plugins";
300
+
301
+ ingest: method(z.object({ files: z.array(upload()), month: z.string() }), async ({ files, month }, ctx) => {
302
+ for (const file of files) {
303
+ await ctx.buckets.receipts.put({ body: Bun.file(file.path), name: file.name, folder: month });
304
+ }
305
+ return { added: files.length };
306
+ }),
307
+ ```
308
+
309
+ Each file is `{ id, name, type, size, path }`. `type` is its MIME type, and
310
+ `path` is where the host staged it on the environment. The phone sends the
311
+ bytes before the call, encrypted like every call, and the call starts once
312
+ they've all arrived. A staged file is deleted a day after its last use, so
313
+ copy what you keep: into a bucket, as above, or wherever you like. `files` is
314
+ always an array, even for one file.
315
+
316
+ `upload()` accepts only files the host attached to the running call. Input
317
+ written by hand, such as an agent's `plugin_call` with a made-up `path`, fails
318
+ validation, and so does a call that sends its own `files` alongside the
319
+ upload's. A method the upload's method calls with `ctx.call` may pass the
320
+ files on. In tests, `app.call("receipts.ingest", { month: "may" }, { files })`
321
+ stands in for the host.
322
+
251
323
  ### Generated methods
252
324
 
253
325
  Every collection, kv, stream and bucket gets methods named
@@ -265,28 +337,198 @@ A bucket's `put` takes `text`, or `from`, an absolute path on the environment to
265
337
  copy: bytes never travel in a call. A method of your own with the same name as a
266
338
  generated one fails the plugin's load.
267
339
 
268
- ## Crons
340
+ Data named in `private` gets none of these, so cards and agents reach it only
341
+ through the plugin's own methods: `private: ["actions", "lines"]` for an audit
342
+ log no tap can edit. It hides methods; it isn't a lock. Another plugin naming
343
+ the same namespace and store can still read and write it.
344
+
345
+ ## Workflows
346
+
347
+ > **Experimental.** Workflows are new and may change in ways that need edits
348
+ > to your plugins: their methods, statuses and options aren't settled yet.
349
+
350
+ A workflow keeps progress across host restarts. Define it beside `methods`;
351
+ `workflow(input, run, options)` follows the same shape as `method`:
269
352
 
270
353
  ```ts
354
+ import { definePlugin, workflow, z } from "@repogo/plugins";
355
+
271
356
  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
- ],
357
+ namespace: "myproject.reports",
358
+ workflows: {
359
+ report: workflow(z.object({ url: z.url() }), async ({ url }, ctx) => {
360
+ const report = await ctx.step.run("fetch", async ({ signal }) => {
361
+ const response = await fetch(url, { signal });
362
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
363
+ return { title: await response.text() };
364
+ }, { kind: "query", attempts: 3, timeout: "30s" });
365
+
366
+ await ctx.sleep("5m");
367
+
368
+ return await ctx.step.run("save", async () => {
369
+ await ctx.kv.reports.set("latest", report);
370
+ return report;
371
+ });
372
+ }, { timeout: "30d", maxOffline: "14d", retention: "30d" }),
373
+ },
374
+ kv: { reports: z.object({ title: z.string() }) },
277
375
  });
278
376
  ```
279
377
 
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.
378
+ For a plugin folder named `reports`, the host generates:
379
+
380
+ - `reports.report.list({ limit?, status?, cursor? })`: bounded summaries and a next cursor.
381
+ - `reports.report.get({ id })`: one summary, input, result, error and the latest 20 operations (steps and sleeps).
382
+ - `reports.report.start({ input, key? })`: persist and queue a run; returns its summary immediately.
383
+ - `reports.report.cancel({ id })`: persist cancellation intent and abort active work.
384
+ - `reports.report.cancelAll({ status? })`: cancel every unfinished run in the project, or only those in one status; returns `{ cancelled }`.
385
+ - `reports.report.retry({ id })`: retry a safely failed query step.
386
+ - `reports.report.restart({ id })`: start a run that needs attention over from the beginning, on the current code.
387
+
388
+ These use the existing plugin methods, `ctx.call`, cards and subscriptions.
389
+ The namespace is required. `confirm` on the workflow confirms its start;
390
+ it does not preapprove later host actions. An unattended workflow refuses
391
+ confirmation-gated plugin methods. The calling project and caller
392
+ are preserved. A start `key` deduplicates within that project, plugin and
393
+ workflow while its run is retained; reusing it with different input fails.
394
+
395
+ Await each step and use a unique, stable name. Keep effects inside step callbacks:
396
+ the handler runs from the beginning after recovery, and completed callbacks
397
+ return their recorded JSON instead of running again. Steps are sequential;
398
+ nesting, overlap and reusing a name fail the run. Return JSON, including an
399
+ explicit `null` for a step with no result. Large payloads belong in buckets;
400
+ a journal record is limited to 1 MiB, a run journal to 64 MiB. `get` omits
401
+ input or final results above 16 KiB and sets their truncation flags.
402
+
403
+ Use `await ctx.sleep("5m")` between steps for a durable wait. No ID is needed:
404
+ the runner identifies each sleep by its position among the workflow's operations.
405
+ It saves an absolute wake time, releases the executor, and resumes without
406
+ repeating completed steps. Loops and consecutive sleeps each get a separate
407
+ position. The first recorded duration wins on replay, even when the expression
408
+ that supplied it later changes. Durations follow the SDK convention (strings
409
+ like `"5m"` or milliseconds), must be positive, and cannot exceed 365 days.
410
+
411
+ Always await sleep at the workflow level, outside step callbacks; do not catch
412
+ its internal waiting signal or detach background workflow operations. Branch
413
+ using recorded step results so replay follows the same operation order.
414
+ The original run timeout and `maxOffline` still apply during sleep. A sleep
415
+ past the run deadline expires at that deadline; an unavailable environment
416
+ resumes only when available and within those limits. `list`/`get` show
417
+ `waiting` with `nextAttemptAt` as the wake time, and detail rows identify sleeps
418
+ with `kind: "sleep"`. Terminal runs have no pending wake time.
419
+
420
+ `attempts` counts the first invocation and defaults to 1. Only steps declared
421
+ `kind: "query"` may automatically retry; they cannot write SDK data or call
422
+ mutations. Within query steps, use `ctx.mcp` for read-aware host access;
423
+ generic `ctx.host`, notifications and agent starts are refused. Query backoff defaults to full jitter with initial `2s`, factor 2
424
+ and maximum `1m`. Import `NonRetryable` and throw it for a permanent failure.
425
+ Step `timeout` defaults to `5m`. The callback receives `signal`, `attempt`,
426
+ `attempts`, `runId`, `stepId` and an `idempotencyKey` stable across its attempts.
427
+ Pass its signal to fetch or subprocess APIs. A timeout aborts cooperatively;
428
+ a callback must settle before another attempt can begin.
429
+
430
+ Mutation steps get one attempt. An interrupted or timed-out mutation becomes
431
+ `unknown`: its external effect may have happened, and it is never automatically
432
+ repeated. Reconcile that effect before starting new work. Cancellation cannot
433
+ roll back remote effects. Manual retry only extends the budget of a failed
434
+ query, keeps its completed prefix and original lifetime, and rejects cancelled,
435
+ expired, unknown, permanent-failure and invalid-result runs.
436
+
437
+ ### When you edit a workflow
438
+
439
+ A run always executes the plugin's current code, loaded from its folder as a
440
+ method call is; nothing is frozen at start. When a run resumes after an edit,
441
+ its finished steps still return their recorded results, so:
442
+
443
+ - An edit that keeps the recorded steps in the same order, such as a fix
444
+ inside a step or a new step at the end, carries on with the new code.
445
+ - An edit the recorded steps can't follow, such as a renamed, removed or
446
+ reordered step, stops the run in `attention`: not finished, and not run
447
+ again on its own. Its `error` says which step it expected. `restart` starts
448
+ it over on the current code, running finished steps again, with the same id,
449
+ key and input and a fresh `timeout`. `cancel` or
450
+ `cancelAll({ status: "attention" })` drops it.
451
+
452
+ Nested workflow starts from inside a run are not supported.
453
+
454
+ The host resumes work without an open phone or page. `timeout` is a wall-clock
455
+ run lifetime, including waits. `maxOffline` expires work when the environment
456
+ or workflow service has been unavailable for that long. A persistent network
457
+ outage is also tracked when the environment has no nonlocal network address;
458
+ this passive check does not probe external services or prove internet access.
459
+ Individual API failures use the step's retry budget. The host records expiry on its next wake.
460
+ Shutdown suspends work without recording user cancellation. A finished run's
461
+ journal stays until its `retention` passes. Admission refuses new runs above
462
+ its storage budget rather than evicting active history. Missing or corrupt
463
+ journals block execution. The default environment-wide budget is 512 MiB.
464
+ Each unfinished, blocked or attention run reserves 130 MiB for its journal and
465
+ one snapshot of its state, including while sleeping. This admits at most three
466
+ unfinished runs, fewer when retained history consumes the remaining space;
467
+ admission reports storage full. Checkpoint replacement temporarily keeps both
468
+ snapshots, so peak disk use can exceed that reservation. The admission budget
469
+ is not a free-disk guarantee.
470
+
471
+ For integration tests, import `testWorkflowRuntime` from
472
+ `@repogo/plugins/testing` and pass the fixture's plugins directory. It uses real
473
+ temporary storage and a subprocess per run, as the host does. Call the same lifecycle methods,
474
+ use `await app.wait("reports.report", id)` to await a terminal/blocked run, and
475
+ `await app.close()` afterward. The in-memory `testRuntime({ plugin })` helper
476
+ does not execute durable workflows.
477
+
478
+
479
+ ## Crons
480
+
481
+ What runs with no one looking is listed in one file the user owns,
482
+ `~/.repogo/crons.json`, not in a plugin's code: a repo you clone can't schedule
483
+ itself. Each line names a plugin, one of its methods and a schedule:
484
+
485
+ ```json
486
+ [
487
+ { "plugin": "incidents", "method": "watch", "schedule": "*/5 * * * *" },
488
+ { "project": "~/code/shop", "plugin": "backup", "method": "run", "schedule": "0 3 * * *", "timezone": "America/New_York" },
489
+ { "plugin": "digest", "method": "send", "schedule": "0 9 * * 1-5", "enabled": false }
490
+ ]
491
+ ```
492
+
493
+ | Field | What |
494
+ | --- | --- |
495
+ | `plugin`, `method` | The plugin's folder name and one of its methods, called with `{}` as a tap would |
496
+ | `project` | The project whose `.repogo/plugins` holds it, an absolute path or one starting `~/`; left out, `~/.repogo/plugins` |
497
+ | `schedule` | A cron: five fields (minute hour day-of-month month day-of-week), or six with seconds first (`*/5 * * * * *`); each `*`, a number, a range, a list or a step |
498
+ | `timezone` | Defaults to the environment's |
499
+ | `enabled` | `false` keeps the line without running it |
500
+
501
+ The host reads the file every minute, and again when the agent's `plugins` tool
502
+ lists what's there, so an edit shows at once. A folder opened only for its
503
+ schedules imports just the plugins they name; the others beside them don't run.
504
+ A line removed or disabled is never started again; a run already going finishes.
284
505
 
285
506
  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`.
507
+ is skipped, and nothing is retried. A line naming something missing, a stream, a
508
+ method that needs input or asks first, a bad schedule, or the same method twice
509
+ says why in `plugins` (each line's `error`), and the others run. A file that
510
+ isn't valid JSON pauses every line until it's fixed. Each line also shows
511
+ `nextRunAt`, `lastRunAt`, `lastError`, `lastDurationMs` and `runs`; `ctx.plugins()`
512
+ has the same for a plugin's own, and a query reading them re-runs around each run.
513
+
514
+ Schedule something only when the user asks for it. A cron's `ctx.root` is its
515
+ project, or the home folder for a plugin in `~/.repogo/plugins`. Plugins in one
516
+ folder share a thread: code that blocks it holds up every other cron there, and
517
+ the host's log names the crons that were running when it happened.
518
+
519
+ ## Logging
520
+
521
+ Write `console.log` (or `warn`, `error`, `debug`); nothing else is needed. The
522
+ host keeps every line with the plugin, the method and the call that wrote it,
523
+ who called (`card`, `agent`, `cron`), and the chat for an agent's call, plus
524
+ the arguments as JSON. An error a method throws, or one nothing caught, is
525
+ kept too, with its stack. Read them with the agent's `plugin_logs` tool, or on
526
+ a card with `<Data method="repogo:plugins.logs">`, which reloads as lines land:
527
+ `plugin`, `level` (that level and worse), `method`, `since`, `until`, `query`
528
+ (a substring) and `limit` narrow it. The `plugins` tool shows each plugin's
529
+ `errors` in the last hour. Lines older than 7 days go, and the oldest hours of
530
+ the noisiest plugin once the log passes 2 GB. A line is cut at 8 KB: a payload
531
+ belongs in the plugin's own data, not its log.
290
532
 
291
533
  ## The context
292
534
 
@@ -295,18 +537,104 @@ Every method, handler and cron gets `ctx`:
295
537
  | Field | What |
296
538
  | --- | --- |
297
539
  | `plugin` | The plugin's name |
540
+ | `caller` | Who started the call: `card`, `agent`, `cron`, or `code` |
298
541
  | `root` | The calling project's checkout |
299
542
  | `collections`, `kv`, `streams`, `buckets` | The plugin's data |
300
543
  | `call(method, input)` | Runs a method of a plugin in the same folder; `"open"` is this plugin's own |
301
544
  | `plugins()` | The plugins in the same folder, with their methods |
302
545
  | `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 |
546
+ | `mcp(server, tool, input)` | Runs a tool of an MCP server the user connected in RepoGo and switched on for the calling project; see below |
547
+ | `notify({ title, body, page?, url?, collapse? })` | Alerts every paired phone; a tap opens `url` (an `https` or `itms-services` link) if set, else `page`, and alerts sharing `collapse` replace each other |
548
+ | `activity({ id, title, body?, page?, symbol?, tint?, label?, compactLabel?, progress?, timer?, status? })` | Shows or updates a Live Activity on every paired phone; see below |
549
+ | `agent(prompt, { agent, project?, title?, timeout? })` | Starts a chat with `prompt` and waits for the agent's whole answer: `{ chatId, text }`; see below |
304
550
  | `emit(event, payload)` | Sends `<plugin>.<event>` to handlers |
305
551
  | `signal` | Aborts when a stream's card goes |
306
- | `log(...)` | Writes to the host's plugin log |
552
+ | `log(...)` | The same as `console.log` |
307
553
 
308
554
  A query doesn't re-run when what a `ctx.host` method read changes.
309
555
 
556
+ ### An agent's answer
557
+
558
+ `ctx.agent` hands a question to Claude or Codex and answers with what it said,
559
+ so a cron can watch, investigate and notify with the finding:
560
+
561
+ ```ts
562
+ watch: method(z.object({}), async (_, ctx) => {
563
+ const down = await failing(ctx);
564
+ if (!down.length) return null;
565
+ const { text, chatId } = await ctx.agent(`Say in two sentences why ${down[0]} is down.`, { agent: "claude" });
566
+ await ctx.notify({ title: `${down[0]} is down`, body: text, page: `chat:${chatId}` });
567
+ return text;
568
+ }),
569
+ ```
570
+
571
+ The chat runs in `project` (the calling project unless said), under the
572
+ calling chat when an agent called the plugin. It waits up to `timeout`
573
+ (10 minutes unless said) and then throws, naming the chat, which keeps
574
+ running. The same two host methods are there by hand: `ctx.host("chats.start", …)`
575
+ and `ctx.host("chats.wait", …)`, for a chat you want to link to rather than wait for.
576
+
577
+ ### Live Activities
578
+
579
+ `ctx.activity` puts a card on the Lock Screen and in the Dynamic Island.
580
+ Its `title`, `body` and `page` are a notice's, and the plugin sets
581
+ everything else too. Send the whole state each time: the same `id` updates
582
+ it, a field left out takes its default, and `status: "done"` or `"failed"`
583
+ ends it with that state, left up for 15 minutes:
584
+
585
+ ```ts
586
+ deploy: method(z.object({ project: z.string() }), async ({ project }, ctx) => {
587
+ const steps = ["Build", "Test", "Deploy", "Verify"];
588
+ const page = `.repogo/tabs/deploys.ui?project=${project}`;
589
+ const show = (current: number, body: string) =>
590
+ ctx.activity({ id: `deploy:${project}`, title: `Deploying ${project}`, body, page,
591
+ symbol: "shippingbox", progress: { kind: "steps", steps, current } });
592
+ await show(0, "Building");
593
+ // …
594
+ await ctx.activity({ id: `deploy:${project}`, title: `${project} is live`, body: "web.app", page,
595
+ status: "done", symbol: "globe", tint: "blue", label: "Live",
596
+ progress: { kind: "steps", steps, current: steps.length } });
597
+ }),
598
+ ```
599
+
600
+ | Field | Unless said |
601
+ | --- | --- |
602
+ | `body` | No line under the title |
603
+ | `page` | A tap opens the app |
604
+ | `symbol` | `circle.dotted`; a check once done, a cross once failed |
605
+ | `tint` | White; green once done, red once failed |
606
+ | `label` | "2 of 4", "40%", "Done" or "Failed"; `""` hides it |
607
+ | `compactLabel` | The `label`, else "2/4" or "40%" |
608
+ | `progress` | None. `{ kind: "steps", steps, current }` draws a segment per step, the ones before `current` complete; `{ kind: "bar", value }` a bar from 0 to 1 |
609
+ | `timer` | `{ kind: "elapsed" }` from the first start. `{ kind: "countdown", endsAt }` counts down to a Unix ms time and ends nothing; `{ kind: "none" }` hides it |
610
+
611
+ - The phones get the latest state at most every 5 seconds; a start and an end go at once.
612
+ - A state that comes to more than Apple's 4 KB as a push is refused.
613
+ - A plugin shows at most two at once; one left without an update for an hour stops counting.
614
+ - A query can't call it, as it can't write.
615
+ - The text reaches Apple and RepoGo's relay as is, as a `notify` alert's does: keep secrets out of it.
616
+
617
+ ### MCP servers
618
+
619
+ `ctx.mcp` reaches the MCP servers the user connected in RepoGo, by the key
620
+ cards name them with, using the user's own sign-in, so a plugin needs no key
621
+ or CLI of its own. It answers the tool's structured content, else its text
622
+ (parsed when it's JSON):
623
+
624
+ ```ts
625
+ customers: method(z.object({}), async (_, ctx) => {
626
+ const list = await ctx.mcp("stripe", "stripe_api_read", { stripe_api_operation_id: "GetCustomers", /* … */ });
627
+ return list.data.map((c) => ({ id: c.id, email: c.email }));
628
+ }, { kind: "query" }),
629
+ ```
630
+
631
+ A query may run only the server's read-only tools, as a card's `Data` may. A
632
+ tool marked destructive runs only from a method with a `confirm`, or one
633
+ called from it with `ctx.call`: the user's answer to that question is its
634
+ yes. A refusal throws `FORBIDDEN`; a tool's own error throws `INTERNAL` with
635
+ its message. Only servers switched on for `ctx.root` answer, so a cron of an
636
+ environment plugin, whose root is the home folder, reaches none.
637
+
310
638
  ## Events
311
639
 
312
640
  Handlers in `on` run after the events they name:
@@ -330,8 +658,12 @@ Events run in memory: none is retried, and a handler that throws is logged.
330
658
 
331
659
  ## How cards and agents reach a plugin
332
660
 
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
661
+ **Agents** use two tools. `plugins` lists every plugin with a line per method
662
+ (its kind, whether it asks first, and its input's fields, optional ones ending
663
+ in `?`), and every line of `~/.repogo/crons.json` with how it's going.
664
+ `plugins` with `plugin` set answers that plugin in full: each method's input
665
+ and output JSON Schema and confirm, its `storage` (each bucket's and stream's
666
+ folder), the pages that show its methods, and the cron lines naming it.
335
667
  `plugin_call` runs one. Call a new method once before a card names it.
336
668
 
337
669
  **Cards** name methods as `plugin:<plugin>.<method>`. A `Data` element runs a
@@ -359,4 +691,26 @@ schema answers `VALIDATION` with a message per field. Anything else thrown is
359
691
  A method's `confirm` is asked before a card's tap runs it. An agent's
360
692
  `plugin_call` gets it back as a `CONFIRM` error until it calls again with
361
693
  `confirmed: true`, once the user agrees. Only `ctx.call` from plugin code skips
362
- it. Give one to anything that can't be undone.
694
+ it. Give one to anything that can't be undone. `{{field}}` in the question is
695
+ filled from the call's input, so it can say what it acts on:
696
+ `confirm: "Restart {{machine}}?"`.
697
+
698
+ ## Testing
699
+
700
+ `@repogo/plugins/testing` runs plugins as the host does, with real data in a
701
+ temporary folder; `host` and `mcp` stand in for the host's methods and the
702
+ user's MCP servers:
703
+
704
+ ```ts
705
+ import { testRuntime } from "@repogo/plugins/testing";
706
+ import notes from "./app";
707
+
708
+ const app = await testRuntime({ notes });
709
+ await app.call("notes.add", { text: "hi" });
710
+ await app.call("notes.clear", {}, { confirmed: false }); // throws CONFIRM, as an unanswered tap
711
+ app.setCrons([{ plugin: "notes", method: "tidy", schedule: "* * * * *" }]); // as a crons.json line would
712
+ await app.tickCrons(Date.now() + 60_000); // runs whatever is due by then
713
+ app.notices; // every ctx.notify so far
714
+ app.logs; // every console line so far, with its method and caller
715
+ await app.close();
716
+ ```
@@ -0,0 +1,27 @@
1
+ import type { Database } from "./database";
2
+ /** How a `cached` method keeps its answers: fresh for this long, shared by every project or not. */
3
+ export type CacheOptions = {
4
+ fresh: number;
5
+ shared: boolean;
6
+ timeout: number;
7
+ };
8
+ export declare class AnswerCache {
9
+ private database;
10
+ private namespace;
11
+ private announce;
12
+ /** One fetch per key at a time; a card and a background check share it. */
13
+ private inflight;
14
+ constructor(database: Database, namespace: string, announce: (tags: string[]) => void);
15
+ setup(): Promise<void>;
16
+ /**
17
+ * The saved answer at once, checked again in the background when it's
18
+ * older than `fresh`. With none saved, the caller waits for the fetch.
19
+ */
20
+ read(key: string, options: CacheOptions, fetch: () => unknown): Promise<unknown>;
21
+ /** Settles once no fetch is out. */
22
+ idle(): Promise<void>;
23
+ private refresh;
24
+ private save;
25
+ }
26
+ /** A key for one answer: the method, the project unless it's shared, and the input with its keys sorted. */
27
+ export declare function cacheKey(method: string, root: string | null, input: unknown): string;
@@ -8,9 +8,12 @@ export type Record_<T> = T & {
8
8
  };
9
9
  export type ListOptions = {
10
10
  where?: Record<string, string | number | boolean | null>;
11
- orderBy?: "createdAt" | "updatedAt";
11
+ /** `id`, `createdAt`, `updatedAt`, or one of the record's own top-level fields. */
12
+ orderBy?: "createdAt" | "updatedAt" | (string & {});
12
13
  order?: "asc" | "desc";
13
14
  limit?: number;
15
+ /** Records to pass over first, in the same order: `offset: 300` is everything past the newest 300. */
16
+ offset?: number;
14
17
  };
15
18
  export declare class Collection<S extends z.ZodObject> {
16
19
  private database;
@@ -28,6 +31,13 @@ export declare class Collection<S extends z.ZodObject> {
28
31
  update(id: string, patch: Partial<z.input<S>>): Promise<Record_<z.infer<S>>>;
29
32
  delete(id: string): Promise<void>;
30
33
  list(options?: ListOptions): Promise<Record_<z.infer<S>>[]>;
34
+ /**
35
+ * Deletes the records `list(options)` would return, with its same defaults,
36
+ * in one transaction, and returns how many. Each emits its `deleted` event.
37
+ */
38
+ deleteMany(options?: ListOptions): Promise<number>;
39
+ /** The query `list` and `deleteMany` share, so both pick out the same records. */
40
+ private select;
31
41
  private eventName;
32
42
  private toRecord;
33
43
  }