@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.
- package/LICENSE +21 -0
- package/README.md +187 -2
- package/bound.d.ts +34 -0
- package/bound.js +55 -0
- package/catalog.d.ts +60 -0
- package/catalog.js +224 -0
- package/code-exec.d.ts +55 -0
- package/code-exec.js +100 -0
- package/env-stage.d.ts +29 -0
- package/env-stage.js +63 -0
- package/groups/core.d.ts +30 -0
- package/groups/core.js +148 -0
- package/groups/env.d.ts +45 -0
- package/groups/env.js +190 -0
- package/groups/files.d.ts +63 -0
- package/groups/files.js +343 -0
- package/groups/notes.d.ts +37 -0
- package/groups/notes.js +176 -0
- package/host.d.ts +199 -0
- package/host.js +70 -0
- package/index.d.ts +18 -0
- package/index.js +18 -0
- package/modules/env.d.ts +42 -0
- package/modules/env.js +237 -0
- package/modules/modules.d.ts +121 -0
- package/modules/modules.js +1060 -0
- package/package.json +55 -4
package/host.d.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the entry points and the service layer
|
|
3
|
+
* (docs/architecture.md#layers, docs/tools.md#rules).
|
|
4
|
+
*
|
|
5
|
+
* A direct tool and a code mode function are thin wrappers of one service
|
|
6
|
+
* call: same arguments, same behaviour, same errors. The host API calls the
|
|
7
|
+
* same layer. So a group never reaches past these interfaces, and the service
|
|
8
|
+
* layer implements them once.
|
|
9
|
+
*/
|
|
10
|
+
import type { BoardLine, CallContext, Channel, DirectToolSpec, Environment, EnvDraft, EnvItem, EnvItemName, EnvItemRef, EnvRef, Event, EventRef, Expectation, Handoff, Journal, MessageRef, ModuleSpec, Note, NoteStore, NoteWriteInput, Page, ParameterSpec, Ref, ScheduledTask, ScheduledTaskInput, ScheduledTaskUpdate, SearchHit, Supervisor, Target, TaskKind, TaskRef, TextTable, ToolResult, WindowRef, Workspace, WorkspaceMount } from "@4fu/arbor-interfaces";
|
|
11
|
+
/** One window the host knows about, for `windows.list` and the board. */
|
|
12
|
+
export interface LiveWindow {
|
|
13
|
+
window: WindowRef;
|
|
14
|
+
state: string;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
task?: Ref;
|
|
17
|
+
targets: readonly string[];
|
|
18
|
+
doing: string;
|
|
19
|
+
expects: readonly Expectation[];
|
|
20
|
+
usage: {
|
|
21
|
+
used: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
reserve: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** One query over the journal, the notes and the board in one call. */
|
|
27
|
+
export interface SearchOutcome {
|
|
28
|
+
query: string;
|
|
29
|
+
scope: readonly string[];
|
|
30
|
+
hits: readonly SearchHit[];
|
|
31
|
+
notes: readonly {
|
|
32
|
+
ref: Ref;
|
|
33
|
+
title: string;
|
|
34
|
+
summary: string;
|
|
35
|
+
}[];
|
|
36
|
+
windows: readonly {
|
|
37
|
+
window: WindowRef;
|
|
38
|
+
doing: string;
|
|
39
|
+
}[];
|
|
40
|
+
total: number;
|
|
41
|
+
nextCall: string;
|
|
42
|
+
}
|
|
43
|
+
/** A send, as the host records and queues it. */
|
|
44
|
+
export interface SendOutcome {
|
|
45
|
+
event: EventRef;
|
|
46
|
+
/** Events on the conversation the window has not seen yet. */
|
|
47
|
+
behind: number;
|
|
48
|
+
}
|
|
49
|
+
/** One task as the host recorded it. */
|
|
50
|
+
export interface TaskSummary {
|
|
51
|
+
ref: TaskRef;
|
|
52
|
+
kind: TaskKind;
|
|
53
|
+
/** Why it is a task, for the board and the judge. */
|
|
54
|
+
reason: string;
|
|
55
|
+
targets: readonly Target[];
|
|
56
|
+
time: number;
|
|
57
|
+
/** The source that made it, when one did. */
|
|
58
|
+
source?: string;
|
|
59
|
+
}
|
|
60
|
+
/** One task with the outside events it batched. */
|
|
61
|
+
export interface TaskRecord extends TaskSummary {
|
|
62
|
+
/** The events the batch was made of, oldest first. */
|
|
63
|
+
events: readonly Event[];
|
|
64
|
+
}
|
|
65
|
+
/** What a group needs from the service layer. Each group takes what it uses. */
|
|
66
|
+
export interface ToolServices {
|
|
67
|
+
readonly table: TextTable;
|
|
68
|
+
readonly journal: Journal;
|
|
69
|
+
readonly notes?: NoteStore;
|
|
70
|
+
readonly workspace?: Workspace;
|
|
71
|
+
/** The environment. Absent when a deployment has none. */
|
|
72
|
+
readonly environment?: Environment;
|
|
73
|
+
/** The service supervisor. Absent when no service may run. */
|
|
74
|
+
readonly supervisor?: Supervisor;
|
|
75
|
+
/** The window's own directory, created on first use. */
|
|
76
|
+
ensureWorkspace(window: WindowRef): Promise<WorkspaceMount | undefined>;
|
|
77
|
+
/** End the run, with a handoff when there is unfinished work. */
|
|
78
|
+
endWindow(window: WindowRef, handoff?: Handoff): Promise<ToolResult>;
|
|
79
|
+
/** True when the window still has work the host can see. */
|
|
80
|
+
hasOpenWork(window: WindowRef): Promise<boolean>;
|
|
81
|
+
/**
|
|
82
|
+
* One note write, with the anchor the host fills in: the window, the event
|
|
83
|
+
* and the evidence. The model never declares an anchor.
|
|
84
|
+
*/
|
|
85
|
+
writeNote(window: WindowRef, input: NoteWriteInput): Promise<Note>;
|
|
86
|
+
/** One query over the journal, the notes and the board. */
|
|
87
|
+
search(window: WindowRef, query: string, scope?: readonly string[]): Promise<SearchOutcome>;
|
|
88
|
+
/** One send, through the effect queue. */
|
|
89
|
+
send(window: WindowRef, request: {
|
|
90
|
+
to: string;
|
|
91
|
+
content: string;
|
|
92
|
+
replyTo?: string;
|
|
93
|
+
cites?: readonly Ref[];
|
|
94
|
+
}): Promise<SendOutcome>;
|
|
95
|
+
/** The board. */
|
|
96
|
+
board(): Promise<Page<BoardLine>>;
|
|
97
|
+
setBoardLine(window: WindowRef, doing: string): Promise<void>;
|
|
98
|
+
expect(window: WindowRef, item?: string, description?: string): Promise<Expectation>;
|
|
99
|
+
unexpect(window: WindowRef, ref: Ref): Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* The channels the plugins registered, in the order they registered. A host
|
|
102
|
+
* no plugin plugged a channel into has none, and the `channels` module is
|
|
103
|
+
* absent (docs/tools.md#modules).
|
|
104
|
+
*/
|
|
105
|
+
channels(): readonly Channel[];
|
|
106
|
+
/** The live windows. */
|
|
107
|
+
windows(): Promise<readonly LiveWindow[]>;
|
|
108
|
+
/** A window's transcript, exactly as the model saw it. */
|
|
109
|
+
windowTranscript(window: WindowRef): Promise<readonly Event[]>;
|
|
110
|
+
/** A message to another live window. */
|
|
111
|
+
windowMessage(from: WindowRef, to: WindowRef, text: string, replyTo?: Ref): Promise<MessageRef>;
|
|
112
|
+
/** A message to every live window. */
|
|
113
|
+
broadcast(from: WindowRef, text: string): Promise<number>;
|
|
114
|
+
/** What is queued for this window and not inserted yet. */
|
|
115
|
+
inbox(window: WindowRef): Promise<readonly {
|
|
116
|
+
ref: Ref;
|
|
117
|
+
from: WindowRef;
|
|
118
|
+
text: string;
|
|
119
|
+
}[]>;
|
|
120
|
+
/** The tasks the host dispatched, newest first (docs/dispatch.md#tasks). */
|
|
121
|
+
tasks(limit?: number): Promise<readonly TaskSummary[]>;
|
|
122
|
+
/** The tasks the refs name, each with the outside events it batched. */
|
|
123
|
+
task(refs: readonly TaskRef[]): Promise<readonly TaskRecord[]>;
|
|
124
|
+
/** The scheduled tasks, soonest first (docs/dispatch.md#scheduled-tasks). */
|
|
125
|
+
schedules(): Promise<readonly ScheduledTask[]>;
|
|
126
|
+
/** Record one scheduled task; the ref it returns names it. */
|
|
127
|
+
schedule(input: ScheduledTaskInput): Promise<TaskRef>;
|
|
128
|
+
/** Change one scheduled task; null when the ref names none. */
|
|
129
|
+
updateSchedule(ref: TaskRef, changes: ScheduledTaskUpdate): Promise<ScheduledTask | null>;
|
|
130
|
+
/** Cancel one scheduled task. */
|
|
131
|
+
cancelSchedule(ref: TaskRef): Promise<void>;
|
|
132
|
+
/**
|
|
133
|
+
* The environment as the file tools read it: the prefix a window sees, and the
|
|
134
|
+
* directory the host reads it from. Undefined when none is configured.
|
|
135
|
+
*/
|
|
136
|
+
envPaths(): {
|
|
137
|
+
prefix: string;
|
|
138
|
+
directory: string;
|
|
139
|
+
} | undefined;
|
|
140
|
+
/** The environment as the catalog reads it: items, the MCP servers' modules. */
|
|
141
|
+
envCatalog(): EnvCatalog;
|
|
142
|
+
/** The item a path under the mount belongs to, when it is one. */
|
|
143
|
+
envItemAt(path: string): Promise<EnvItem | undefined>;
|
|
144
|
+
/** Record that a window read an item, so `reload` knows what its draft is based on. */
|
|
145
|
+
envRead(window: WindowRef, item: EnvItem): void;
|
|
146
|
+
/**
|
|
147
|
+
* `reload`: install drafts from the window's workspace and remove items, as
|
|
148
|
+
* one environment revision. A refusal changes nothing.
|
|
149
|
+
*/
|
|
150
|
+
reload(window: WindowRef, request: {
|
|
151
|
+
drafts: readonly EnvDraft[];
|
|
152
|
+
remove: readonly (EnvItemRef | EnvItemName)[];
|
|
153
|
+
}): Promise<ReloadOutcome>;
|
|
154
|
+
/** Call one function of an installed MCP server, from a script. */
|
|
155
|
+
callMcp(server: string, fn: string, args: Record<string, unknown>): Promise<string>;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The environment as the catalog reads it. The host keeps this in step with
|
|
159
|
+
* every change, so the modules a window was opened with plus every delta since
|
|
160
|
+
* are what `tool_search` and `tool_describe` answer
|
|
161
|
+
* (docs/environment.md#deltas-in-a-window).
|
|
162
|
+
*/
|
|
163
|
+
export interface EnvCatalog {
|
|
164
|
+
/** Every installed item: skills, servers, services and the settings. */
|
|
165
|
+
items: readonly EnvItem[];
|
|
166
|
+
/** One module per MCP server, declared with the functions it listed. */
|
|
167
|
+
modules: readonly ModuleSpec[];
|
|
168
|
+
/** Functions a delta removed, with the change that removed them. */
|
|
169
|
+
removed: readonly {
|
|
170
|
+
name: string;
|
|
171
|
+
ref: EnvRef;
|
|
172
|
+
}[];
|
|
173
|
+
}
|
|
174
|
+
/** What `reload` answers with: the revision it landed and what it changed. */
|
|
175
|
+
export interface ReloadOutcome {
|
|
176
|
+
revision: EnvRef;
|
|
177
|
+
changes: readonly {
|
|
178
|
+
change: "added" | "changed" | "removed";
|
|
179
|
+
item: EnvItem;
|
|
180
|
+
}[];
|
|
181
|
+
/** How many live windows the delta reached. */
|
|
182
|
+
delivered: number;
|
|
183
|
+
}
|
|
184
|
+
/** The context one call is made in, filled by the service layer. */
|
|
185
|
+
export declare function callContext(window: WindowRef, options?: {
|
|
186
|
+
task?: string;
|
|
187
|
+
workspace?: WorkspaceMount;
|
|
188
|
+
envRevision?: string;
|
|
189
|
+
call?: EventRef;
|
|
190
|
+
}): CallContext;
|
|
191
|
+
/** One refusal, in the core's error shape, as a tool result. */
|
|
192
|
+
export declare function refused(rule: string, table: TextTable, key: string, args?: Record<string, string | number>, refs?: readonly Ref[]): ToolResult;
|
|
193
|
+
/** A tool's spec, with its description taken from the table. */
|
|
194
|
+
export declare function toolSpec(table: TextTable, name: string, parameters: Record<string, Omit<ParameterSpec, "description">>): DirectToolSpec;
|
|
195
|
+
/** A module's spec, with its description taken from the table. */
|
|
196
|
+
export declare function moduleSpec(table: TextTable, name: string, functions: readonly {
|
|
197
|
+
name: string;
|
|
198
|
+
signature: string;
|
|
199
|
+
}[]): ModuleSpec;
|
package/host.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the entry points and the service layer
|
|
3
|
+
* (docs/architecture.md#layers, docs/tools.md#rules).
|
|
4
|
+
*
|
|
5
|
+
* A direct tool and a code mode function are thin wrappers of one service
|
|
6
|
+
* call: same arguments, same behaviour, same errors. The host API calls the
|
|
7
|
+
* same layer. So a group never reaches past these interfaces, and the service
|
|
8
|
+
* layer implements them once.
|
|
9
|
+
*/
|
|
10
|
+
/** The context one call is made in, filled by the service layer. */
|
|
11
|
+
export function callContext(window, options = {}) {
|
|
12
|
+
return {
|
|
13
|
+
window,
|
|
14
|
+
...(options.task === undefined ? {} : { task: options.task }),
|
|
15
|
+
...(options.workspace === undefined ? {} : { workspace: options.workspace }),
|
|
16
|
+
...(options.envRevision === undefined ? {} : { envRevision: options.envRevision }),
|
|
17
|
+
...(options.call === undefined ? {} : { call: options.call }),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** One refusal, in the core's error shape, as a tool result. */
|
|
21
|
+
export function refused(rule, table, key, args = {}, refs) {
|
|
22
|
+
return {
|
|
23
|
+
callId: "",
|
|
24
|
+
ok: false,
|
|
25
|
+
content: "",
|
|
26
|
+
error: {
|
|
27
|
+
rule,
|
|
28
|
+
message: messageOf(table, key, args),
|
|
29
|
+
nextAction: nextActionOf(table, key, args),
|
|
30
|
+
...(refs === undefined || refs.length === 0 ? {} : { refs }),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function messageOf(table, key, args) {
|
|
35
|
+
const message = lookup(table, `refused.${key}`, args);
|
|
36
|
+
return message === "" ? key : message;
|
|
37
|
+
}
|
|
38
|
+
function nextActionOf(table, key, args) {
|
|
39
|
+
const next = lookup(table, `next.${key}`, args);
|
|
40
|
+
return next === "" ? undefined : next;
|
|
41
|
+
}
|
|
42
|
+
function lookup(table, key, args) {
|
|
43
|
+
const template = table[key];
|
|
44
|
+
if (template === undefined)
|
|
45
|
+
return "";
|
|
46
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
|
47
|
+
const value = args[name];
|
|
48
|
+
return value === undefined ? match : String(value);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/** A tool's spec, with its description taken from the table. */
|
|
52
|
+
export function toolSpec(table, name, parameters) {
|
|
53
|
+
const described = {};
|
|
54
|
+
for (const [key, parameter] of Object.entries(parameters)) {
|
|
55
|
+
described[key] = { ...parameter, description: lookup(table, `param.${key}`, {}) };
|
|
56
|
+
}
|
|
57
|
+
return { name, description: lookup(table, `tool.${name}`, {}), parameters: described };
|
|
58
|
+
}
|
|
59
|
+
/** A module's spec, with its description taken from the table. */
|
|
60
|
+
export function moduleSpec(table, name, functions) {
|
|
61
|
+
return {
|
|
62
|
+
name,
|
|
63
|
+
description: lookup(table, `module.${name}`, {}),
|
|
64
|
+
functions: functions.map((fn) => ({
|
|
65
|
+
name: fn.name,
|
|
66
|
+
signature: fn.signature,
|
|
67
|
+
description: lookup(table, `function.${fn.name}`, {}),
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-tools - the entry points: direct tools, code mode modules and the one
|
|
3
|
+
* catalog that routes both through the service layer (docs/tools.md).
|
|
4
|
+
*
|
|
5
|
+
* Decisions and plain commands are direct tools, declared to the model and
|
|
6
|
+
* called in the turn. Everything else is a module function, called from a script
|
|
7
|
+
* in `code_exec`. There is no CLI, and there is no second path around the
|
|
8
|
+
* service layer.
|
|
9
|
+
*/
|
|
10
|
+
export { ToolCatalog, type ToolCatalogOptions } from "./catalog.ts";
|
|
11
|
+
export { SCRIPT_TIMEOUT_MS, directToolIn, runScript, type CodeExecContext, type ScriptCatalog, } from "./code-exec.ts";
|
|
12
|
+
export { RESULT_LIMIT, boundResult, boundText, nextCall, type Bound } from "./bound.ts";
|
|
13
|
+
export { callContext, moduleSpec, refused, toolSpec, type EnvCatalog, type LiveWindow, type ReloadOutcome, type SearchOutcome, type SendOutcome, type TaskRecord, type TaskSummary, type ToolServices, } from "./host.ts";
|
|
14
|
+
export { CoreToolGroup, handoffOf } from "./groups/core.ts";
|
|
15
|
+
export { EnvToolGroup } from "./groups/env.ts";
|
|
16
|
+
export { NoteToolGroup } from "./groups/notes.ts";
|
|
17
|
+
export { FileToolGroup } from "./groups/files.ts";
|
|
18
|
+
export { BoardModuleGroup, ChannelsModuleGroup, JournalModuleGroup, NotesModuleGroup, ScheduleModuleGroup, TasksModuleGroup, WindowsModuleGroup, WorkspaceModuleGroup, } from "./modules/modules.ts";
|
package/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @4fu/arbor-tools - the entry points: direct tools, code mode modules and the one
|
|
3
|
+
* catalog that routes both through the service layer (docs/tools.md).
|
|
4
|
+
*
|
|
5
|
+
* Decisions and plain commands are direct tools, declared to the model and
|
|
6
|
+
* called in the turn. Everything else is a module function, called from a script
|
|
7
|
+
* in `code_exec`. There is no CLI, and there is no second path around the
|
|
8
|
+
* service layer.
|
|
9
|
+
*/
|
|
10
|
+
export { ToolCatalog } from "./catalog.js";
|
|
11
|
+
export { SCRIPT_TIMEOUT_MS, directToolIn, runScript, } from "./code-exec.js";
|
|
12
|
+
export { RESULT_LIMIT, boundResult, boundText, nextCall } from "./bound.js";
|
|
13
|
+
export { callContext, moduleSpec, refused, toolSpec, } from "./host.js";
|
|
14
|
+
export { CoreToolGroup, handoffOf } from "./groups/core.js";
|
|
15
|
+
export { EnvToolGroup } from "./groups/env.js";
|
|
16
|
+
export { NoteToolGroup } from "./groups/notes.js";
|
|
17
|
+
export { FileToolGroup } from "./groups/files.js";
|
|
18
|
+
export { BoardModuleGroup, ChannelsModuleGroup, JournalModuleGroup, NotesModuleGroup, ScheduleModuleGroup, TasksModuleGroup, WindowsModuleGroup, WorkspaceModuleGroup, } from "./modules/modules.js";
|
package/modules/env.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment modules: `env`, `services`, and one module per installed MCP
|
|
3
|
+
* server (docs/tools.md#modules, docs/environment.md).
|
|
4
|
+
*
|
|
5
|
+
* A module whose backing feature is not configured is absent, so `env` and
|
|
6
|
+
* `services` appear only when the host has an environment and a supervisor. A
|
|
7
|
+
* capability is never a direct tool: an MCP server arrives as a module of its
|
|
8
|
+
* own, declared with the functions it listed when it was validated, and reaches
|
|
9
|
+
* a live window through an environment delta.
|
|
10
|
+
*/
|
|
11
|
+
import type { CallContext, ModuleSpec, ToolCall, ToolGroup, ToolResult } from "@4fu/arbor-interfaces";
|
|
12
|
+
import type { ToolServices } from "../host.ts";
|
|
13
|
+
export declare class EnvModuleGroup implements ToolGroup {
|
|
14
|
+
readonly id = "modules.env";
|
|
15
|
+
private readonly services;
|
|
16
|
+
constructor(services: ToolServices);
|
|
17
|
+
tools(): readonly [];
|
|
18
|
+
/** The modules this group declares: `env`, `services` and every MCP server. */
|
|
19
|
+
modules(): readonly ModuleSpec[];
|
|
20
|
+
call(request: ToolCall, _context: CallContext): Promise<ToolResult>;
|
|
21
|
+
/** `env.list`: the items and their revisions, one line each. */
|
|
22
|
+
private list;
|
|
23
|
+
/** `env.history(item?)`: one item's revisions, or the environment's. */
|
|
24
|
+
private history;
|
|
25
|
+
/** `services.list`: the services and their state. */
|
|
26
|
+
private services_;
|
|
27
|
+
/** `services.status(name)`: one service's real numbers. */
|
|
28
|
+
private status;
|
|
29
|
+
/** `services.logs(name)`: the tail of a service's own output. */
|
|
30
|
+
private logs;
|
|
31
|
+
/**
|
|
32
|
+
* One call into an MCP server: `weather.forecast({ city })` from a script.
|
|
33
|
+
* A function a delta removed is refused with the ref of the change that
|
|
34
|
+
* removed it, so the model knows where the answer is.
|
|
35
|
+
*/
|
|
36
|
+
private server;
|
|
37
|
+
/** One environment item's line: its ref, its name and its description. */
|
|
38
|
+
private answer;
|
|
39
|
+
private missing;
|
|
40
|
+
private absent;
|
|
41
|
+
private notOwned;
|
|
42
|
+
}
|
package/modules/env.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment modules: `env`, `services`, and one module per installed MCP
|
|
3
|
+
* server (docs/tools.md#modules, docs/environment.md).
|
|
4
|
+
*
|
|
5
|
+
* A module whose backing feature is not configured is absent, so `env` and
|
|
6
|
+
* `services` appear only when the host has an environment and a supervisor. A
|
|
7
|
+
* capability is never a direct tool: an MCP server arrives as a module of its
|
|
8
|
+
* own, declared with the functions it listed when it was validated, and reaches
|
|
9
|
+
* a live window through an environment delta.
|
|
10
|
+
*/
|
|
11
|
+
import { textOf } from "@4fu/arbor-prompt";
|
|
12
|
+
import { moduleSpec } from "../host.js";
|
|
13
|
+
export class EnvModuleGroup {
|
|
14
|
+
id = "modules.env";
|
|
15
|
+
services;
|
|
16
|
+
constructor(services) {
|
|
17
|
+
this.services = services;
|
|
18
|
+
}
|
|
19
|
+
tools() {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
/** The modules this group declares: `env`, `services` and every MCP server. */
|
|
23
|
+
modules() {
|
|
24
|
+
if (this.services.environment === undefined)
|
|
25
|
+
return [];
|
|
26
|
+
const catalog = this.services.envCatalog();
|
|
27
|
+
const table = this.services.table;
|
|
28
|
+
const modules = [moduleSpec(table, "env", [
|
|
29
|
+
{ name: "env.list", signature: "env.list()" },
|
|
30
|
+
{ name: "env.history", signature: "env.history(item?)" },
|
|
31
|
+
])];
|
|
32
|
+
if (this.services.supervisor !== undefined) {
|
|
33
|
+
modules.push(moduleSpec(table, "services", [
|
|
34
|
+
{ name: "services.list", signature: "services.list()" },
|
|
35
|
+
{ name: "services.status", signature: "services.status(name)" },
|
|
36
|
+
{ name: "services.logs", signature: "services.logs(name, tail?)" },
|
|
37
|
+
]));
|
|
38
|
+
}
|
|
39
|
+
for (const module of catalog.modules)
|
|
40
|
+
modules.push(module);
|
|
41
|
+
return modules;
|
|
42
|
+
}
|
|
43
|
+
async call(request, _context) {
|
|
44
|
+
if (this.services.environment === undefined)
|
|
45
|
+
return this.absent(request);
|
|
46
|
+
switch (request.name) {
|
|
47
|
+
case "env.list":
|
|
48
|
+
return this.list(request);
|
|
49
|
+
case "env.history":
|
|
50
|
+
return this.history(request);
|
|
51
|
+
case "services.list":
|
|
52
|
+
return this.services_(request);
|
|
53
|
+
case "services.status":
|
|
54
|
+
return this.status(request);
|
|
55
|
+
case "services.logs":
|
|
56
|
+
return this.logs(request);
|
|
57
|
+
default:
|
|
58
|
+
return this.server(request);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** `env.list`: the items and their revisions, one line each. */
|
|
62
|
+
async list(request) {
|
|
63
|
+
const table = this.services.table;
|
|
64
|
+
const items = (await this.services.environment?.list()) ?? [];
|
|
65
|
+
const lines = items.map((item) => itemLine(item, table));
|
|
66
|
+
return this.answer(request, lines);
|
|
67
|
+
}
|
|
68
|
+
/** `env.history(item?)`: one item's revisions, or the environment's. */
|
|
69
|
+
async history(request) {
|
|
70
|
+
const table = this.services.table;
|
|
71
|
+
const item = stringArg(request.args, "item");
|
|
72
|
+
const revisions = (await this.services.environment?.history(item === "" ? undefined : item)) ?? [];
|
|
73
|
+
const lines = revisions.map((revision) => textOf(table, "result.env.history", {
|
|
74
|
+
ref: revision.ref,
|
|
75
|
+
window: revision.window ?? "",
|
|
76
|
+
time: revision.time,
|
|
77
|
+
changes: revision.changes
|
|
78
|
+
.map((change) => `${change.change === "added" ? "+" : change.change === "removed" ? "-" : "~"}${change.item.name}`)
|
|
79
|
+
.join(", "),
|
|
80
|
+
}));
|
|
81
|
+
return this.answer(request, lines);
|
|
82
|
+
}
|
|
83
|
+
/** `services.list`: the services and their state. */
|
|
84
|
+
async services_(request) {
|
|
85
|
+
const table = this.services.table;
|
|
86
|
+
const supervisor = this.services.supervisor;
|
|
87
|
+
if (supervisor === undefined)
|
|
88
|
+
return this.absent(request);
|
|
89
|
+
const items = (await this.services.environment?.list()) ?? [];
|
|
90
|
+
const lines = [];
|
|
91
|
+
for (const item of items) {
|
|
92
|
+
if (item.kind !== "service")
|
|
93
|
+
continue;
|
|
94
|
+
const status = await supervisor.status(item.name).catch(() => null);
|
|
95
|
+
if (status === null) {
|
|
96
|
+
lines.push(textOf(table, "result.services.item", { name: item.name, state: "stopped", revision: item.revision, restarts: 0 }));
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
lines.push(textOf(table, "result.services.item", {
|
|
100
|
+
name: status.name,
|
|
101
|
+
state: status.state,
|
|
102
|
+
revision: status.revision,
|
|
103
|
+
restarts: status.restarts,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
return this.answer(request, lines);
|
|
107
|
+
}
|
|
108
|
+
/** `services.status(name)`: one service's real numbers. */
|
|
109
|
+
async status(request) {
|
|
110
|
+
const table = this.services.table;
|
|
111
|
+
const supervisor = this.services.supervisor;
|
|
112
|
+
if (supervisor === undefined)
|
|
113
|
+
return this.absent(request);
|
|
114
|
+
const name = stringArg(request.args, "name");
|
|
115
|
+
const found = await supervisor.status(name).catch(() => null);
|
|
116
|
+
if (found === null)
|
|
117
|
+
return this.missing(request, name);
|
|
118
|
+
return this.answer(request, [
|
|
119
|
+
textOf(table, "result.services.status", {
|
|
120
|
+
name: found.name,
|
|
121
|
+
state: found.state,
|
|
122
|
+
revision: found.revision,
|
|
123
|
+
ports: found.ports.join(", "),
|
|
124
|
+
restarts: found.restarts,
|
|
125
|
+
since: found.since,
|
|
126
|
+
}),
|
|
127
|
+
...(found.lastError === undefined ? [] : [`${found.lastError.rule} ${found.lastError.message}`]),
|
|
128
|
+
...(found.refs.length === 0 ? [] : [textOf(table, "error.refs", { refs: found.refs.join(", ") })]),
|
|
129
|
+
]);
|
|
130
|
+
}
|
|
131
|
+
/** `services.logs(name)`: the tail of a service's own output. */
|
|
132
|
+
async logs(request) {
|
|
133
|
+
const table = this.services.table;
|
|
134
|
+
const supervisor = this.services.supervisor;
|
|
135
|
+
if (supervisor === undefined)
|
|
136
|
+
return this.absent(request);
|
|
137
|
+
const name = stringArg(request.args, "name");
|
|
138
|
+
const tail = numberArg(request.args, "tail");
|
|
139
|
+
const page = await supervisor.logs(name, tail === undefined ? undefined : { tail }).catch(() => null);
|
|
140
|
+
if (page === null)
|
|
141
|
+
return this.missing(request, name);
|
|
142
|
+
return this.answer(request, [
|
|
143
|
+
textOf(table, "result.services.logs", { name: page.name, lines: page.total }),
|
|
144
|
+
...page.items,
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One call into an MCP server: `weather.forecast({ city })` from a script.
|
|
149
|
+
* A function a delta removed is refused with the ref of the change that
|
|
150
|
+
* removed it, so the model knows where the answer is.
|
|
151
|
+
*/
|
|
152
|
+
async server(request) {
|
|
153
|
+
const dot = request.name.indexOf(".");
|
|
154
|
+
const server = dot === -1 ? "" : request.name.slice(0, dot);
|
|
155
|
+
if (server === "")
|
|
156
|
+
return this.notOwned(request);
|
|
157
|
+
const catalog = this.services.envCatalog();
|
|
158
|
+
if (!catalog.modules.some((module) => module.name === server))
|
|
159
|
+
return this.notOwned(request);
|
|
160
|
+
try {
|
|
161
|
+
const content = await this.services.callMcp(server, request.name, request.args);
|
|
162
|
+
return { callId: request.callId, ok: true, content };
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const info = error;
|
|
166
|
+
return {
|
|
167
|
+
callId: request.callId,
|
|
168
|
+
ok: false,
|
|
169
|
+
content: "",
|
|
170
|
+
error: {
|
|
171
|
+
rule: info.rule ?? "mcp.call.failed",
|
|
172
|
+
message: info.message ?? String(error),
|
|
173
|
+
...(info.nextAction === undefined ? {} : { nextAction: info.nextAction }),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** One environment item's line: its ref, its name and its description. */
|
|
179
|
+
answer(request, lines) {
|
|
180
|
+
const table = this.services.table;
|
|
181
|
+
const body = lines.length === 0 ? textOf(table, "result.search.empty") : lines.join("\n");
|
|
182
|
+
const total = textOf(table, "result.search.total", { total: lines.length });
|
|
183
|
+
return { callId: request.callId, ok: true, content: `${body}\n${total}` };
|
|
184
|
+
}
|
|
185
|
+
missing(request, name) {
|
|
186
|
+
return {
|
|
187
|
+
callId: request.callId,
|
|
188
|
+
ok: false,
|
|
189
|
+
content: "",
|
|
190
|
+
error: {
|
|
191
|
+
rule: "service.notfound",
|
|
192
|
+
message: textOf(this.services.table, "refused.service.notfound", { name }),
|
|
193
|
+
nextAction: textOf(this.services.table, "next.service.notfound"),
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
absent(request) {
|
|
198
|
+
return {
|
|
199
|
+
callId: request.callId,
|
|
200
|
+
ok: false,
|
|
201
|
+
content: "",
|
|
202
|
+
error: {
|
|
203
|
+
rule: "env.absent",
|
|
204
|
+
message: textOf(this.services.table, "refused.env.absent"),
|
|
205
|
+
nextAction: textOf(this.services.table, "next.env.absent"),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
notOwned(request) {
|
|
210
|
+
return {
|
|
211
|
+
callId: request.callId,
|
|
212
|
+
ok: false,
|
|
213
|
+
content: "",
|
|
214
|
+
error: {
|
|
215
|
+
rule: "module.function.unknown",
|
|
216
|
+
message: textOf(this.services.table, "refused.unknown_function", { module: "env", name: request.name }),
|
|
217
|
+
nextAction: textOf(this.services.table, "next.unknown_function"),
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** One item's line, in the shape `<env>` uses, so a list reads like the prompt. */
|
|
223
|
+
function itemLine(item, table) {
|
|
224
|
+
return textOf(table, "result.env.item", {
|
|
225
|
+
ref: item.ref,
|
|
226
|
+
name: item.name,
|
|
227
|
+
description: item.description,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function stringArg(args, name) {
|
|
231
|
+
const value = args[name];
|
|
232
|
+
return typeof value === "string" ? value : "";
|
|
233
|
+
}
|
|
234
|
+
function numberArg(args, name) {
|
|
235
|
+
const value = args[name];
|
|
236
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
237
|
+
}
|