@matatbread/matbot-plugin-api 0.3.7 → 0.4.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/package.json +3 -2
- package/src/hooks.ts +6 -6
- package/src/host-machine.ts +191 -0
- package/src/host.ts +43 -0
- package/src/index.ts +27 -8
- package/src/invoke-tool.ts +25 -3
- package/src/notify.ts +242 -0
- package/src/plugin.ts +40 -205
- package/src/session.ts +30 -1
- package/src/store-query.ts +12 -0
- package/src/types/events.ts +81 -0
- package/src/types/files.ts +46 -0
- package/src/types/frontend.ts +11 -0
- package/src/types/health.ts +6 -0
- package/src/types/hooks.ts +217 -0
- package/src/types/knowledge.ts +27 -0
- package/src/types/messages.ts +154 -0
- package/src/types/primitives.ts +5 -0
- package/src/types/principal.ts +10 -0
- package/src/types/provider.ts +157 -0
- package/src/types/registries.ts +84 -0
- package/src/types/session-runner.ts +69 -0
- package/src/types/steering.ts +33 -0
- package/src/types/storage.ts +16 -0
- package/src/types/system-context.ts +15 -0
- package/src/types/tools.ts +262 -0
- package/src/types/typescript.ts +50 -0
- package/src/types/vault.ts +37 -0
- package/src/types.ts +27 -1101
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Message, MessageContent, Session } from './messages.js';
|
|
2
|
+
import type { PromptFn, Tool } from './tools.js';
|
|
3
|
+
|
|
4
|
+
// ── Pipeline hooks ────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
export interface RunConfig {
|
|
7
|
+
provider: string;
|
|
8
|
+
sessionId?: string;
|
|
9
|
+
traceId?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hooks are sorted by the *job* they do, not by lifecycle position — the channel name is the
|
|
14
|
+
* contract. Each channel has a fixed home, cadence, and effect-ceiling (a hook may always do
|
|
15
|
+
* less: returning nothing makes it a pure observer). The discriminated union on `on` is what
|
|
16
|
+
* keeps the effects honest — `contribute` hands you a read-only session, `toolcall`/`react` can't
|
|
17
|
+
* return one at all, so a write that goes nowhere won't type-check.
|
|
18
|
+
*
|
|
19
|
+
* screen runner, once per turn before the first provider call. The only channel that may
|
|
20
|
+
* durably mutate history. Returns any of: a replacement `session` (persisted),
|
|
21
|
+
* turn-scoped `ephemeral` context (appended onto the tail of this turn's outgoing
|
|
22
|
+
* messages — the freshest input the model reads — never persisted, and placed at the
|
|
23
|
+
* tail rather than a system prefix so a "do X now" directive keeps its salience and
|
|
24
|
+
* doesn't bust the cached prefix), `durable` context (the persisted, visible twin of
|
|
25
|
+
* `ephemeral`: folded onto this turn's user message as `origin: 'robo'` blocks and
|
|
26
|
+
* carried live as a `robo-user` event, so it survives into the next turn's history),
|
|
27
|
+
* or `abort`. Mix freely. This is where the durable-vs-ephemeral choice for incoming
|
|
28
|
+
* user input lives.
|
|
29
|
+
* contribute runner, before *every* provider call. Ephemeral by construction (it re-fires, so a
|
|
30
|
+
* durable mutation would accumulate). Returns a transformed copy of `outgoing` — the
|
|
31
|
+
* message array about to be sent — and never touches the stored session. Mind prompt
|
|
32
|
+
* caching: vary the *tail* (newest turn) freely, but a transform that rewrites the
|
|
33
|
+
* cached prefix (system / early history) busts the cache on every call.
|
|
34
|
+
* toolcall runner, before each tool execution. Read-only. Returns `rejectTool` (skip this call,
|
|
35
|
+
* feed an error result back so the model self-corrects, without breaking the
|
|
36
|
+
* tool_use/tool_result pairing) and/or `abort`.
|
|
37
|
+
* toolresult runner, after each tool execution, before the result is recorded/yielded. Folds the
|
|
38
|
+
* result through each hook: return `{ result }` to replace it (hard redaction, truncation),
|
|
39
|
+
* or nothing to observe (auditing — the context carries args, result, isError and
|
|
40
|
+
* `durationMs`). It owns the LLM-facing + persisted result and the `tool:end` event;
|
|
41
|
+
* note it does NOT see the live `tool:stdout/stderr` chunks, which stream before the
|
|
42
|
+
* result exists.
|
|
43
|
+
* followup pump, once after a turn commits (post-persist). May `resubmit` a robo follow-up turn
|
|
44
|
+
* (head-enqueued, so it runs next as its own real turn; `resubmitDepth` is the chain
|
|
45
|
+
* length for the hook's own budget — the runner also hard-caps it), `retractAndRerun`
|
|
46
|
+
* (pop the committed turn into a marker and re-run the originating user turn with
|
|
47
|
+
* ephemeral context — supersede rather than follow), and/or append durable `markers`
|
|
48
|
+
* to the committed session (LLM-invisible annotations — the second durable-write point
|
|
49
|
+
* after `screen`, safe because it too fires once per turn).
|
|
50
|
+
*/
|
|
51
|
+
export type HookPoint = 'screen' | 'contribute' | 'toolcall' | 'toolresult' | 'followup';
|
|
52
|
+
|
|
53
|
+
export interface ScreenContext {
|
|
54
|
+
session: Session;
|
|
55
|
+
config: RunConfig;
|
|
56
|
+
signal: AbortSignal;
|
|
57
|
+
/** The turn's interactive prompt, when a frontend supplied one (a live user behind this turn).
|
|
58
|
+
* A hook that drives an interactive tool (e.g. a trigger invoking `ask_user`) forwards this into
|
|
59
|
+
* the tool's context; absent (cron/background/no frontend) the tool gets a rejecting prompt. */
|
|
60
|
+
prompt?: PromptFn;
|
|
61
|
+
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
62
|
+
removeHook(): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A raced screen verdict the runner folds in WITHOUT gating the turn on it. A `screen` hook that starts
|
|
66
|
+
* expensive work concurrently (e.g. a triggers classifier judging the user message) returns immediately
|
|
67
|
+
* and hands back one of these instead of blocking on it. The runner polls `claim()` — synchronously,
|
|
68
|
+
* never awaiting — at each turn-loop edge (before a provider call, on every stream event, and just
|
|
69
|
+
* before committing). The first time `claim()` returns blocks (the work has settled WITH a correction),
|
|
70
|
+
* the runner DISCARDS the uncommitted in-progress response and re-runs the loop with those blocks
|
|
71
|
+
* tail-folded as ephemeral context — an in-situ redo: no store pop, no retraction marker, cleaner than a
|
|
72
|
+
* post-commit retract. Because the mid-stream poll runs before each event is emitted, a verdict faster
|
|
73
|
+
* than time-to-first-token is caught before any token reaches the frontend (the clean path); a slower
|
|
74
|
+
* one aborts the in-flight provider request, saving the unstreamed remainder. If it never fires before
|
|
75
|
+
* the turn commits, the turn commits normally and any correction is left to a post-commit `followup`.
|
|
76
|
+
*
|
|
77
|
+
* `claim()` MUST be exactly-once: return the correction on the first successful poll and `undefined`
|
|
78
|
+
* forever after (not settled, no correction, or already claimed). The hook uses that single delivery to
|
|
79
|
+
* coordinate with its own post-commit path — a claimed verdict is never also delivered by `followup`.
|
|
80
|
+
*/
|
|
81
|
+
export interface DeferredScreen {
|
|
82
|
+
claim(): DeferredCorrection | undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A claimed raced-verdict correction. `ephemeral` is tail-folded onto the re-run's outgoing messages
|
|
86
|
+
* and never persisted (the transient "for this answer only" twin); `durable` is folded onto the turn's
|
|
87
|
+
* user message — persisted, visible (the hook marks it `origin: 'robo'`), and carried live as a
|
|
88
|
+
* `robo-user` event — so it updates the conversation rather than informing one answer (a `contextual`
|
|
89
|
+
* trigger's semantics, preserved even though the verdict now lands mid-turn instead of before it). At
|
|
90
|
+
* least one is non-empty when returned.
|
|
91
|
+
*/
|
|
92
|
+
export interface DeferredCorrection {
|
|
93
|
+
ephemeral?: MessageContent[];
|
|
94
|
+
durable?: MessageContent[];
|
|
95
|
+
}
|
|
96
|
+
export interface ScreenResult {
|
|
97
|
+
session?: Session;
|
|
98
|
+
/**
|
|
99
|
+
* A raced verdict the runner folds in without gating on it (see {@link DeferredScreen}). Lets a
|
|
100
|
+
* screen hook race expensive work — a classifier — against generation: return immediately, hand this
|
|
101
|
+
* back, and the runner restarts the turn in-situ if the verdict fires before commit. A hook that
|
|
102
|
+
* would rather gate the turn (block until the verdict) just returns `ephemeral` as usual instead.
|
|
103
|
+
*/
|
|
104
|
+
deferred?: DeferredScreen;
|
|
105
|
+
/** Turn-scoped context appended onto the tail of this turn's outgoing messages (the freshest
|
|
106
|
+
* input the model reads), never persisted. At the tail, not a system prefix, so a directive
|
|
107
|
+
* keeps its salience and the cached system/history prefix stays stable across turns. */
|
|
108
|
+
ephemeral?: MessageContent[];
|
|
109
|
+
/**
|
|
110
|
+
* The persisted, visible twin of `ephemeral`: context that should outlive this turn rather than
|
|
111
|
+
* inform it once. The runner folds these blocks onto this turn's user message (so they ride into
|
|
112
|
+
* the stored history and every subsequent provider call) AND carries them live as a `robo-user`
|
|
113
|
+
* event, so a live draw and a reload render the same thing. They are LLM-visible (unlike
|
|
114
|
+
* `markers`) and machine-authored, so a caller marks them `origin: 'robo'` for presentation.
|
|
115
|
+
* Use when a fired hook produces context that genuinely updates the conversation, not a one-shot
|
|
116
|
+
* corrective for the turn about to run.
|
|
117
|
+
*/
|
|
118
|
+
durable?: MessageContent[];
|
|
119
|
+
/**
|
|
120
|
+
* Durable `marker` blocks to append to this turn's session (LLM-invisible). The dispatcher both
|
|
121
|
+
* appends them to the persisted session AND carries them live on the turn's event stream, so a
|
|
122
|
+
* live draw and a reload render the same thing. Use instead of hand-appending to `session` when
|
|
123
|
+
* you just want to annotate (e.g. a fired trigger's silent tool recording what it did).
|
|
124
|
+
*/
|
|
125
|
+
markers?: MessageContent[];
|
|
126
|
+
abort?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ContributeContext {
|
|
130
|
+
readonly outgoing: readonly Message[];
|
|
131
|
+
readonly session: Session;
|
|
132
|
+
config: RunConfig;
|
|
133
|
+
signal: AbortSignal;
|
|
134
|
+
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
135
|
+
removeHook(): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface ToolCallContext {
|
|
139
|
+
readonly session: Session;
|
|
140
|
+
readonly toolCall: { id: string; name: string; input: unknown };
|
|
141
|
+
readonly tool: Tool;
|
|
142
|
+
config: RunConfig;
|
|
143
|
+
signal: AbortSignal;
|
|
144
|
+
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
145
|
+
removeHook(): void;
|
|
146
|
+
}
|
|
147
|
+
export interface ToolCallResult {
|
|
148
|
+
rejectTool?: { message: string };
|
|
149
|
+
abort?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface ToolResultContext {
|
|
153
|
+
readonly session: Session;
|
|
154
|
+
readonly toolCall: { id: string; name: string; input: unknown };
|
|
155
|
+
readonly tool: Tool;
|
|
156
|
+
readonly result: unknown;
|
|
157
|
+
readonly isError: boolean;
|
|
158
|
+
readonly durationMs: number;
|
|
159
|
+
config: RunConfig;
|
|
160
|
+
signal: AbortSignal;
|
|
161
|
+
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
162
|
+
removeHook(): void;
|
|
163
|
+
}
|
|
164
|
+
// The toolresult hook returns `{ result }` to replace the tool's result, or nothing to leave it
|
|
165
|
+
// (and just observe) — a trivial single-field return, inlined in the Hook union like `contribute`'s.
|
|
166
|
+
|
|
167
|
+
export interface FollowupContext {
|
|
168
|
+
readonly session: Session;
|
|
169
|
+
readonly resubmitDepth: number;
|
|
170
|
+
config: RunConfig;
|
|
171
|
+
signal: AbortSignal;
|
|
172
|
+
/** The turn's interactive prompt, when a frontend supplied one (a live user behind this turn).
|
|
173
|
+
* A hook that drives an interactive tool (e.g. a trigger invoking `ask_user` as a proactive
|
|
174
|
+
* follow-up question) forwards this into the tool's context; absent (cron/background/no frontend)
|
|
175
|
+
* the tool gets a rejecting prompt. Note this prompt fires *post-commit*, out of band from the
|
|
176
|
+
* turn's `done`, and blocks the pump until the human answers. */
|
|
177
|
+
prompt?: PromptFn;
|
|
178
|
+
/** Unregister the hook currently running. For one-shot hooks that should fire at most once. */
|
|
179
|
+
removeHook(): void;
|
|
180
|
+
}
|
|
181
|
+
export interface FollowupResult {
|
|
182
|
+
resubmit?: { content: MessageContent[] };
|
|
183
|
+
/**
|
|
184
|
+
* Retract-and-rerun: supersede the just-committed turn instead of following it. The pump pops the
|
|
185
|
+
* committed turn back to (and excluding) the last user message, stashes the popped content in a
|
|
186
|
+
* durable retraction marker (LLM-elided like every marker, so a frontend can render it
|
|
187
|
+
* struck-through and a post-mortem can audit it), then re-runs that same user turn with `context`
|
|
188
|
+
* injected EPHEMERALLY (tail-folded, never persisted) — agent-phase injection time-shifted onto a
|
|
189
|
+
* committed turn. This is the inverse of `resubmit`, which leaves the response in place and appends
|
|
190
|
+
* a new robo turn after it. Self-terminating by design: a well-formed trigger fires on a *curable*
|
|
191
|
+
* defect that the injected context dissolves on the redo, so it won't re-fire; `resubmitDepth` (a
|
|
192
|
+
* redo carries parent+1) caps an ill-formed one. `resubmit` and `retractAndRerun` are independent
|
|
193
|
+
* capabilities — a single turn returning both is not expected, but both head-enqueue if it does.
|
|
194
|
+
*
|
|
195
|
+
* `context` is folded EPHEMERALLY onto the redo (for-this-answer-only). `durable` is instead folded
|
|
196
|
+
* onto the re-run's user message — persisted, visible (mark it `origin: 'robo'`), carried live as a
|
|
197
|
+
* `robo-user` event — so a `contextual`-kind correction updates the conversation durably even when it
|
|
198
|
+
* lands post-commit. At least one of the two is present.
|
|
199
|
+
*/
|
|
200
|
+
retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
|
|
201
|
+
/**
|
|
202
|
+
* Durable `marker` blocks to append to the just-committed session (LLM-invisible; for tracing /
|
|
203
|
+
* cross-references). The second durable-write capability after `screen` — safe here for the same
|
|
204
|
+
* reason: `followup` fires once per turn, so an append can't accumulate the way a per-call channel
|
|
205
|
+
* would. The pump persists them AND emits them live (post-commit, like a `queued` event) so a live
|
|
206
|
+
* draw matches a reload. Use for recording what a post-commit reaction (e.g. a fired trigger's
|
|
207
|
+
* silent tool) actually did.
|
|
208
|
+
*/
|
|
209
|
+
markers?: MessageContent[];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type Hook =
|
|
213
|
+
| { on: 'screen'; priority?: number; pluginName?: string; handler(ctx: ScreenContext): ScreenResult | void | Promise<ScreenResult | void> }
|
|
214
|
+
| { on: 'contribute'; priority?: number; pluginName?: string; handler(ctx: ContributeContext): Message[] | void | Promise<Message[] | void> }
|
|
215
|
+
| { on: 'toolcall'; priority?: number; pluginName?: string; handler(ctx: ToolCallContext): ToolCallResult | void | Promise<ToolCallResult | void> }
|
|
216
|
+
| { on: 'toolresult'; priority?: number; pluginName?: string; handler(ctx: ToolResultContext): { result: unknown } | void | Promise<{ result: unknown } | void> }
|
|
217
|
+
| { on: 'followup'; priority?: number; pluginName?: string; handler(ctx: FollowupContext): FollowupResult | void | Promise<FollowupResult | void> };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// ── Knowledge index ───────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface KnowledgeEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
version: string;
|
|
6
|
+
entities: string[];
|
|
7
|
+
tags: string[];
|
|
8
|
+
summary: string;
|
|
9
|
+
content: string;
|
|
10
|
+
contentHash?: string;
|
|
11
|
+
source: { type: string; uuid: string };
|
|
12
|
+
confidence?: number;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
updatedAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface KnowledgeIndex {
|
|
18
|
+
index(entry: KnowledgeEntry): Promise<void>;
|
|
19
|
+
/** Remove the entry with this id, if present (idempotent — removing an absent id is a no-op). The
|
|
20
|
+
* id is the index's sole primary key (`index` replaces by it), so retraction is by id alone; the
|
|
21
|
+
* index never inspects an entry's opaque `source`. The party that indexed an entry owns retracting
|
|
22
|
+
* it — e.g. a skill manager retracts a hidden/deleted skill rather than the index policing tenants. */
|
|
23
|
+
remove(id: string): Promise<void>;
|
|
24
|
+
search(terms: Array<{ term: string; context?: string }>, signal: AbortSignal): Promise<KnowledgeEntry[]>;
|
|
25
|
+
/** Enumerate all indexed entries. When present, register('KnowledgeIndex', …) drains these into the incoming backend. */
|
|
26
|
+
entries?(): Iterable<KnowledgeEntry>;
|
|
27
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { HookPoint } from './hooks.js';
|
|
2
|
+
import type { ISODate, MimeType } from './primitives.js';
|
|
3
|
+
import type { ProviderMeta, Usage, UsageRecord } from './provider.js';
|
|
4
|
+
|
|
5
|
+
// ── Messages & Session ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export type MessageRole = 'user' | 'assistant' | 'tool' | 'system' | 'marker';
|
|
8
|
+
|
|
9
|
+
export type MessageContent = (
|
|
10
|
+
// ── prose and reasoning: what the model said ──
|
|
11
|
+
| { type: 'text'; text: string }
|
|
12
|
+
| { type: 'thinking'; thinking: string; signature: string }
|
|
13
|
+
| { type: 'redacted-thinking'; data: string }
|
|
14
|
+
| { type: 'reasoning'; reasoning: string }
|
|
15
|
+
| { type: 'refusal'; text: string }
|
|
16
|
+
|
|
17
|
+
// ── inline media: bytes plus a mime type. The first three are also `ModelContent` — the arms a tool
|
|
18
|
+
// may hand the model to look at (see the `model-content` ToolEvent). `image-url` and `file-ref`
|
|
19
|
+
// are references rather than data, and nothing produces them yet; they are reconsidered together
|
|
20
|
+
// when durable user-supplied media is built (a `document` / `document-reference` split). ──
|
|
21
|
+
| { type: 'image'; data: string; mimeType: MimeType }
|
|
22
|
+
| { type: 'document'; data: string; mimeType: MimeType; name?: string }
|
|
23
|
+
| { type: 'audio'; data: string; mimeType: MimeType }
|
|
24
|
+
| { type: 'image-url'; url: string; detail?: 'low' | 'high' | 'auto' }
|
|
25
|
+
| { type: 'file-ref'; fileId: string; name: string; mimeType: MimeType }
|
|
26
|
+
|
|
27
|
+
// ── tool use ──
|
|
28
|
+
| { type: 'tool-call'; id: string; name: string; input: unknown;
|
|
29
|
+
/** Provider-specific round-trip metadata, persisted and re-sent verbatim on replay — see
|
|
30
|
+
* `ProviderMeta` and the `tool-call` `CompletionEvent`. */
|
|
31
|
+
meta?: ProviderMeta }
|
|
32
|
+
| { type: 'tool-result'; id: string; result: unknown; isError?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Accounting addendum: completions the tool itself ran (e.g. `single_turn`, `dream_time`'s ranker/
|
|
35
|
+
* merger), captured ambiently via the usage carrier and recorded for cost reckoning. Elided from
|
|
36
|
+
* provider submission — adapters serialise only `id`/`result`/`isError`. One entry per provider
|
|
37
|
+
* call, each tagged with the provider billed; never summed at write time, as rates differ.
|
|
38
|
+
*/
|
|
39
|
+
usage?: UsageRecord[] }
|
|
40
|
+
|
|
41
|
+
// ── frontend-facing: rendered to a person, never sent to the model ──
|
|
42
|
+
| { type: 'form'; fields: FormField[]; submitLabel?: string }
|
|
43
|
+
| { type: 'form-response'; values: Record<string, string> }
|
|
44
|
+
// Durable, opaque annotation — persisted unchanged, elided from every submission. See MarkerData below.
|
|
45
|
+
| { type: 'marker'; creator: string; data: unknown }
|
|
46
|
+
|
|
47
|
+
// ── a provider block this build does not know: preserved verbatim rather than dropped ──
|
|
48
|
+
| { type: 'unknown-content'; blockType: string; raw: unknown }
|
|
49
|
+
) & {
|
|
50
|
+
/**
|
|
51
|
+
* Authorship provenance, for *presentation only* — orthogonal to the message's `role`, which is
|
|
52
|
+
* the LLM-protocol identity. Absent ⇒ authored per the role (a human for `user`, the model for
|
|
53
|
+
* `assistant`). `'robo'` ⇒ machine-authored by matbot — a `followup` resubmission, or a hook-
|
|
54
|
+
* injected fragment inside a human turn. It is still carried to the model as ordinary
|
|
55
|
+
* role-appropriate content (the model sees a `user` block either way); the flag is OOB metadata
|
|
56
|
+
* that frontends use to present it agent-side (a robot indicator) rather than as the user's words.
|
|
57
|
+
*/
|
|
58
|
+
origin?: 'robo';
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A marker is opaque, durable annotation carried in the message stream: links, status,
|
|
63
|
+
* cross-references that are meaningful to a frontend but transparent to the LLM. They are
|
|
64
|
+
* persisted unchanged, elided from provider submission, and deliberately preserved by
|
|
65
|
+
* session compaction (removing one can break things — e.g. a pointer back to an ancestor
|
|
66
|
+
* session). Any code with session access may emit one, normally as its own message.
|
|
67
|
+
*
|
|
68
|
+
* `creator` is the emitting plugin's reference; `data` is anything serialisable. For
|
|
69
|
+
* per-creator type safety, augment `MarkerData` and read/write via `Marker<'your-creator'>`:
|
|
70
|
+
*
|
|
71
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
72
|
+
* interface MarkerData { 'split-session': { peerSessionId: string } }
|
|
73
|
+
* }
|
|
74
|
+
*
|
|
75
|
+
* Unregistered creators fall back to `data: unknown`. The base `MessageContent` member stays
|
|
76
|
+
* loose (`creator: string`) so the union and its exhaustive switches are unaffected.
|
|
77
|
+
*
|
|
78
|
+
* One of matbot's five open-registry augmentation points — same technique at each; see
|
|
79
|
+
* docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
|
|
80
|
+
*/
|
|
81
|
+
export interface MarkerData {
|
|
82
|
+
/** Emitted by the hook dispatcher when a hook handler threw: the hook was skipped (treated as a
|
|
83
|
+
* no-op) and this records it once, so a misconfigured/throwing hook degrades visibly instead of
|
|
84
|
+
* bricking the turn. `channel` is the hook point, `pluginName` the owning plugin if known. */
|
|
85
|
+
'matbot-hooks': { channel: HookPoint; pluginName?: string; message: string };
|
|
86
|
+
/** Emitted by the runner when a provider call was cut short rather than finishing (see the
|
|
87
|
+
* `truncated` CompletionEvent). Marker-role so the reader and an audit see it while the model does
|
|
88
|
+
* not — the model's own text is already truncated in the transcript; a block telling it so would
|
|
89
|
+
* invite it to narrate the cut-off rather than continue past it. */
|
|
90
|
+
'matbot-truncation': { reason: 'max-tokens' | 'stream-end'; raw?: string };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Marker<K extends string = string> = {
|
|
94
|
+
type: 'marker';
|
|
95
|
+
creator: K;
|
|
96
|
+
data: K extends keyof MarkerData ? MarkerData[K] : unknown;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export interface FormField {
|
|
100
|
+
name: string;
|
|
101
|
+
label: string;
|
|
102
|
+
type: 'text' | 'password' | 'select' | 'confirm';
|
|
103
|
+
options?: string[];
|
|
104
|
+
/** select-only: render an "Other…" affordance that lets the user type a free-form answer instead
|
|
105
|
+
* of picking an option. The typed value is returned verbatim, on the same channel as a pick —
|
|
106
|
+
* callers never learn whether the answer was a preset or free text. Ignored for other types. */
|
|
107
|
+
allowOther?: boolean;
|
|
108
|
+
default?: string;
|
|
109
|
+
required?: boolean;
|
|
110
|
+
/** Presentation hint only (default true): whether the frontend offers a cancel affordance (the
|
|
111
|
+
* "give up" path — see `PromptFn`). Set false to render a hard-blocking prompt with no out. The
|
|
112
|
+
* runner and server never consult this; a frontend with no cancel UI simply can't fire one. */
|
|
113
|
+
cancelable?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Canonical values a `type: 'confirm'` prompt resolves to. The rendered label and buttons are a
|
|
118
|
+
* cosmetic, host-specific concern (and may be localised), so consumers MUST branch on these tokens
|
|
119
|
+
* — never on the displayed string. Compare case-insensitively to tolerate host casing differences.
|
|
120
|
+
*/
|
|
121
|
+
export const CONFIRM_YES = 'yes';
|
|
122
|
+
export const CONFIRM_NO = 'no';
|
|
123
|
+
|
|
124
|
+
export interface Message {
|
|
125
|
+
id: string;
|
|
126
|
+
role: MessageRole;
|
|
127
|
+
content: MessageContent[];
|
|
128
|
+
createdAt: ISODate;
|
|
129
|
+
traceId: string;
|
|
130
|
+
providerName?: string;
|
|
131
|
+
/** Token/cost accounting for the provider call that produced this message (assistant turns). The
|
|
132
|
+
* billed provider is this message's `providerName`. Accounting only — elided from provider submission. */
|
|
133
|
+
usage?: Usage;
|
|
134
|
+
metadata?: Record<string, unknown>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export type SessionStatus = 'active' | 'archived' | 'pinned';
|
|
138
|
+
|
|
139
|
+
export interface Session {
|
|
140
|
+
id: string;
|
|
141
|
+
version: string;
|
|
142
|
+
// Legacy: persisted sessions may still carry `ownerPrincipalId` / `actorPrincipalId` / `persona`, and
|
|
143
|
+
// `contexts` (a required-but-never-read `string[]`, dropped at 0.4.0 — system context is contributed by
|
|
144
|
+
// SystemContextContributor, not carried on the session). Never read — ownership-at-rest is structural
|
|
145
|
+
// (the storage partition), resolved via the backend, not a field. Left undeclared; old data keeps them
|
|
146
|
+
// harmlessly as excess properties.
|
|
147
|
+
title?: string;
|
|
148
|
+
status: SessionStatus;
|
|
149
|
+
messages: Message[];
|
|
150
|
+
parentSessionId?: string;
|
|
151
|
+
branchPointMessageId?: string;
|
|
152
|
+
createdAt: ISODate;
|
|
153
|
+
updatedAt: ISODate;
|
|
154
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// ── Principal ───────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/** Identity of whatever initiated an operation — a turn, a tool call, a store read/write.
|
|
4
|
+
* Established at each entry point and carried ambiently via the `PrincipalCarrier`
|
|
5
|
+
* (`currentPrincipal()` / `runAs()`), so any layer can attribute or test the origin without it
|
|
6
|
+
* being threaded through every signature. It grants nothing — policy is the service's concern. */
|
|
7
|
+
export interface Principal {
|
|
8
|
+
id: string;
|
|
9
|
+
type: 'user' | 'agent' | 'system';
|
|
10
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { HealthStatus } from './health.js';
|
|
2
|
+
import type { Message } from './messages.js';
|
|
3
|
+
import type { Tool } from './tools.js';
|
|
4
|
+
|
|
5
|
+
// ── Provider ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface ModelParameters {
|
|
8
|
+
temperature?: number;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
topP?: number;
|
|
11
|
+
stopSequences?: string[];
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProviderConfig {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Plugin module specifier (npm name or file URL) that provides the adapter for this provider. */
|
|
18
|
+
module: string;
|
|
19
|
+
model: string;
|
|
20
|
+
credentials?: Record<string, string>;
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
parameters?: ModelParameters;
|
|
23
|
+
/**
|
|
24
|
+
* Ceiling on the agentic rounds one turn may take against this provider — a round being one provider
|
|
25
|
+
* call plus the tool batch it asked for. Reaching it ends the turn (`aborted`, reason `round-limit`)
|
|
26
|
+
* rather than starting another round. Absent ⇒ unbounded, which is the historical behaviour.
|
|
27
|
+
*
|
|
28
|
+
* It lives here, per provider, rather than as one global or a per-call override because that is the
|
|
29
|
+
* unit spend is actually denominated in: a local model can afford to grind, a frontier model at 100×
|
|
30
|
+
* the rate cannot, and the same deployment runs both. Not in `parameters` — those are forwarded to
|
|
31
|
+
* the endpoint unmodified, and this never leaves matbot.
|
|
32
|
+
*/
|
|
33
|
+
maxRounds?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Token (and optional cost) counters for one provider call — pure accounting data, never conversation
|
|
38
|
+
* content. Recorded onto the message stream for cost reckoning but elided from provider submission. The
|
|
39
|
+
* producing provider is recorded alongside at each storage site (an assistant turn carries it on the
|
|
40
|
+
* message's `providerName`; a tool-invoked completion pairs it explicitly — see `tool-result`), since a
|
|
41
|
+
* tool may run a completion against any configured provider, each with its own rates.
|
|
42
|
+
*/
|
|
43
|
+
export interface Usage {
|
|
44
|
+
inputTokens: number;
|
|
45
|
+
outputTokens: number;
|
|
46
|
+
costUsd?: number;
|
|
47
|
+
cacheReadTokens?: number;
|
|
48
|
+
cacheCreationTokens?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One provider call's usage, tagged with the provider billed — the unit a tool accrues (a tool may
|
|
52
|
+
* run completions against any provider, each with its own rates) and the element persisted on a
|
|
53
|
+
* `tool-result`'s `usage` addendum. See the ambient usage carrier (`recordUsage`/`currentUsageSink`). */
|
|
54
|
+
export interface UsageRecord {
|
|
55
|
+
provider: string;
|
|
56
|
+
usage: Usage;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Opaque, provider-specific round-trip metadata attached to a tool-call — captured from a completion,
|
|
61
|
+
* persisted, and re-sent verbatim when the call is replayed in history (e.g. a Gemini 3 "thought
|
|
62
|
+
* signature", which every historical `functionCall` must carry or the request 400s). The harness never
|
|
63
|
+
* interprets it: it stores, renders, or elides it. Empty by default — a provider package augments its
|
|
64
|
+
* OWN slice from its own module (namespaced by provider family, so slices from different providers in a
|
|
65
|
+
* mixed-provider session never collide), so adding a provider that needs round-trip state touches NO
|
|
66
|
+
* core code:
|
|
67
|
+
*
|
|
68
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
69
|
+
* interface ProviderMeta { google?: { thoughtSignature?: string } }
|
|
70
|
+
* }
|
|
71
|
+
*
|
|
72
|
+
* A single session interleaves tool-calls from many providers, so this must be an open, additive union
|
|
73
|
+
* (interface augmentation) — a generic type parameter can bind only one provider per instantiation and
|
|
74
|
+
* so could not type a heterogeneous transcript.
|
|
75
|
+
*
|
|
76
|
+
* One of matbot's five open-registry augmentation points — same technique at each; see
|
|
77
|
+
* docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
|
|
78
|
+
*/
|
|
79
|
+
export interface ProviderMeta {}
|
|
80
|
+
|
|
81
|
+
export type CompletionEvent =
|
|
82
|
+
| { type: 'text-delta'; delta: string }
|
|
83
|
+
| { type: 'tool-call'; id: string; name: string; input: unknown;
|
|
84
|
+
/** Provider-specific round-trip metadata for this call, opaque to the harness — see `ProviderMeta`. */
|
|
85
|
+
meta?: ProviderMeta;
|
|
86
|
+
/**
|
|
87
|
+
* Set when the call's arguments were severed mid-stream and never parsed — nearly always the
|
|
88
|
+
* response hitting its token limit part-way through a large call. `input` is then `{}` (the wire
|
|
89
|
+
* requires an object; the real arguments are unrecoverable), and the runner does NOT execute the
|
|
90
|
+
* call: it pairs it with an error result saying so, exactly as it does a rejected one, and the
|
|
91
|
+
* model self-corrects on the next round.
|
|
92
|
+
*
|
|
93
|
+
* Only adapters that accumulate arguments as a streamed JSON *string* can detect this — the
|
|
94
|
+
* Anthropic (`input_json_delta`) and OpenAI (`function.arguments` fragments) shapes. Gemini
|
|
95
|
+
* delivers each `functionCall` complete with `args` already an object, so there is nothing to
|
|
96
|
+
* sever and the field is never set there.
|
|
97
|
+
*/
|
|
98
|
+
truncated?: { bytes: number; stopReason?: string } }
|
|
99
|
+
| { type: 'tool-result'; id: string; result: unknown }
|
|
100
|
+
| { type: 'thinking'; delta: string }
|
|
101
|
+
| { type: 'thinking-block'; thinking: string; signature: string }
|
|
102
|
+
| { type: 'redacted-thinking'; data: string }
|
|
103
|
+
| { type: 'reasoning-block'; reasoning: string }
|
|
104
|
+
| { type: 'refusal'; text: string }
|
|
105
|
+
| { type: 'unknown-block'; blockType: string; raw: unknown }
|
|
106
|
+
/**
|
|
107
|
+
* The response was cut short — the model did not choose to stop. `max-tokens` is the provider saying
|
|
108
|
+
* so outright (`stop_reason: "max_tokens"` / `finish_reason: "length"` / `finishReason: "MAX_TOKENS"`);
|
|
109
|
+
* `stream-end` is the stream ending with no finish reason at all, e.g. a dropped connection.
|
|
110
|
+
*
|
|
111
|
+
* Emitted whether or not a tool call was caught in it, because the far commoner case has no tool call
|
|
112
|
+
* in it at all: prose stopping mid-sentence, which matbot previously did not surface anywhere. The
|
|
113
|
+
* runner records it as a durable `matbot-truncation` marker — LLM-invisible, so it informs the reader
|
|
114
|
+
* and the audit without the model narrating its own cut-off. Acting on it (continuing, re-asking with
|
|
115
|
+
* a larger budget) is a `followup` hook's business, not the harness's.
|
|
116
|
+
*/
|
|
117
|
+
| { type: 'truncated'; reason: 'max-tokens' | 'stream-end'; raw?: string }
|
|
118
|
+
| ({ type: 'usage' } & Usage)
|
|
119
|
+
| { type: 'done' };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The result the runner records for a tool call whose arguments were truncated (see the `truncated`
|
|
123
|
+
* field on the `tool-call` event). The call did not run and has no side effect.
|
|
124
|
+
*
|
|
125
|
+
* The `truncated` addendum is what makes this distinguishable from an ordinary tool failure, so a
|
|
126
|
+
* `toolresult` hook can recognise it and fold in advice the harness has no business knowing — which
|
|
127
|
+
* argument to split, which of *this* tool's parameters offers a cheaper edit. Narrow with
|
|
128
|
+
* {@link isTruncatedToolResult} rather than duck-typing the shape:
|
|
129
|
+
*
|
|
130
|
+
* services.hooks.register({ on: 'toolresult', handler: ctx => {
|
|
131
|
+
* if (!isTruncatedToolResult(ctx.result)) return;
|
|
132
|
+
* return { result: { ...ctx.result, error: `${ctx.result.error} For a large edit, pass start_line/end_line instead.` } };
|
|
133
|
+
* }});
|
|
134
|
+
*/
|
|
135
|
+
export interface TruncatedToolResult {
|
|
136
|
+
error: string;
|
|
137
|
+
truncated: { tool: string; bytes: number; stopReason?: string };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isTruncatedToolResult(result: unknown): result is TruncatedToolResult {
|
|
141
|
+
if (typeof result !== 'object' || result === null) return false;
|
|
142
|
+
const t = (result as { truncated?: unknown }).truncated;
|
|
143
|
+
return typeof t === 'object' && t !== null
|
|
144
|
+
&& typeof (t as { tool?: unknown }).tool === 'string'
|
|
145
|
+
&& typeof (t as { bytes?: unknown }).bytes === 'number';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ProviderAdapter {
|
|
149
|
+
readonly name: string;
|
|
150
|
+
complete(
|
|
151
|
+
messages: Message[],
|
|
152
|
+
config: ProviderConfig,
|
|
153
|
+
tools: readonly Tool[],
|
|
154
|
+
signal: AbortSignal
|
|
155
|
+
): AsyncIterable<CompletionEvent>;
|
|
156
|
+
health(): Promise<HealthStatus>;
|
|
157
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Hook } from './hooks.js';
|
|
2
|
+
import type { Session } from './messages.js';
|
|
3
|
+
import type { ProviderConfig } from './provider.js';
|
|
4
|
+
import type { Tool } from './tools.js';
|
|
5
|
+
|
|
6
|
+
// ── Registries ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `services.hooks` is: the *registration* half of hook dispatch, and the whole of what a plugin can
|
|
10
|
+
* do with it. `MatbotRuntime.hooks` was typed as the `HookRegistry` class, so the plugin contract also
|
|
11
|
+
* advertised the host's dispatch surface (`runScreen`, `runContribute`, `runToolCall`, `runToolResult`,
|
|
12
|
+
* `runFollowup`) — none of which a plugin may call, and which the scoped per-plugin facade had to be
|
|
13
|
+
* `as unknown as`-cast to satisfy. The cast was the tell. Core still holds the class where it dispatches.
|
|
14
|
+
*/
|
|
15
|
+
export interface HookRegistrar {
|
|
16
|
+
register(hook: Hook): void;
|
|
17
|
+
removeByPlugin(pluginName: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Tool CRUD is observed on the {@link Notifier}, as a `RegistryChange` with `registry: 'tools'` — this
|
|
21
|
+
* registry had its own broadcaster over the same primitive, which is duplication, not layering. One
|
|
22
|
+
* notification per tool (removeByPlugin announces a `removed` per matched tool); the registering
|
|
23
|
+
* plugin's name rides in `detail`, advisory as ever — resolve the name for anything authoritative. */
|
|
24
|
+
export interface ToolRegistry {
|
|
25
|
+
/** Registers `tool` and returns — there is nothing to await. The name is free in the overwhelming
|
|
26
|
+
* case, and that path completes in the calling tick, so `setup()` can fire-and-forget. When a
|
|
27
|
+
* *different* plugin already owns the name the host may resolve it out of band (it can ask the user),
|
|
28
|
+
* in which case the tool lands after `setup()` has returned, or not at all if the user keeps the
|
|
29
|
+
* incumbent. The host owns that outcome — a failure to resolve keeps the existing tool, and neither
|
|
30
|
+
* case can reject into the caller. Registration order across plugins is not a contract; resolve by
|
|
31
|
+
* name at call time. */
|
|
32
|
+
register(tool: Tool): void;
|
|
33
|
+
remove(name: string): void;
|
|
34
|
+
resolve(name: string): Tool | null;
|
|
35
|
+
list(): readonly Tool[];
|
|
36
|
+
removeByPlugin(pluginName: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the runner tells a {@link ToolPresenter} about the call it's about to make. */
|
|
40
|
+
export interface PresentContext {
|
|
41
|
+
/** The session as of this provider call — the persisted user turn plus any assistant/tool turns
|
|
42
|
+
* taken so far this turn. A presenter ranks the tool set against this. */
|
|
43
|
+
session: Session;
|
|
44
|
+
/** The provider profile name this turn runs under, so a presenter can tailor to provider
|
|
45
|
+
* capabilities (e.g. Anthropic's in-context tool_reference expansion). */
|
|
46
|
+
provider: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Chooses which tools are advertised to the model for a single provider call — a read-only *view* over
|
|
51
|
+
* the turn's tool snapshot, NOT tool management. Presentation is orthogonal to the {@link ToolRegistry},
|
|
52
|
+
* which stays the single source of registration, resolution, and type-contracts (its fragile, load-bearing
|
|
53
|
+
* machinery is never a swap surface). The runner consults the presenter before *every* provider call, so
|
|
54
|
+
* an impl may present a subset (deferring a large library behind a search tool) and grow it mid-turn as the
|
|
55
|
+
* model discovers tools. Executor resolution is unaffected — a tool withheld here stays callable by name
|
|
56
|
+
* through the live registry — so presentation only ever changes what the model is *told about*.
|
|
57
|
+
*
|
|
58
|
+
* Optional {@link MatbotServices} member (offer-loosely): absent ⇒ the runner advertises the whole
|
|
59
|
+
* snapshot, byte-identical to today. `present` may return synchronously or async (a reranker).
|
|
60
|
+
*/
|
|
61
|
+
export interface ToolPresenter {
|
|
62
|
+
present(tools: readonly Tool[], ctx: PresentContext): readonly Tool[] | Promise<readonly Tool[]>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The named provider profiles the runtime resolves by name. A `ReadonlyMap` for the many read-only
|
|
67
|
+
* consumers (`services.providers.get`/`has`/`keys`/…), plus a sanctioned write path so a plugin can
|
|
68
|
+
* *contribute* profiles live — a storage backend replaying provider definitions it captured in its own
|
|
69
|
+
* medium, or the built-in `provider` tool — exactly as a plugin contributes tools via {@link ToolRegistry}.
|
|
70
|
+
* Keyed by `config.name`; `register` upserts.
|
|
71
|
+
*/
|
|
72
|
+
export interface ProviderRegistry extends ReadonlyMap<string, ProviderConfig> {
|
|
73
|
+
register(config: ProviderConfig): void;
|
|
74
|
+
/** Explicit delete (the `provider` tool's `remove`) — removes the profile outright. */
|
|
75
|
+
remove(name: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Undo a contribution: restore the boot-baseline profile for `name` if one existed (a plugin-supplied
|
|
78
|
+
* profile that shadowed a configured one), otherwise delete it. A plugin that registers providers
|
|
79
|
+
* (e.g. a storage backend replaying them from its medium) calls this for each in its `teardown()`, so
|
|
80
|
+
* unloading it reverts the provider set to the host's boot condition — the multi-valued analogue of a
|
|
81
|
+
* swap-member reverting to its captured boot default.
|
|
82
|
+
*/
|
|
83
|
+
revert(name: string): void;
|
|
84
|
+
}
|