@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.3.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.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
- return pluginEvents.subscribe(signal);
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
- consume(options, handler) {
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.consume(forwarded, () => handler(scoped as never));
316
+ services.mounted.observe(forwarded, () => handler(scoped as never));
316
317
  },
317
318
  };
318
319
 
@@ -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
- await deps.store.set(session.id, session);
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, Principal, Usage } from './types.js';
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
+ }
@@ -1 +1,2 @@
1
1
  export * from './query/index.js';
2
+ export { CachingStorageBackend, type CachingOptions, type CacheNamespaceStats } from './caching-backend.js';
@@ -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
- return this.events.subscribe(signal);
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
  }