@4fu/arbor-tools 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1060 @@
1
+ /**
2
+ * The code mode modules: `journal`, `windows`, `board`, `notes`, `channels`
3
+ * and `workspace` (docs/tools.md#modules).
4
+ *
5
+ * A module whose backing feature is not configured is absent. The prompt names
6
+ * a module, never a function; how to call one comes from `tool_search` and
7
+ * `tool_describe`, which read the same catalog the window was opened with.
8
+ * `env`, `services` and the MCP servers' own modules are in `./env.ts`.
9
+ */
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { isAbsolute, join, normalize, resolve } from "node:path";
12
+ import { formatTime, textOf } from "@4fu/arbor-prompt";
13
+ import { moduleSpec, refused } from "../host.js";
14
+ import { envCommand } from "../env-stage.js";
15
+ /** `journal`: search, show, around, trace, links. */
16
+ export class JournalModuleGroup {
17
+ id = "modules.journal";
18
+ services;
19
+ constructor(services) {
20
+ this.services = services;
21
+ }
22
+ tools() {
23
+ return [];
24
+ }
25
+ modules() {
26
+ return [
27
+ moduleSpec(this.services.table, "journal", [
28
+ { name: "journal.search", signature: "journal.search(query, options?)" },
29
+ { name: "journal.show", signature: "journal.show(ref)" },
30
+ { name: "journal.around", signature: "journal.around(ref, before?, after?)" },
31
+ { name: "journal.trace", signature: "journal.trace(ref)" },
32
+ { name: "journal.links", signature: "journal.links(ref, offset?)" },
33
+ ]),
34
+ ];
35
+ }
36
+ async call(request, _context) {
37
+ const journal = this.services.journal;
38
+ const args = request.args;
39
+ switch (request.name) {
40
+ case "journal.search": {
41
+ const page = await journal.search(stringArg(args, "query"), {
42
+ ...(numberArg(args, "limit") === undefined ? {} : { limit: numberArg(args, "limit") }),
43
+ });
44
+ return this.answer(request, page.items.map((hit) => `${hit.ref} ${hit.kind} ${hit.summary}`), page);
45
+ }
46
+ case "journal.show": {
47
+ const refs = eventRefsArg(args, "ref");
48
+ const events = await journal.getMany(refs);
49
+ return this.answer(request, events.map((event) => `${event.ref} ${event.kind} ${bodyOf(event)}`), {
50
+ items: events,
51
+ total: events.length,
52
+ limit: events.length,
53
+ nextCall: "journal.show(ref)",
54
+ });
55
+ }
56
+ case "journal.around": {
57
+ const events = await journal.around(eventRefsArg(args, "ref")[0] ?? "r0", {
58
+ ...(numberArg(args, "before") === undefined ? {} : { before: numberArg(args, "before") }),
59
+ ...(numberArg(args, "after") === undefined ? {} : { after: numberArg(args, "after") }),
60
+ });
61
+ return this.answer(request, events.map((event) => `${event.ref} ${event.kind} ${bodyOf(event)}`), {
62
+ items: events,
63
+ total: events.length,
64
+ limit: events.length,
65
+ nextCall: "journal.around(ref)",
66
+ });
67
+ }
68
+ case "journal.trace": {
69
+ const node = await journal.trace(refArg(args, "ref"));
70
+ return this.answer(request, [traceText(node, 0)], {
71
+ items: [node.ref],
72
+ total: 1,
73
+ limit: 1,
74
+ nextCall: "journal.trace(ref)",
75
+ });
76
+ }
77
+ case "journal.links": {
78
+ const page = await journal.links(refArg(args, "ref"), {
79
+ ...(numberArg(args, "offset") === undefined ? {} : { offset: numberArg(args, "offset") }),
80
+ });
81
+ return this.answer(request, page.items.map((item) => `${item.ref} ${item.kind} ${item.summary}`), page);
82
+ }
83
+ default:
84
+ return this.notOwned(request);
85
+ }
86
+ }
87
+ answer(request, lines, page) {
88
+ const table = this.services.table;
89
+ const body = lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n");
90
+ return {
91
+ callId: request.callId,
92
+ ok: true,
93
+ content: `${body}\n${textOf(table, "result.search.total", { total: page.total })}\n${page.nextCall}`,
94
+ };
95
+ }
96
+ notOwned(request) {
97
+ return {
98
+ callId: request.callId,
99
+ ok: false,
100
+ content: "",
101
+ error: {
102
+ rule: "module.function.unknown",
103
+ message: textOf(this.services.table, "refused.unknown_function", {
104
+ module: request.name.split(".")[0] ?? "",
105
+ name: request.name,
106
+ }),
107
+ nextAction: textOf(this.services.table, "next.unknown_function"),
108
+ },
109
+ };
110
+ }
111
+ }
112
+ /** `windows`: list, show, send, broadcast, inbox. */
113
+ export class WindowsModuleGroup {
114
+ id = "modules.windows";
115
+ services;
116
+ constructor(services) {
117
+ this.services = services;
118
+ }
119
+ tools() {
120
+ return [];
121
+ }
122
+ modules() {
123
+ return [
124
+ moduleSpec(this.services.table, "windows", [
125
+ { name: "windows.list", signature: "windows.list()" },
126
+ { name: "windows.show", signature: "windows.show(c…)" },
127
+ { name: "windows.send", signature: "windows.send(to, text, reply_to?)" },
128
+ { name: "windows.broadcast", signature: "windows.broadcast(text)" },
129
+ { name: "windows.inbox", signature: "windows.inbox()" },
130
+ ]),
131
+ ];
132
+ }
133
+ async call(request, context) {
134
+ const table = this.services.table;
135
+ const args = request.args;
136
+ switch (request.name) {
137
+ case "windows.list": {
138
+ const windows = await this.services.windows();
139
+ const lines = windows.map((live) => `${live.window} ${live.state} ${live.doing} ${live.usage.used}/${live.usage.limit}`);
140
+ return {
141
+ callId: request.callId,
142
+ ok: true,
143
+ content: lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n"),
144
+ };
145
+ }
146
+ case "windows.show": {
147
+ const events = await this.services.windowTranscript(windowArg(args, "window"));
148
+ const lines = events.map((event) => `${event.ref} ${event.kind} ${bodyOf(event)}`);
149
+ return {
150
+ callId: request.callId,
151
+ ok: true,
152
+ content: lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n"),
153
+ };
154
+ }
155
+ case "windows.send": {
156
+ const to = windowArg(args, "to");
157
+ try {
158
+ const event = await this.services.windowMessage(context.window, to, stringArg(args, "text"), stringArg(args, "reply_to") === "" ? undefined : stringArg(args, "reply_to"));
159
+ return { callId: request.callId, ok: true, content: textOf(table, "result.window_message", { ref: event, window: to }) };
160
+ }
161
+ catch (error) {
162
+ // A message to an ended window, or one past the hop limit, is refused
163
+ // in the core's error shape, so the model reads the rule and the next
164
+ // action like any other refusal.
165
+ return refusedOf(request, error);
166
+ }
167
+ }
168
+ case "windows.broadcast": {
169
+ const count = await this.services.broadcast(context.window, stringArg(args, "text"));
170
+ return {
171
+ callId: request.callId,
172
+ ok: true,
173
+ content: textOf(table, "result.broadcast.count", { count }),
174
+ };
175
+ }
176
+ case "windows.inbox": {
177
+ const messages = await this.services.inbox(context.window);
178
+ const lines = messages.map((message) => `${message.ref} ${message.from} ${message.text}`);
179
+ return {
180
+ callId: request.callId,
181
+ ok: true,
182
+ content: lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n"),
183
+ };
184
+ }
185
+ default:
186
+ return this.notOwned(request);
187
+ }
188
+ }
189
+ notOwned(request) {
190
+ return {
191
+ callId: request.callId,
192
+ ok: false,
193
+ content: "",
194
+ error: {
195
+ rule: "module.function.unknown",
196
+ message: textOf(this.services.table, "refused.unknown_function", {
197
+ module: "windows",
198
+ name: request.name,
199
+ }),
200
+ nextAction: textOf(this.services.table, "next.unknown_function"),
201
+ },
202
+ };
203
+ }
204
+ }
205
+ /** `board`: list, set, expect, unexpect. */
206
+ export class BoardModuleGroup {
207
+ id = "modules.board";
208
+ services;
209
+ constructor(services) {
210
+ this.services = services;
211
+ }
212
+ tools() {
213
+ return [];
214
+ }
215
+ modules() {
216
+ return [
217
+ moduleSpec(this.services.table, "board", [
218
+ { name: "board.list", signature: "board.list()" },
219
+ { name: "board.set", signature: "board.set(line)" },
220
+ { name: "board.expect", signature: "board.expect(item? | description?)" },
221
+ { name: "board.unexpect", signature: "board.unexpect(ref)" },
222
+ ]),
223
+ ];
224
+ }
225
+ async call(request, context) {
226
+ const table = this.services.table;
227
+ const args = request.args;
228
+ switch (request.name) {
229
+ case "board.list": {
230
+ const page = await this.services.board();
231
+ const lines = page.items.map((line) => `${line.window} ${line.state} ${line.doing} ${line.targets.join(",")}`);
232
+ return {
233
+ callId: request.callId,
234
+ ok: true,
235
+ content: lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n"),
236
+ };
237
+ }
238
+ case "board.set":
239
+ await this.services.setBoardLine(context.window, stringArg(args, "line"));
240
+ return { callId: request.callId, ok: true, content: textOf(table, "result.board.set") };
241
+ case "board.expect": {
242
+ const item = stringArg(args, "item");
243
+ const description = stringArg(args, "description");
244
+ const expectation = await this.services.expect(context.window, item === "" ? undefined : item, description === "" ? undefined : description);
245
+ return { callId: request.callId, ok: true, content: expectation.ref };
246
+ }
247
+ case "board.unexpect":
248
+ await this.services.unexpect(context.window, refArg(args, "ref"));
249
+ return { callId: request.callId, ok: true, content: textOf(table, "result.board.unexpect") };
250
+ default:
251
+ return this.notOwned(request);
252
+ }
253
+ }
254
+ notOwned(request) {
255
+ return {
256
+ callId: request.callId,
257
+ ok: false,
258
+ content: "",
259
+ error: {
260
+ rule: "module.function.unknown",
261
+ message: textOf(this.services.table, "refused.unknown_function", { module: "board", name: request.name }),
262
+ nextAction: textOf(this.services.table, "next.unknown_function"),
263
+ },
264
+ };
265
+ }
266
+ }
267
+ /** `notes`: search, read, context, active. Absent without a `NoteStore`. */
268
+ export class NotesModuleGroup {
269
+ id = "modules.notes";
270
+ services;
271
+ constructor(services) {
272
+ this.services = services;
273
+ }
274
+ tools() {
275
+ return [];
276
+ }
277
+ modules() {
278
+ if (this.services.notes === undefined)
279
+ return [];
280
+ return [
281
+ moduleSpec(this.services.table, "notes", [
282
+ { name: "notes.search", signature: "notes.search(query?, status?, about?, linked?)" },
283
+ { name: "notes.read", signature: "notes.read(ref)" },
284
+ { name: "notes.context", signature: "notes.context(ref)" },
285
+ { name: "notes.active", signature: "notes.active()" },
286
+ ]),
287
+ ];
288
+ }
289
+ async call(request, _context) {
290
+ const store = this.services.notes;
291
+ if (store === undefined) {
292
+ return {
293
+ callId: request.callId,
294
+ ok: false,
295
+ content: "",
296
+ error: {
297
+ rule: "notes.absent",
298
+ message: textOf(this.services.table, "refused.notes.absent"),
299
+ nextAction: textOf(this.services.table, "next.notes.absent"),
300
+ },
301
+ };
302
+ }
303
+ const args = request.args;
304
+ switch (request.name) {
305
+ case "notes.search": {
306
+ const page = await store.search({
307
+ ...(stringArg(args, "query") === "" ? {} : { query: stringArg(args, "query") }),
308
+ ...(stringArg(args, "about") === "" ? {} : { about: stringArg(args, "about") }),
309
+ });
310
+ const lines = page.items.map((note) => `${note.ref} ${note.title}:${note.summary}`);
311
+ return {
312
+ callId: request.callId,
313
+ ok: true,
314
+ content: `${lines.join("\n")}\n${textOf(this.services.table, "result.search.total", { total: page.total })}`,
315
+ };
316
+ }
317
+ case "notes.read": {
318
+ const body = await store.read(noteRefArg(args, "ref"));
319
+ if (body === null) {
320
+ return {
321
+ callId: request.callId,
322
+ ok: false,
323
+ content: "",
324
+ error: {
325
+ rule: "notes.not_found",
326
+ message: textOf(this.services.table, "refused.notes.notfound", { ref: noteRefArg(args, "ref") }),
327
+ nextAction: textOf(this.services.table, "next.notes.notfound"),
328
+ },
329
+ };
330
+ }
331
+ return {
332
+ callId: request.callId,
333
+ ok: true,
334
+ content: `${body.ref} ${body.body}\n${body.nextCall}`,
335
+ };
336
+ }
337
+ case "notes.context": {
338
+ const context_ = await store.context(noteRefArg(args, "ref"));
339
+ if (context_ === null) {
340
+ return {
341
+ callId: request.callId,
342
+ ok: false,
343
+ content: "",
344
+ error: {
345
+ rule: "notes.not_found",
346
+ message: textOf(this.services.table, "refused.notes.notfound", { ref: noteRefArg(args, "ref") }),
347
+ nextAction: textOf(this.services.table, "next.notes.notfound"),
348
+ },
349
+ };
350
+ }
351
+ return {
352
+ callId: request.callId,
353
+ ok: true,
354
+ content: `${context_.ref} ${context_.evidence.join(", ")}\n${context_.events.join(", ")}`,
355
+ };
356
+ }
357
+ case "notes.active": {
358
+ const page = await store.active();
359
+ const lines = page.items.map((note) => `${note.ref} ${note.title}:${note.summary}`);
360
+ return {
361
+ callId: request.callId,
362
+ ok: true,
363
+ content: `${lines.join("\n")}\n${textOf(this.services.table, "result.search.total", { total: page.total })}`,
364
+ };
365
+ }
366
+ default:
367
+ return this.notOwned(request);
368
+ }
369
+ }
370
+ notOwned(request) {
371
+ return {
372
+ callId: request.callId,
373
+ ok: false,
374
+ content: "",
375
+ error: {
376
+ rule: "module.function.unknown",
377
+ message: textOf(this.services.table, "refused.unknown_function", { module: "notes", name: request.name }),
378
+ nextAction: textOf(this.services.table, "next.unknown_function"),
379
+ },
380
+ };
381
+ }
382
+ }
383
+ /** `channels`: list, get. Absent when no plugin registered a channel. */
384
+ export class ChannelsModuleGroup {
385
+ id = "modules.channels";
386
+ services;
387
+ constructor(services) {
388
+ this.services = services;
389
+ }
390
+ tools() {
391
+ return [];
392
+ }
393
+ modules() {
394
+ // A channel is a plugin's adapter, so a host no plugin plugged one into has
395
+ // nothing to list and nothing to look up.
396
+ if (this.services.channels().length === 0)
397
+ return [];
398
+ return [
399
+ moduleSpec(this.services.table, "channels", [
400
+ { name: "channels.list", signature: "channels.list()" },
401
+ { name: "channels.get", signature: "channels.get(ref)" },
402
+ ]),
403
+ ];
404
+ }
405
+ async call(request, _context) {
406
+ switch (request.name) {
407
+ case "channels.list":
408
+ return this.list(request);
409
+ case "channels.get":
410
+ return this.get(request);
411
+ default:
412
+ return this.notOwned(request);
413
+ }
414
+ }
415
+ /** `channels.list`: every channel, and what is connected on it. */
416
+ async list(request) {
417
+ const table = this.services.table;
418
+ const lines = [];
419
+ for (const channel of this.services.channels()) {
420
+ const connections = await channel.connections();
421
+ if (connections.length === 0) {
422
+ lines.push(textOf(table, "result.channels.none", { channel: channel.id }));
423
+ continue;
424
+ }
425
+ for (const connection of connections) {
426
+ lines.push(textOf(table, "result.channels.line", {
427
+ channel: channel.id,
428
+ id: connection.id,
429
+ state: connection.online ? textOf(table, "result.channels.online") : textOf(table, "result.channels.offline"),
430
+ detail: connection.detail === undefined ? "" : ` ${connection.detail}`,
431
+ }));
432
+ }
433
+ }
434
+ return { callId: request.callId, ok: true, content: lines.join("\n") };
435
+ }
436
+ /** `channels.get(ref)`: one outside message, by its native id. */
437
+ async get(request) {
438
+ const table = this.services.table;
439
+ const ref = stringArg(request.args, "ref");
440
+ const channel = this.channelOf(ref);
441
+ if (channel === undefined) {
442
+ return {
443
+ callId: request.callId,
444
+ ok: false,
445
+ content: "",
446
+ error: {
447
+ rule: "channels.unknown",
448
+ message: textOf(table, "refused.channels.unknown", { ref }),
449
+ nextAction: textOf(table, "next.channels.unknown"),
450
+ },
451
+ };
452
+ }
453
+ const message = await channel.get(ref);
454
+ if (message === null) {
455
+ return {
456
+ callId: request.callId,
457
+ ok: false,
458
+ content: "",
459
+ error: {
460
+ rule: "channels.not_found",
461
+ message: textOf(table, "refused.channels.notfound", { ref }),
462
+ nextAction: textOf(table, "next.channels.notfound"),
463
+ },
464
+ };
465
+ }
466
+ return {
467
+ callId: request.callId,
468
+ ok: true,
469
+ content: [
470
+ textOf(table, "result.channels.message", {
471
+ external: message.external,
472
+ source: message.source,
473
+ target: message.target,
474
+ time: formatTime(message.time),
475
+ reply: message.replyTo === undefined
476
+ ? ""
477
+ : textOf(table, "result.channels.reply", { reply: message.replyTo }),
478
+ stored: message.stored ? textOf(table, "result.channels.stored") : "",
479
+ }),
480
+ textOfBlocks(message.content),
481
+ ].join("\n"),
482
+ };
483
+ }
484
+ /** The channel a native id names, by its prefix: `http:42` is `http`'s. */
485
+ channelOf(ref) {
486
+ const prefix = ref.split(":")[0] ?? "";
487
+ return this.services.channels().find((channel) => channel.id === prefix);
488
+ }
489
+ notOwned(request) {
490
+ return {
491
+ callId: request.callId,
492
+ ok: false,
493
+ content: "",
494
+ error: {
495
+ rule: "module.function.unknown",
496
+ message: textOf(this.services.table, "refused.unknown_function", { module: "channels", name: request.name }),
497
+ nextAction: textOf(this.services.table, "next.unknown_function"),
498
+ },
499
+ };
500
+ }
501
+ }
502
+ /** `tasks`: list, show. The tasks the host dispatched (docs/dispatch.md#tasks). */
503
+ export class TasksModuleGroup {
504
+ id = "modules.tasks";
505
+ services;
506
+ constructor(services) {
507
+ this.services = services;
508
+ }
509
+ tools() {
510
+ return [];
511
+ }
512
+ modules() {
513
+ return [
514
+ moduleSpec(this.services.table, "tasks", [
515
+ { name: "tasks.list", signature: "tasks.list(limit?)" },
516
+ { name: "tasks.show", signature: "tasks.show(t…)" },
517
+ ]),
518
+ ];
519
+ }
520
+ async call(request, _context) {
521
+ const table = this.services.table;
522
+ const args = request.args;
523
+ switch (request.name) {
524
+ case "tasks.list": {
525
+ const tasks = await this.services.tasks(numberArg(args, "limit"));
526
+ const lines = tasks.map((task) => this.line(task));
527
+ return {
528
+ callId: request.callId,
529
+ ok: true,
530
+ content: lines.length === 0 ? textOf(table, "result.list.empty") : lines.join("\n"),
531
+ };
532
+ }
533
+ case "tasks.show": {
534
+ const records = await this.services.task(taskRefsArg(args, "ref"));
535
+ const lines = [];
536
+ for (const record of records) {
537
+ lines.push(this.line(record));
538
+ // The outside events the batch was made of, as the journal holds them.
539
+ for (const event of record.events)
540
+ lines.push(`${event.ref} ${event.kind} ${bodyOf(event)}`);
541
+ }
542
+ return {
543
+ callId: request.callId,
544
+ ok: true,
545
+ content: lines.length === 0 ? textOf(table, "result.list.empty") : lines.join("\n"),
546
+ };
547
+ }
548
+ default:
549
+ return this.notOwned(request);
550
+ }
551
+ }
552
+ /** One task as one line: its ref, its kind, why it is one, its targets and its time. */
553
+ line(task) {
554
+ return textOf(this.services.table, "result.tasks.line", {
555
+ ref: task.ref,
556
+ kind: task.kind,
557
+ reason: task.reason,
558
+ targets: task.targets.join(","),
559
+ time: formatTime(task.time),
560
+ });
561
+ }
562
+ notOwned(request) {
563
+ return {
564
+ callId: request.callId,
565
+ ok: false,
566
+ content: "",
567
+ error: {
568
+ rule: "module.function.unknown",
569
+ message: textOf(this.services.table, "refused.unknown_function", {
570
+ module: "tasks",
571
+ name: request.name,
572
+ }),
573
+ nextAction: textOf(this.services.table, "next.unknown_function"),
574
+ },
575
+ };
576
+ }
577
+ }
578
+ /** `schedule`: list, create, update, cancel (docs/dispatch.md#scheduled-tasks). */
579
+ export class ScheduleModuleGroup {
580
+ id = "modules.schedule";
581
+ services;
582
+ constructor(services) {
583
+ this.services = services;
584
+ }
585
+ tools() {
586
+ return [];
587
+ }
588
+ modules() {
589
+ return [
590
+ moduleSpec(this.services.table, "schedule", [
591
+ { name: "schedule.list", signature: "schedule.list()" },
592
+ { name: "schedule.create", signature: "schedule.create({ name?, firstAt, repeat?, skipDaily?, targets?, task })" },
593
+ { name: "schedule.update", signature: "schedule.update({ ref, name?, firstAt?, repeat?, skipDaily?, targets?, task? })" },
594
+ { name: "schedule.cancel", signature: "schedule.cancel({ ref })" },
595
+ ]),
596
+ ];
597
+ }
598
+ async call(request, _context) {
599
+ const table = this.services.table;
600
+ const args = request.args;
601
+ switch (request.name) {
602
+ case "schedule.list": {
603
+ const schedules = await this.services.schedules();
604
+ const lines = schedules.map((task) => textOf(table, "result.schedule.line", {
605
+ ref: task.ref,
606
+ name: task.name ?? "",
607
+ time: formatTime(task.nextAt),
608
+ repeat: this.repeatText(task.repeat),
609
+ targets: task.targets.join(","),
610
+ }));
611
+ return {
612
+ callId: request.callId,
613
+ ok: true,
614
+ content: lines.length === 0 ? textOf(table, "result.list.empty") : lines.join("\n"),
615
+ };
616
+ }
617
+ case "schedule.create":
618
+ return this.create(request);
619
+ case "schedule.update":
620
+ return this.update(request);
621
+ case "schedule.cancel": {
622
+ const ref = taskRefArg(args, "ref");
623
+ if (ref === undefined)
624
+ return this.badInput("ref");
625
+ await this.services.cancelSchedule(ref);
626
+ return {
627
+ callId: request.callId,
628
+ ok: true,
629
+ content: textOf(table, "result.schedule.cancelled", { ref }),
630
+ };
631
+ }
632
+ default:
633
+ return this.notOwned(request);
634
+ }
635
+ }
636
+ /** `schedule.create`: one new scheduled task, from what the script named. */
637
+ async create(request) {
638
+ const table = this.services.table;
639
+ const args = request.args;
640
+ const firstAt = numberArg(args, "firstAt");
641
+ if (firstAt === undefined)
642
+ return this.badInput("firstAt");
643
+ const repeat = repeatArg(args);
644
+ if (repeat === "invalid")
645
+ return this.badInput("repeat");
646
+ const task = taskArg(args, "task", firstAt);
647
+ if (task === undefined)
648
+ return this.badInput("task");
649
+ const name = stringArg(args, "name");
650
+ const skipDaily = skipDailyArg(args);
651
+ const targets = targetsArg(args);
652
+ const input = {
653
+ firstAt,
654
+ ...(name === "" ? {} : { name }),
655
+ ...(repeat === undefined ? {} : { repeat }),
656
+ ...(skipDaily.length === 0 ? {} : { skipDaily }),
657
+ ...(targets.length === 0 ? {} : { targets }),
658
+ task,
659
+ };
660
+ const ref = await this.services.schedule(input);
661
+ return {
662
+ callId: request.callId,
663
+ ok: true,
664
+ content: textOf(table, "result.schedule.created", { ref }),
665
+ };
666
+ }
667
+ /** `schedule.update`: only the fields the script named change. */
668
+ async update(request) {
669
+ const table = this.services.table;
670
+ const args = request.args;
671
+ const ref = taskRefArg(args, "ref");
672
+ if (ref === undefined)
673
+ return this.badInput("ref");
674
+ const repeat = repeatArg(args);
675
+ if (repeat === "invalid")
676
+ return this.badInput("repeat");
677
+ const firstAt = numberArg(args, "firstAt");
678
+ const task = taskArg(args, "task", firstAt ?? 0);
679
+ const name = stringArg(args, "name");
680
+ const skipDaily = skipDailyArg(args);
681
+ const targets = targetsArg(args);
682
+ const changes = {
683
+ ...(name === "" ? {} : { name }),
684
+ ...(firstAt === undefined ? {} : { firstAt }),
685
+ ...(repeat === undefined ? {} : { repeat }),
686
+ ...(skipDaily.length === 0 ? {} : { skipDaily }),
687
+ ...(targets.length === 0 ? {} : { targets }),
688
+ ...(task === undefined ? {} : { task }),
689
+ };
690
+ const updated = await this.services.updateSchedule(ref, changes);
691
+ if (updated === null)
692
+ return this.notFound(ref);
693
+ return {
694
+ callId: request.callId,
695
+ ok: true,
696
+ content: textOf(table, "result.schedule.updated", { ref }),
697
+ };
698
+ }
699
+ /** One repeat rule as one line: only once, a period, or a daily clock time. */
700
+ repeatText(repeat) {
701
+ const table = this.services.table;
702
+ if (repeat === undefined)
703
+ return textOf(table, "result.schedule.once");
704
+ return "everyMs" in repeat
705
+ ? textOf(table, "result.schedule.every_ms", { ms: repeat.everyMs })
706
+ : textOf(table, "result.schedule.daily", { time: repeat.dailyAt });
707
+ }
708
+ /** One argument the script did not name in a shape this module can read. */
709
+ badInput(field) {
710
+ return refused("schedule.input.invalid", this.services.table, "schedule.input", { field });
711
+ }
712
+ notFound(ref) {
713
+ return refused("schedule.not_found", this.services.table, "schedule.notfound", { ref });
714
+ }
715
+ notOwned(request) {
716
+ return {
717
+ callId: request.callId,
718
+ ok: false,
719
+ content: "",
720
+ error: {
721
+ rule: "module.function.unknown",
722
+ message: textOf(this.services.table, "refused.unknown_function", {
723
+ module: "schedule",
724
+ name: request.name,
725
+ }),
726
+ nextAction: textOf(this.services.table, "next.unknown_function"),
727
+ },
728
+ };
729
+ }
730
+ }
731
+ /** `workspace`: grep, find, ls. Absent when the host has no workspace. */
732
+ export class WorkspaceModuleGroup {
733
+ id = "modules.workspace";
734
+ services;
735
+ constructor(services) {
736
+ this.services = services;
737
+ }
738
+ tools() {
739
+ return [];
740
+ }
741
+ modules() {
742
+ // A module whose backing feature is not configured is absent
743
+ // (docs/tools.md#modules).
744
+ if (this.services.workspace === undefined)
745
+ return [];
746
+ return [
747
+ moduleSpec(this.services.table, "workspace", [
748
+ { name: "workspace.grep", signature: "workspace.grep(pattern, path?)" },
749
+ { name: "workspace.find", signature: "workspace.find(name, path?)" },
750
+ { name: "workspace.ls", signature: "workspace.ls(path?)" },
751
+ ]),
752
+ ];
753
+ }
754
+ async call(request, context) {
755
+ switch (request.name) {
756
+ case "workspace.ls":
757
+ return this.ls(request, context.window, context);
758
+ case "workspace.find":
759
+ return this.find(request, context.window);
760
+ case "workspace.grep":
761
+ return this.grep(request, context.window);
762
+ default:
763
+ return this.notOwned(request);
764
+ }
765
+ }
766
+ async ls(request, window, context) {
767
+ const workspace = await this.services.ensureWorkspace(window);
768
+ if (workspace === undefined)
769
+ return this.absent(request);
770
+ const path = stringArg(request.args, "path") || ".";
771
+ // A path under the environment's prefix is read where the command runs, so
772
+ // the prefix is resolved the same way `bash` resolves it.
773
+ const listed = await this.runIn(workspace, `ls -1p ${quote(await envCommand(workspace, path, this.services.envPaths(), context.envRevision))}`);
774
+ if (listed !== null) {
775
+ return { callId: request.callId, ok: listed.exitCode === 0, content: listed.stdout.trim() };
776
+ }
777
+ const target = hostPath(workspace.path, path);
778
+ const entries = await readdirSafe(target);
779
+ return {
780
+ callId: request.callId,
781
+ ok: true,
782
+ content: entries.map((entry) => `${entry.name}${entry.directory ? "/" : ""}`).join("\n"),
783
+ };
784
+ }
785
+ async find(request, window) {
786
+ const workspace = await this.services.ensureWorkspace(window);
787
+ if (workspace === undefined)
788
+ return this.absent(request);
789
+ const name = stringArg(request.args, "name");
790
+ const found = await this.runIn(workspace, `find . -name ${quote(`*${name}*`)}`);
791
+ if (found !== null) {
792
+ return {
793
+ callId: request.callId,
794
+ ok: found.exitCode === 0,
795
+ content: found.stdout.trim().split("\n").filter((line) => line !== "").join("\n"),
796
+ };
797
+ }
798
+ const paths = await walk(workspace.path, (entry) => entry.name.includes(name));
799
+ return {
800
+ callId: request.callId,
801
+ ok: true,
802
+ content: paths.map((path) => path.slice(workspace.path.length + 1)).join("\n"),
803
+ };
804
+ }
805
+ async grep(request, window) {
806
+ const workspace = await this.services.ensureWorkspace(window);
807
+ if (workspace === undefined)
808
+ return this.absent(request);
809
+ const pattern = stringArg(request.args, "pattern");
810
+ const matched = await this.runIn(workspace, `grep -rn ${quote(pattern)} .`);
811
+ if (matched !== null) {
812
+ return { callId: request.callId, ok: matched.exitCode === 0, content: matched.stdout.trim() };
813
+ }
814
+ const files = await walk(workspace.path, () => true);
815
+ const lines = [];
816
+ for (const file of files) {
817
+ const text = await readFileSafe(file);
818
+ if (text === null || !text.includes(pattern))
819
+ continue;
820
+ const relative = file.slice(workspace.path.length + 1);
821
+ text.split("\n").forEach((line, index) => {
822
+ if (line.includes(pattern))
823
+ lines.push(`${relative}:${index + 1}:${line}`);
824
+ });
825
+ }
826
+ return { callId: request.callId, ok: true, content: lines.slice(0, 200).join("\n") };
827
+ }
828
+ /**
829
+ * One lookup inside the workspace. An implementation that owns the execution
830
+ * environment answers with `exec`, because its `path` is not a host path;
831
+ * `null` means the caller walks the host directory itself.
832
+ */
833
+ async runIn(workspace, command) {
834
+ if (workspace.exec === undefined)
835
+ return null;
836
+ return workspace.exec(command).catch(() => null);
837
+ }
838
+ absent(request) {
839
+ return {
840
+ callId: request.callId,
841
+ ok: false,
842
+ content: "",
843
+ error: {
844
+ rule: "workspace.absent",
845
+ message: textOf(this.services.table, "result.no_workspace"),
846
+ nextAction: textOf(this.services.table, "next.workspace.notfound"),
847
+ },
848
+ };
849
+ }
850
+ notOwned(request) {
851
+ return {
852
+ callId: request.callId,
853
+ ok: false,
854
+ content: "",
855
+ error: {
856
+ rule: "module.function.unknown",
857
+ message: textOf(this.services.table, "refused.unknown_function", {
858
+ module: "workspace",
859
+ name: request.name,
860
+ }),
861
+ nextAction: textOf(this.services.table, "next.unknown_function"),
862
+ },
863
+ };
864
+ }
865
+ }
866
+ function bodyOf(event) {
867
+ return event.body === null ? "" : event.body.replace(/\s+/g, " ").slice(0, 120);
868
+ }
869
+ /** The text of an outside message's blocks: what its sender wrote. */
870
+ function textOfBlocks(blocks) {
871
+ const lines = [];
872
+ for (const block of blocks) {
873
+ const content = block.fields.content;
874
+ if (typeof content === "string")
875
+ lines.push(content);
876
+ }
877
+ return lines.join("\n");
878
+ }
879
+ function traceText(node, depth) {
880
+ const lines = [`${" ".repeat(depth)}${node.ref} ${node.summary}`];
881
+ for (const parent of node.from) {
882
+ lines.push(traceText(parent, depth + 1));
883
+ }
884
+ return lines.join("\n");
885
+ }
886
+ /** One shell argument, quoted so a path with a space survives. */
887
+ function quote(value) {
888
+ return `'${value.replace(/'/g, "'\\''")}'`;
889
+ }
890
+ /**
891
+ * Where a path names on this host: itself when it is absolute, under the
892
+ * workspace when it is not. Nothing is refused, because the owner asked for no
893
+ * restriction on where a window reads or writes
894
+ * (docs/workspaces.md#another-windows-directory).
895
+ */
896
+ function hostPath(root, path) {
897
+ return isAbsolute(path) ? normalize(path) : resolve(root, path);
898
+ }
899
+ async function readdirSafe(path) {
900
+ try {
901
+ return await readdir(path, { withFileTypes: true }).then((entries) => entries.map((entry) => ({ name: entry.name, directory: entry.isDirectory() })));
902
+ }
903
+ catch {
904
+ return [];
905
+ }
906
+ }
907
+ async function readFileSafe(path) {
908
+ try {
909
+ return await readFile(path, "utf8");
910
+ }
911
+ catch {
912
+ return null;
913
+ }
914
+ }
915
+ /** Every file under a directory, in a stable order. */
916
+ async function walk(root, keep) {
917
+ const found = [];
918
+ const step = async (directory) => {
919
+ for (const entry of await readdirSafe(directory)) {
920
+ const path = join(directory, entry.name);
921
+ if (entry.directory) {
922
+ if (entry.name === ".arbor" || entry.name === ".git")
923
+ continue;
924
+ await step(path);
925
+ continue;
926
+ }
927
+ if (!keep(entry))
928
+ continue;
929
+ found.push(path);
930
+ }
931
+ };
932
+ await step(root);
933
+ return found.sort();
934
+ }
935
+ function stringArg(args, name) {
936
+ const value = args[name];
937
+ return typeof value === "string" ? value : "";
938
+ }
939
+ function numberArg(args, name) {
940
+ const value = args[name];
941
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
942
+ }
943
+ function refArg(args, name) {
944
+ const value = args[name];
945
+ return typeof value === "string" ? value : "";
946
+ }
947
+ function noteRefArg(args, name) {
948
+ const value = refArg(args, name);
949
+ return /^n\d+(@\d+)?$/.test(value) ? value : "n0";
950
+ }
951
+ function eventRefsArg(args, name) {
952
+ const value = args[name];
953
+ if (Array.isArray(value))
954
+ return value.filter((item) => typeof item === "string");
955
+ const single = refArg(args, name);
956
+ return /^r\d+$/.test(single) ? [single] : [];
957
+ }
958
+ function windowArg(args, name) {
959
+ const value = refArg(args, name);
960
+ return /^c\d+$/.test(value) ? value : "c0";
961
+ }
962
+ /** The task refs an argument names: one, or a list of them. */
963
+ function taskRefsArg(args, name) {
964
+ const value = args[name];
965
+ if (Array.isArray(value)) {
966
+ return value.flatMap((one) => typeof one === "string" && /^t\d+$/.test(one) ? [one] : []);
967
+ }
968
+ const single = refArg(args, name);
969
+ return /^t\d+$/.test(single) ? [single] : [];
970
+ }
971
+ /** One task ref, or undefined when the argument is not one. */
972
+ function taskRefArg(args, name) {
973
+ return taskRefsArg(args, name)[0];
974
+ }
975
+ /**
976
+ * One repeat rule, `invalid` when the argument is not one this module can read:
977
+ * a period in milliseconds, or a clock time a firing repeats at daily.
978
+ */
979
+ function repeatArg(args) {
980
+ const value = args.repeat;
981
+ if (value === undefined)
982
+ return undefined;
983
+ if (typeof value !== "object" || value === null)
984
+ return "invalid";
985
+ const repeat = value;
986
+ if (typeof repeat.everyMs === "number" && Number.isFinite(repeat.everyMs) && repeat.everyMs > 0) {
987
+ return { everyMs: repeat.everyMs };
988
+ }
989
+ if (typeof repeat.dailyAt === "string" && /^\d{1,2}:\d{2}$/u.test(repeat.dailyAt.trim())) {
990
+ return { dailyAt: repeat.dailyAt.trim() };
991
+ }
992
+ return "invalid";
993
+ }
994
+ /** The daily skip periods an argument names. */
995
+ function skipDailyArg(args) {
996
+ const value = args.skipDaily;
997
+ if (!Array.isArray(value))
998
+ return [];
999
+ return value.flatMap((one) => {
1000
+ if (typeof one !== "object" || one === null)
1001
+ return [];
1002
+ const period = one;
1003
+ return typeof period.from === "string" && typeof period.to === "string"
1004
+ ? [{ from: period.from, to: period.to }]
1005
+ : [];
1006
+ });
1007
+ }
1008
+ /** The strings an argument names, or none when it names something else. */
1009
+ function stringsArg(value) {
1010
+ if (!Array.isArray(value))
1011
+ return [];
1012
+ return value.filter((one) => typeof one === "string" && one !== "");
1013
+ }
1014
+ /** The targets an argument names, or none when it names something else. */
1015
+ function targetsArg(args) {
1016
+ return stringsArg(args.targets);
1017
+ }
1018
+ /**
1019
+ * What a scheduled firing dispatches. Its time defaults to the firing's own, so
1020
+ * a script names what the task says and why and nothing else; the blocks it
1021
+ * carries are data, exactly as the host holds them.
1022
+ */
1023
+ function taskArg(args, name, at) {
1024
+ const value = args[name];
1025
+ if (typeof value !== "object" || value === null)
1026
+ return undefined;
1027
+ const task = value;
1028
+ if (typeof task.reason !== "string" || task.reason === "" || !Array.isArray(task.content))
1029
+ return undefined;
1030
+ return {
1031
+ reason: task.reason,
1032
+ targets: stringsArg(task.targets),
1033
+ time: typeof task.time === "number" && Number.isFinite(task.time) ? task.time : at,
1034
+ content: task.content,
1035
+ };
1036
+ }
1037
+ /**
1038
+ * One refusal the service layer threw, as the tool result the model reads. The
1039
+ * service layer refuses in the core's error shape (docs/architecture.md#errors),
1040
+ * so the rule and the next action it names reach the model unchanged.
1041
+ */
1042
+ function refusedOf(request, error) {
1043
+ const refusal = error;
1044
+ const rule = typeof refusal.rule === "string" ? refusal.rule : "internal";
1045
+ const message = typeof refusal.message === "string" ? refusal.message : String(error);
1046
+ const nextAction = typeof refusal.nextAction === "string" ? refusal.nextAction : undefined;
1047
+ const refs = Array.isArray(refusal.refs) ? refusal.refs.filter((one) => typeof one === "string") : [];
1048
+ return {
1049
+ callId: request.callId,
1050
+ ok: false,
1051
+ content: "",
1052
+ error: {
1053
+ rule,
1054
+ message,
1055
+ ...(nextAction === undefined ? {} : { nextAction }),
1056
+ ...(refs.length === 0 ? {} : { refs }),
1057
+ },
1058
+ };
1059
+ }
1060
+ export { EnvModuleGroup } from "./env.js";