@matatbread/matbot-core 0.3.3 → 0.3.4
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 +2 -2
- package/src/registry.ts +5 -4
- package/src/session-runner.ts +12 -2
- package/src/session.ts +2 -10
- package/src/storage-base/caching-backend.ts +166 -0
- package/src/storage-base/index.ts +1 -0
- package/src/tool-registry.ts +3 -2
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.
|
|
7
|
+
"version": "0.3.4",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.3.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.3.4"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/registry.ts
CHANGED
|
@@ -39,8 +39,9 @@ const state = {
|
|
|
39
39
|
// not in `state` — it owns subscribers across the registry's lifetime, not per-plugin data.
|
|
40
40
|
const pluginEvents = createBroadcaster<PluginRegistryEvent>();
|
|
41
41
|
|
|
42
|
-
export function watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
|
|
43
|
-
|
|
42
|
+
export async function* watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
|
|
43
|
+
// Plugin CRUD is global — no origin — so unwrap the envelope to the bare event the panel expects.
|
|
44
|
+
for await (const { value } of pluginEvents.subscribe(signal)) yield value;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Settings namespace + key under which the user's "overwrite all colliding tools" choice
|
|
@@ -306,13 +307,13 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
306
307
|
// fires. Forward-referenced via `scoped`, assigned below; consume() only runs after setup.
|
|
307
308
|
let scoped: MatbotMachine;
|
|
308
309
|
const scopedMounted: Mounted = {
|
|
309
|
-
|
|
310
|
+
observe(options, handler) {
|
|
310
311
|
// Forward to the host mount table but deliver *this plugin's* scoped machine — it reads through
|
|
311
312
|
// the same proxies/registry, so scoped[key] is the host's live service. onUnmount is scoped too.
|
|
312
313
|
const forwarded = options.onUnmount !== undefined
|
|
313
314
|
? { ...options, onUnmount: () => options.onUnmount!(scoped) }
|
|
314
315
|
: options;
|
|
315
|
-
services.mounted.
|
|
316
|
+
services.mounted.observe(forwarded, () => handler(scoped as never));
|
|
316
317
|
},
|
|
317
318
|
};
|
|
318
319
|
|
package/src/session-runner.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { MatbotPlugin } from './plugin.js';
|
|
|
7
7
|
import type { HookRegistry } from './hooks.js';
|
|
8
8
|
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
9
9
|
import { appendMessage, createMessage } from './session.js';
|
|
10
|
-
import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
|
|
10
|
+
import { contextSwitch, withUsageScope, isReadOnlyError } from '@matatbread/matbot-plugin-api';
|
|
11
11
|
import { runSession } from './runner.js';
|
|
12
12
|
|
|
13
13
|
export interface SessionRunnerDeps {
|
|
@@ -211,7 +211,17 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
211
211
|
// while queued. That is what stops a mid-turn submit from clobbering session state. A robo
|
|
212
212
|
// resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
|
|
213
213
|
session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
|
|
214
|
-
|
|
214
|
+
try {
|
|
215
|
+
await deps.store.set(session.id, session);
|
|
216
|
+
} catch (e) {
|
|
217
|
+
// A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
|
|
218
|
+
// from another profile's partition). That is a per-turn condition, not a host fault: surface
|
|
219
|
+
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
220
|
+
// crash the process. Any other write failure stays fatal.
|
|
221
|
+
if (!isReadOnlyError(e)) throw e;
|
|
222
|
+
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
215
225
|
}
|
|
216
226
|
|
|
217
227
|
const resolved = await deps.resolveProvider(head.provider);
|
package/src/session.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
|
-
import type { Session, Message, MessageContent, MessageRole,
|
|
1
|
+
import type { Session, Message, MessageContent, MessageRole, Usage } from './types.js';
|
|
2
2
|
|
|
3
3
|
export interface CreateSessionOpts {
|
|
4
|
-
ownerPrincipal: Principal;
|
|
5
|
-
actorPrincipal?: Principal;
|
|
6
|
-
persona?: string;
|
|
7
4
|
title?: string;
|
|
8
5
|
contexts?: string[];
|
|
9
6
|
parentSessionId?: string;
|
|
10
7
|
branchPointMessageId?: string;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
export function createSession(opts: CreateSessionOpts): Session {
|
|
10
|
+
export function createSession(opts: CreateSessionOpts = {}): Session {
|
|
14
11
|
const now = new Date().toISOString();
|
|
15
12
|
return {
|
|
16
13
|
id: crypto.randomUUID(),
|
|
17
14
|
version: crypto.randomUUID(),
|
|
18
|
-
ownerPrincipalId: opts.ownerPrincipal.id,
|
|
19
|
-
...(opts.actorPrincipal && opts.actorPrincipal.id !== opts.ownerPrincipal.id
|
|
20
|
-
? { actorPrincipalId: opts.actorPrincipal.id }
|
|
21
|
-
: {}),
|
|
22
|
-
...(opts.persona !== undefined ? { persona: opts.persona } : {}),
|
|
23
15
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
24
16
|
...(opts.parentSessionId !== undefined ? { parentSessionId: opts.parentSessionId } : {}),
|
|
25
17
|
...(opts.branchPointMessageId !== undefined ? { branchPointMessageId: opts.branchPointMessageId } : {}),
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Store, StorageBackend, FileStore, StoreQuery, QueryResult, CASResult,
|
|
3
|
+
} from '@matatbread/matbot-plugin-api';
|
|
4
|
+
import { executeQuery } from './query/execute.js';
|
|
5
|
+
|
|
6
|
+
export interface CachingOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Bounded-staleness ceiling for FOREIGN writes — another process/tab/host mutating the same backing
|
|
9
|
+
* store behind our back. When set, a namespace's in-memory image is treated as stale this many ms
|
|
10
|
+
* after it was warmed and re-loaded on the next read. Own writes are always coherent regardless
|
|
11
|
+
* (write-through keeps the image in step), so a strictly single-writer deployment can leave this
|
|
12
|
+
* unset. Undefined ⇒ warm once and never expire on time.
|
|
13
|
+
*
|
|
14
|
+
* TTL is the *portable* invalidation tier — every backing store supports it. A backing store that
|
|
15
|
+
* also exposes a change feed (Node `fs.watch`, the Drive Changes API, Postgres `LISTEN/NOTIFY`)
|
|
16
|
+
* could drive sharper foreign-write invalidation by evicting `image` on a remote change event; that
|
|
17
|
+
* is a later, opt-in refinement layered on top of this and deliberately NOT required here.
|
|
18
|
+
*/
|
|
19
|
+
ttlMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Per-namespace cache counters, for answering "is the cache actually saving reads?" — see
|
|
23
|
+
* {@link CachingStorageBackend.stats}. `reads` is get()+query() calls; `hits` is the subset served
|
|
24
|
+
* from the warm image without touching the backing store; `loads` is full-namespace warms/refreshes
|
|
25
|
+
* (the expensive round-trips); `lastLoadMs` times the most recent one; `docs` is the current image
|
|
26
|
+
* size. A healthy cache shows `hits` climbing while `loads` stays flat (typically 1). */
|
|
27
|
+
export interface CacheNamespaceStats {
|
|
28
|
+
docs: number;
|
|
29
|
+
reads: number;
|
|
30
|
+
hits: number;
|
|
31
|
+
loads: number;
|
|
32
|
+
lastLoadMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cache-aside, write-through caching Store. Reads serve from a full in-memory image of the namespace
|
|
37
|
+
* (`Map<id, T>`), warmed lazily by a single `backing.query({})`; every write goes to the backing store
|
|
38
|
+
* first (the system of record — CAS and durability stay there) and then updates the image on success.
|
|
39
|
+
*
|
|
40
|
+
* Coherence is honest: always coherent with THIS store's own writes; foreign writes only as sharp as
|
|
41
|
+
* the {@link CachingOptions.ttlMs} tier (never, if unset). That is the single-writer assumption made
|
|
42
|
+
* explicit at one layer — a backend that cannot assume it simply isn't wrapped, or wraps with a short
|
|
43
|
+
* TTL.
|
|
44
|
+
*/
|
|
45
|
+
class CachingStore<T extends { id: string; version: string }> implements Store<T> {
|
|
46
|
+
private image: Map<string, T> | undefined;
|
|
47
|
+
private warmedAt = 0;
|
|
48
|
+
private warming: Promise<Map<string, T>> | undefined;
|
|
49
|
+
|
|
50
|
+
private readonly backing: Store<T>;
|
|
51
|
+
private readonly ttlMs: number | undefined;
|
|
52
|
+
|
|
53
|
+
private reads = 0;
|
|
54
|
+
private hits = 0;
|
|
55
|
+
private loads = 0;
|
|
56
|
+
private lastLoadMs = 0;
|
|
57
|
+
|
|
58
|
+
constructor(backing: Store<T>, ttlMs: number | undefined) {
|
|
59
|
+
this.backing = backing;
|
|
60
|
+
this.ttlMs = ttlMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get stats(): CacheNamespaceStats {
|
|
64
|
+
return { docs: this.image?.size ?? 0, reads: this.reads, hits: this.hits, loads: this.loads, lastLoadMs: this.lastLoadMs };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private fresh(): boolean {
|
|
68
|
+
if (this.image === undefined) return false;
|
|
69
|
+
if (this.ttlMs === undefined) return true;
|
|
70
|
+
return Date.now() - this.warmedAt < this.ttlMs;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Serve the whole-namespace image, (re)loading it when cold or TTL-stale. Concurrent callers share
|
|
74
|
+
// one in-flight load rather than each firing their own `query({})`.
|
|
75
|
+
private warm(): Promise<Map<string, T>> {
|
|
76
|
+
if (this.image !== undefined && this.fresh()) { this.hits++; return Promise.resolve(this.image); }
|
|
77
|
+
return (this.warming ??= this.load());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async load(): Promise<Map<string, T>> {
|
|
81
|
+
const started = Date.now();
|
|
82
|
+
try {
|
|
83
|
+
const { items } = await this.backing.query({});
|
|
84
|
+
const map = new Map<string, T>(items.map(d => [d.id, d]));
|
|
85
|
+
this.image = map;
|
|
86
|
+
this.warmedAt = Date.now();
|
|
87
|
+
this.loads++;
|
|
88
|
+
this.lastLoadMs = Date.now() - started;
|
|
89
|
+
return map;
|
|
90
|
+
} finally {
|
|
91
|
+
this.warming = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async get(id: string): Promise<T | null> {
|
|
96
|
+
this.reads++;
|
|
97
|
+
return (await this.warm()).get(id) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async query(q: StoreQuery): Promise<QueryResult<T>> {
|
|
101
|
+
this.reads++;
|
|
102
|
+
return executeQuery([...(await this.warm()).values()], q);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async set(id: string, value: T): Promise<void> {
|
|
106
|
+
await this.backing.set(id, value);
|
|
107
|
+
this.image?.set(id, value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async cas(id: string, expected: string, next: T): Promise<CASResult<T>> {
|
|
111
|
+
const result = await this.backing.cas(id, expected, next);
|
|
112
|
+
if (this.image !== undefined) {
|
|
113
|
+
if (result.ok) this.image.set(id, next);
|
|
114
|
+
// Conflict: the backing store hands back the authoritative current doc — reconcile the image so a
|
|
115
|
+
// retry (and the caller inspecting `current`) sees truth, not our stale copy.
|
|
116
|
+
else if (result.current !== null) this.image.set(id, result.current);
|
|
117
|
+
else this.image.delete(id);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async delete(id: string, expectedVersion?: string): Promise<boolean> {
|
|
123
|
+
const ok = await this.backing.delete(id, expectedVersion);
|
|
124
|
+
if (ok) this.image?.delete(id);
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wraps any {@link StorageBackend} in a per-namespace read-through cache — see {@link CachingStore}.
|
|
131
|
+
* Compose it BELOW any principal-partitioning router (e.g. the profiles backend) so each partition
|
|
132
|
+
* gets its own cache and the cache never sees a blended, principal-blind view; a slow backend
|
|
133
|
+
* (browser Google Drive) wraps its own backend before registering. File blobs pass straight through
|
|
134
|
+
* uncached (large, and read far less often than documents).
|
|
135
|
+
*/
|
|
136
|
+
export class CachingStorageBackend implements StorageBackend {
|
|
137
|
+
readonly inner: StorageBackend;
|
|
138
|
+
private readonly ttlMs: number | undefined;
|
|
139
|
+
private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
|
|
140
|
+
|
|
141
|
+
constructor(inner: StorageBackend, options: CachingOptions = {}) {
|
|
142
|
+
this.inner = inner;
|
|
143
|
+
this.ttlMs = options.ttlMs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
|
|
147
|
+
let store = this.stores.get(namespace);
|
|
148
|
+
if (store === undefined) {
|
|
149
|
+
store = new CachingStore(this.inner.createStore(namespace), this.ttlMs);
|
|
150
|
+
this.stores.set(namespace, store);
|
|
151
|
+
}
|
|
152
|
+
return store as unknown as Store<T>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Per-namespace cache counters (only namespaces touched so far). A diagnostic surface — read it to
|
|
156
|
+
* confirm the cache is serving reads (`hits` up, `loads` ~1) rather than re-reading the backend. */
|
|
157
|
+
stats(): Record<string, CacheNamespaceStats> {
|
|
158
|
+
const out: Record<string, CacheNamespaceStats> = {};
|
|
159
|
+
for (const [ns, store] of this.stores) out[ns] = store.stats;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get fileStore(): FileStore { return this.inner.fileStore; }
|
|
164
|
+
|
|
165
|
+
close(): Promise<void> { return this.inner.close?.() ?? Promise.resolve(); }
|
|
166
|
+
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -32,7 +32,8 @@ export class ToolRegistryImpl implements ToolRegistry {
|
|
|
32
32
|
return [...this.tools.values()];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
|
|
36
|
-
|
|
35
|
+
async *watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
|
|
36
|
+
// Tool CRUD is global — no origin — so unwrap the envelope to the bare event.
|
|
37
|
+
for await (const { value } of this.events.subscribe(signal)) yield value;
|
|
37
38
|
}
|
|
38
39
|
}
|