@matatbread/matbot-core 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,64 @@
1
+ import type { Tool, ToolExecutor, ToolContext, ToolEvent, MatbotMachine } from '@matatbread/matbot-plugin-api';
2
+
3
+ /**
4
+ * Exposes {@link MatbotMachine.singleTurn} to the model: a one-shot completion against a configured
5
+ * provider, returning its reply. The intended use is consulting another model (e.g. a different-lineage
6
+ * critic of the current draft, or any generation that should run on a specific provider) with a
7
+ * well-defined interface, rather than the model improvising a bash/curl call.
8
+ *
9
+ * `provider` is optional: omitted, the call runs on the current turn's provider ({@link ToolContext.provider}).
10
+ * This is the general case — name a provider to switch models, or leave it off to relay through the
11
+ * model already in use. It lives in core (not in any plugin) because it is the model-facing surface of
12
+ * a core service (`singleTurn`); core is also the one cross-runtime home both the node app and the
13
+ * browser bundle register it from — the tool-plugin barrel (where `plugin`/`provider` live) is node-only.
14
+ */
15
+ export function createSingleTurnTool(services: MatbotMachine): Tool {
16
+ const executor: ToolExecutor = {
17
+ async *execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent> {
18
+ const args = input as { provider?: string; prompt?: string; system?: string };
19
+ if (typeof args.prompt !== 'string') { yield { type: 'error', message: 'single_turn requires a string "prompt".' }; return; }
20
+ const provider = args.provider ?? ctx.provider;
21
+ if (!provider) {
22
+ yield { type: 'error', message: 'single_turn needs a "provider" — none was given and there is no current turn provider to fall back to.' };
23
+ return;
24
+ }
25
+ if (!services.providers.has(provider)) {
26
+ const known = [...services.providers.keys()].join(', ') || '(none configured)';
27
+ yield { type: 'error', message: `Unknown provider "${provider}". Configured providers: ${known}.` };
28
+ return;
29
+ }
30
+ const res = await services.singleTurn({
31
+ provider,
32
+ prompt: args.prompt,
33
+ signal: ctx.signal,
34
+ ...(typeof args.system === 'string' ? { system: args.system } : {}),
35
+ });
36
+ yield { type: 'result', value: { text: res.text, usage: res.usage } };
37
+ },
38
+ };
39
+
40
+ return {
41
+ name: 'single_turn',
42
+ description:
43
+ 'Run a single-turn completion against a configured provider and return its reply. This is a ' +
44
+ 'one-shot call — not your own response: you send one `prompt` (and optional `system`), and get ' +
45
+ 'back its text and token usage. Use it to consult a different model — e.g. a second, ' +
46
+ 'different-lineage model critiquing your draft, or any generation that should run on a specific ' +
47
+ 'provider. `provider` is OPTIONAL: omit it to run on the current conversation\'s model, or name ' +
48
+ 'a configured provider to switch models (list or add providers with the provider tool).\n\n' +
49
+ 'Parameters (TypeScript):\n' +
50
+ '```ts\n' +
51
+ '{ provider?: string; prompt: string; system?: string } // -> { text, usage: { inputTokens, outputTokens } }\n' +
52
+ '```',
53
+ inputSchema: {
54
+ type: 'object',
55
+ required: ['prompt'],
56
+ properties: {
57
+ provider: { type: 'string', description: 'Name of a configured provider to run the completion against. Optional — defaults to the current turn\'s provider.' },
58
+ prompt: { type: 'string', description: 'The user message to send to that provider.' },
59
+ system: { type: 'string', description: 'Optional system prompt for the call.' },
60
+ },
61
+ },
62
+ executor,
63
+ };
64
+ }
@@ -0,0 +1 @@
1
+ export * from './query/index.js';
@@ -0,0 +1,16 @@
1
+ import type { FieldPath } from '@matatbread/matbot-plugin-api';
2
+
3
+ export function pathSegments(field: FieldPath): string[] {
4
+ return Array.isArray(field) ? field : [field];
5
+ }
6
+
7
+ // Null-safe traversal (the grammar's implicit `?.`): any absent or non-object segment
8
+ // short-circuits to undefined and never throws. A present `null` is returned as-is.
9
+ export function getField(row: unknown, segments: string[]): unknown {
10
+ let cur: unknown = row;
11
+ for (const seg of segments) {
12
+ if (cur === null || typeof cur !== 'object') return undefined;
13
+ cur = (cur as Record<string, unknown>)[seg];
14
+ }
15
+ return cur;
16
+ }
@@ -0,0 +1,42 @@
1
+ import type { Filter, Orderable } from '@matatbread/matbot-plugin-api';
2
+ import { getField, pathSegments } from './access.js';
3
+
4
+ type Predicate = (row: unknown) => boolean;
5
+
6
+ // null and absent collapse to a single "missing" state — no comparison ever matches a missing
7
+ // value (so `neq` excludes missing rows too). Only `exists` observes missing-ness.
8
+ function resolve(row: unknown, seg: string[]): unknown {
9
+ const v = getField(row, seg);
10
+ return v === null ? undefined : v;
11
+ }
12
+
13
+ // Ordering is defined only within a type (numeric for numbers, codepoint for strings). A
14
+ // cross-type comparison yields undefined → the predicate is false (type-mismatch no-match).
15
+ function order(a: unknown, b: Orderable): number | undefined {
16
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
17
+ if (typeof a === 'string' && typeof b === 'string') return a < b ? -1 : a > b ? 1 : 0;
18
+ return undefined;
19
+ }
20
+
21
+ // Compile the AST to a composed closure — the same one-pass "compile to native" the SQL/ES
22
+ // backends do, with JS closures as the target. No `eval`/`new Function`: CSP-safe, and operands
23
+ // are captured as data so there is no injection surface.
24
+ export function compileFilter(f: Filter): Predicate {
25
+ switch (f.op) {
26
+ case 'and': { const ps = f.clauses.map(compileFilter); return r => ps.every(p => p(r)); }
27
+ case 'or': { const ps = f.clauses.map(compileFilter); return r => ps.some(p => p(r)); }
28
+ case 'not': { const p = compileFilter(f.clause); return r => !p(r); }
29
+
30
+ case 'eq': { const seg = pathSegments(f.field), v = f.value; return r => resolve(r, seg) === v; }
31
+ case 'neq': { const seg = pathSegments(f.field), v = f.value; return r => { const x = resolve(r, seg); return x !== undefined && x !== v; }; }
32
+ case 'lt': { const seg = pathSegments(f.field), v = f.value; return r => { const c = order(resolve(r, seg), v); return c !== undefined && c < 0; }; }
33
+ case 'lte': { const seg = pathSegments(f.field), v = f.value; return r => { const c = order(resolve(r, seg), v); return c !== undefined && c <= 0; }; }
34
+ case 'gt': { const seg = pathSegments(f.field), v = f.value; return r => { const c = order(resolve(r, seg), v); return c !== undefined && c > 0; }; }
35
+ case 'gte': { const seg = pathSegments(f.field), v = f.value; return r => { const c = order(resolve(r, seg), v); return c !== undefined && c >= 0; }; }
36
+ case 'in': { const seg = pathSegments(f.field), vs = f.value as unknown[]; return r => { const x = resolve(r, seg); return x !== undefined && vs.includes(x); }; }
37
+ case 'nin': { const seg = pathSegments(f.field), vs = f.value as unknown[]; return r => { const x = resolve(r, seg); return x !== undefined && !vs.includes(x); }; }
38
+ case 'exists': { const seg = pathSegments(f.field), want = f.value; return r => (resolve(r, seg) !== undefined) === want; }
39
+ case 'stringContains': { const seg = pathSegments(f.field), v = f.value; return r => { const x = resolve(r, seg); return typeof x === 'string' && x.includes(v); }; }
40
+ case 'arrayContains': { const seg = pathSegments(f.field), v = f.value; return r => { const x = getField(r, seg); return Array.isArray(x) && (x as unknown[]).includes(v); }; }
41
+ }
42
+ }
@@ -0,0 +1,49 @@
1
+ import type { StoreQuery, QueryResult } from '@matatbread/matbot-plugin-api';
2
+ import { validateQuery } from './validate.js';
3
+ import { compileFilter } from './compile.js';
4
+ import { applySort } from './sort.js';
5
+ import { encodeCursor, decodeCursor, type PageState } from './paginate.js';
6
+
7
+ function toQuery(p: PageState): StoreQuery {
8
+ return {
9
+ ...(p.where !== undefined ? { where: p.where } : {}),
10
+ ...(p.sort !== undefined ? { sort: p.sort } : {}),
11
+ ...(p.limit !== undefined ? { limit: p.limit } : {}),
12
+ };
13
+ }
14
+
15
+ // Reference in-memory execution: validate → filter → totally-order → paginate. Backends that load
16
+ // all documents into memory (filesystem, IndexedDB, sqlite-as-blob) delegate here; a pushdown
17
+ // backend would instead compile the same StoreQuery to its native query language.
18
+ //
19
+ // A cursor is self-contained: it carries the query, sort, page size, and position, so a caller can
20
+ // page by sending only the cursor back. When a cursor is present it fully determines the page —
21
+ // any where/sort/limit passed alongside it are ignored — which is what makes consecutive pages a
22
+ // disjoint cover (page N re-applies the same sort as page 1, so the total order never shifts). A
23
+ // cursor is untrusted input, so its decoded query is validated exactly like a fresh one.
24
+ export function executeQuery<T extends { id: string; version: string }>(docs: T[], q: StoreQuery): QueryResult<T> {
25
+ const page: PageState = q.cursor !== undefined
26
+ ? decodeCursor(q.cursor)
27
+ : {
28
+ offset: 0,
29
+ ...(q.where !== undefined ? { where: q.where } : {}),
30
+ ...(q.sort !== undefined ? { sort: q.sort } : {}),
31
+ ...(q.limit !== undefined ? { limit: q.limit } : {}),
32
+ };
33
+
34
+ validateQuery(toQuery(page));
35
+
36
+ const predicate = page.where !== undefined ? compileFilter(page.where) : () => true;
37
+ const ordered = applySort(docs.filter(predicate), page.sort);
38
+
39
+ const total = ordered.length;
40
+ const end = page.limit !== undefined ? page.offset + page.limit : ordered.length;
41
+ const slice = ordered.slice(page.offset, end);
42
+ const next = page.offset + slice.length;
43
+
44
+ return {
45
+ items: slice,
46
+ total,
47
+ ...(next < total ? { cursor: encodeCursor({ ...page, offset: next }) } : {}),
48
+ };
49
+ }
@@ -0,0 +1,6 @@
1
+ export { getField, pathSegments } from './access.js';
2
+ export { compileFilter } from './compile.js';
3
+ export { validateQuery } from './validate.js';
4
+ export { applySort } from './sort.js';
5
+ export { encodeCursor, decodeCursor, type PageState } from './paginate.js';
6
+ export { executeQuery } from './execute.js';
@@ -0,0 +1,26 @@
1
+ import type { Filter, SortSpec } from '@matatbread/matbot-plugin-api';
2
+ import { StoreQueryError } from '@matatbread/matbot-plugin-api';
3
+
4
+ // Everything needed to reproduce the next page deterministically: the query, its sort, the page
5
+ // size, and the position. The cursor is opaque and self-contained — a caller pages by sending only
6
+ // a previous result's cursor back. The in-memory reference backend carries the literal query and an
7
+ // offset; a pushdown backend would carry its own keyset under the same opaque contract.
8
+ export interface PageState {
9
+ where?: Filter;
10
+ sort?: SortSpec[];
11
+ limit?: number;
12
+ offset: number;
13
+ }
14
+
15
+ export function encodeCursor(state: PageState): string {
16
+ return JSON.stringify(state);
17
+ }
18
+
19
+ export function decodeCursor(cursor: string): PageState {
20
+ let parsed: unknown;
21
+ try { parsed = JSON.parse(cursor); } catch { parsed = undefined; }
22
+ const offset = (parsed as { offset?: unknown } | undefined)?.offset;
23
+ if (parsed === null || typeof parsed !== 'object' || typeof offset !== 'number' || !Number.isInteger(offset) || offset < 0)
24
+ throw new StoreQueryError('unreadable cursor — pass back a cursor from a previous result verbatim', '/cursor', 'MALFORMED');
25
+ return parsed as PageState;
26
+ }
@@ -0,0 +1,29 @@
1
+ import type { SortSpec } from '@matatbread/matbot-plugin-api';
2
+ import { getField, pathSegments } from './access.js';
3
+
4
+ // Compares two resolved field values. Missing (undefined/null) sorts last; numbers compare
5
+ // numerically, everything else by string codepoint order.
6
+ function compareValues(a: unknown, b: unknown): number {
7
+ if (a === b) return 0;
8
+ const am = a === undefined || a === null;
9
+ const bm = b === undefined || b === null;
10
+ if (am || bm) return am ? (bm ? 0 : 1) : -1;
11
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
12
+ const as = String(a), bs = String(b);
13
+ return as < bs ? -1 : as > bs ? 1 : 0;
14
+ }
15
+
16
+ // Sorts by the requested specs, then appends `id` as a final tiebreaker so the order is always
17
+ // total — without which an opaque cursor over the result could not point at a stable boundary.
18
+ export function applySort<T extends { id: string }>(docs: T[], sort: SortSpec[] | undefined): T[] {
19
+ const compiled = [...(sort ?? []), { field: 'id', dir: 'asc' as const }]
20
+ .map(s => ({ seg: pathSegments(s.field), sign: s.dir === 'desc' ? -1 : 1 }));
21
+
22
+ return [...docs].sort((a, b) => {
23
+ for (const { seg, sign } of compiled) {
24
+ const c = compareValues(getField(a, seg), getField(b, seg));
25
+ if (c !== 0) return c * sign;
26
+ }
27
+ return 0;
28
+ });
29
+ }
@@ -0,0 +1,113 @@
1
+ import type { StoreQuery, Filter } from '@matatbread/matbot-plugin-api';
2
+ import { StoreQueryError } from '@matatbread/matbot-plugin-api';
3
+
4
+ // Validate the query at the boundary, before any backend touches data, and throw a located,
5
+ // detailed StoreQueryError so an LLM author can fix the offending clause and retry. Input is
6
+ // treated as untrusted (LLM/JSON-sourced) — the static types are only a convenience for callers.
7
+
8
+ function isComparable(v: unknown): boolean {
9
+ return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
10
+ }
11
+
12
+ function requireComparable(v: unknown, ptr: string, op: string): void {
13
+ if (v === null)
14
+ throw new StoreQueryError(`'${op}' cannot compare to null — use { op: 'exists', value: false } to match absent-or-null`, ptr, 'NULL_OPERAND');
15
+ if (!isComparable(v))
16
+ throw new StoreQueryError(`'${op}' requires a string|number|boolean value`, ptr, 'OPERAND_TYPE');
17
+ }
18
+
19
+ function requireOrderable(v: unknown, ptr: string, op: string): void {
20
+ if (v === null)
21
+ throw new StoreQueryError(`'${op}' cannot compare to null — use { op: 'exists', value: false }`, ptr, 'NULL_OPERAND');
22
+ if (typeof v !== 'string' && typeof v !== 'number')
23
+ throw new StoreQueryError(`'${op}' requires a string|number value`, ptr, 'OPERAND_TYPE');
24
+ }
25
+
26
+ function validateField(field: unknown, ptr: string): void {
27
+ if (typeof field === 'string') {
28
+ if (field.length === 0) throw new StoreQueryError('field path is empty', ptr, 'EMPTY_FIELD');
29
+ return;
30
+ }
31
+ if (Array.isArray(field)) {
32
+ if (field.length === 0) throw new StoreQueryError('field path has no segments', ptr, 'EMPTY_FIELD');
33
+ field.forEach((s, i) => {
34
+ if (typeof s !== 'string' || s.length === 0)
35
+ throw new StoreQueryError('field path segment must be a non-empty string', `${ptr}/${i}`, 'EMPTY_FIELD');
36
+ });
37
+ return;
38
+ }
39
+ throw new StoreQueryError('field must be a string or array of strings', ptr, 'MALFORMED');
40
+ }
41
+
42
+ function validateFilter(f: Filter, ptr: string): void {
43
+ if (f === null || typeof f !== 'object' || typeof (f as { op?: unknown }).op !== 'string')
44
+ throw new StoreQueryError('filter node must be an object with an `op`', ptr, 'MALFORMED');
45
+
46
+ switch (f.op) {
47
+ case 'and':
48
+ case 'or':
49
+ if (!Array.isArray(f.clauses) || f.clauses.length === 0)
50
+ throw new StoreQueryError(`'${f.op}' requires a non-empty clauses array`, `${ptr}/clauses`, 'EMPTY_CLAUSES');
51
+ f.clauses.forEach((c, i) => validateFilter(c, `${ptr}/clauses/${i}`));
52
+ return;
53
+ case 'not':
54
+ if (f.clause === undefined)
55
+ throw new StoreQueryError(`'not' requires a clause`, `${ptr}/clause`, 'MALFORMED');
56
+ validateFilter(f.clause, `${ptr}/clause`);
57
+ return;
58
+ case 'eq':
59
+ case 'neq':
60
+ validateField(f.field, `${ptr}/field`);
61
+ requireComparable(f.value, `${ptr}/value`, f.op);
62
+ return;
63
+ case 'lt':
64
+ case 'lte':
65
+ case 'gt':
66
+ case 'gte':
67
+ validateField(f.field, `${ptr}/field`);
68
+ requireOrderable(f.value, `${ptr}/value`, f.op);
69
+ return;
70
+ case 'in':
71
+ case 'nin':
72
+ validateField(f.field, `${ptr}/field`);
73
+ if (!Array.isArray(f.value))
74
+ throw new StoreQueryError(`'${f.op}' requires an array value`, `${ptr}/value`, 'OPERAND_TYPE');
75
+ f.value.forEach((el, i) => requireComparable(el, `${ptr}/value/${i}`, f.op));
76
+ return;
77
+ case 'exists':
78
+ validateField(f.field, `${ptr}/field`);
79
+ if (typeof f.value !== 'boolean')
80
+ throw new StoreQueryError(`'exists' requires a boolean value`, `${ptr}/value`, 'OPERAND_TYPE');
81
+ return;
82
+ case 'stringContains':
83
+ validateField(f.field, `${ptr}/field`);
84
+ if (typeof f.value !== 'string')
85
+ throw new StoreQueryError(`'stringContains' requires a string value`, `${ptr}/value`, 'OPERAND_TYPE');
86
+ return;
87
+ case 'arrayContains':
88
+ validateField(f.field, `${ptr}/field`);
89
+ requireComparable(f.value, `${ptr}/value`, f.op);
90
+ return;
91
+ default:
92
+ throw new StoreQueryError(`unknown op '${String((f as { op: unknown }).op)}'`, `${ptr}/op`, 'UNKNOWN_OP');
93
+ }
94
+ }
95
+
96
+ export function validateQuery(q: StoreQuery): void {
97
+ if (q.where !== undefined) validateFilter(q.where, '/where');
98
+
99
+ if (q.sort !== undefined) {
100
+ if (!Array.isArray(q.sort))
101
+ throw new StoreQueryError('`sort` must be an array', '/sort', 'MALFORMED');
102
+ q.sort.forEach((s, i) => {
103
+ if (s === null || typeof s !== 'object')
104
+ throw new StoreQueryError('sort spec must be an object', `/sort/${i}`, 'MALFORMED');
105
+ validateField(s.field, `/sort/${i}/field`);
106
+ if (s.dir !== 'asc' && s.dir !== 'desc')
107
+ throw new StoreQueryError(`sort dir must be 'asc' or 'desc'`, `/sort/${i}/dir`, 'MALFORMED');
108
+ });
109
+ }
110
+
111
+ if (q.limit !== undefined && (typeof q.limit !== 'number' || !Number.isInteger(q.limit) || q.limit < 0))
112
+ throw new StoreQueryError('`limit` must be a non-negative integer', '/limit', 'MALFORMED');
113
+ }
@@ -0,0 +1,26 @@
1
+ import type { Session, SystemContextContributor, SystemContextRegistry } from './types.js';
2
+
3
+ interface TaggedContributor {
4
+ fn: SystemContextContributor;
5
+ pluginName?: string;
6
+ }
7
+
8
+ export class SystemContextRegistryImpl implements SystemContextRegistry {
9
+ private readonly _contributors: TaggedContributor[] = [];
10
+
11
+ register(contributor: SystemContextContributor, pluginName?: string): void {
12
+ this._contributors.push({ fn: contributor, ...(pluginName !== undefined ? { pluginName } : {}) });
13
+ }
14
+
15
+ removeByPlugin(pluginName: string): void {
16
+ for (let i = this._contributors.length - 1; i >= 0; i--) {
17
+ if (this._contributors[i]?.pluginName === pluginName) this._contributors.splice(i, 1);
18
+ }
19
+ }
20
+
21
+ async build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null> {
22
+ const parts = (await Promise.all(this._contributors.map(c => c.fn(ctx))))
23
+ .filter((s): s is string => typeof s === 'string' && s.length > 0);
24
+ return parts.length > 0 ? parts.join('\n\n') : null;
25
+ }
26
+ }
@@ -0,0 +1,38 @@
1
+ import type { Tool, ToolRegistry, ToolRegistryEvent } from './types.js';
2
+ import { createBroadcaster } from '@matatbread/matbot-plugin-api';
3
+
4
+ export class ToolRegistryImpl implements ToolRegistry {
5
+ private readonly tools = new Map<string, Tool>();
6
+ private readonly events = createBroadcaster<ToolRegistryEvent>();
7
+
8
+ constructor(initial?: Iterable<Tool>) {
9
+ if (initial !== undefined) for (const tool of initial) this.tools.set(tool.name, tool);
10
+ }
11
+
12
+ register(tool: Tool): void {
13
+ this.tools.set(tool.name, tool);
14
+ this.events.emit({ type: 'registered', name: tool.name, ...(tool.pluginName !== undefined ? { pluginName: tool.pluginName } : {}) });
15
+ }
16
+
17
+ remove(name: string): void {
18
+ if (this.tools.delete(name)) this.events.emit({ type: 'removed', name });
19
+ }
20
+
21
+ removeByPlugin(pluginName: string): void {
22
+ for (const [name, tool] of this.tools) {
23
+ if (tool.pluginName === pluginName && this.tools.delete(name)) this.events.emit({ type: 'removed', name });
24
+ }
25
+ }
26
+
27
+ resolve(name: string): Tool | null {
28
+ return this.tools.get(name) ?? null;
29
+ }
30
+
31
+ list(): Tool[] {
32
+ return [...this.tools.values()];
33
+ }
34
+
35
+ watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
36
+ return this.events.subscribe(signal);
37
+ }
38
+ }
package/src/types.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type * from '@matatbread/matbot-plugin-api';
2
+
3
+ // ── Internal types (not part of the plugin API) ───────────────────────────────
4
+
5
+ import type {
6
+ ProviderAdapter,
7
+ } from '@matatbread/matbot-plugin-api';
8
+
9
+ export interface ProviderRegistry {
10
+ register(adapter: ProviderAdapter): void;
11
+ resolve(name: string): ProviderAdapter;
12
+ }
13
+