@getfamiliar/shared 0.1.12 → 0.1.13

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,105 @@
1
+ import { TOPIC_PATTERN } from "./Schema.js";
2
+ const TOPIC_REGEXP = new RegExp(TOPIC_PATTERN);
3
+ /** File name of the folder form of a handler (`a/b/c/index.md`). */
4
+ const INDEX_FILE = "index.md";
5
+ /**
6
+ * Build the candidate handler files for a topic, one entry per topic
7
+ * level (most specific first). Each level lists its candidates in
8
+ * precedence order: the file form `a/b/c.md` before the folder form
9
+ * `a/b/c/index.md`. A single-segment topic `a` has only `a/index.md`
10
+ * — workspace-root files are never handlers.
11
+ *
12
+ * For topic `mail:new:ms365` without `isExactTopic`:
13
+ *
14
+ * 1. `mail/new/ms365.md`, `mail/new/ms365/index.md`
15
+ * 2. `mail/new.md`, `mail/new/index.md`
16
+ * 3. `mail/index.md`
17
+ *
18
+ * @param topic Colon-separated topic, e.g. `mail:new:ms365`.
19
+ * @param options See {@link HandlerResolutionOptions}.
20
+ * @returns The candidate levels, most specific first.
21
+ */
22
+ export function handlerCandidateLevels(topic, options) {
23
+ const segments = topic.split(":");
24
+ const shallowestDepth = options.isExactTopic ? segments.length : 1;
25
+ const levels = [];
26
+ for (let depth = segments.length; depth >= shallowestDepth; depth--) {
27
+ const folder = segments.slice(0, depth).join("/");
28
+ const level = depth === 1 ? [] : [`${folder}.md`];
29
+ level.push(`${folder}/${INDEX_FILE}`);
30
+ levels.push(level);
31
+ }
32
+ return levels;
33
+ }
34
+ /**
35
+ * Resolve a topic to the single handler file that runs for it. The
36
+ * first level of {@link handlerCandidateLevels} holding any existing
37
+ * file wins; within a level the file form beats the folder form and
38
+ * the loser is reported as {@link HandlerResolution.shadowedPath}.
39
+ * Nothing is merged — exactly one file is the handler.
40
+ *
41
+ * @param topic Colon-separated topic, e.g. `mail:new:ms365`.
42
+ * @param options See {@link HandlerResolutionOptions}.
43
+ * @param isFile Existence probe for a workspace-relative path.
44
+ * @returns The resolution, or `null` when no candidate exists.
45
+ */
46
+ export function resolveHandlerPath(topic, options, isFile) {
47
+ for (const level of handlerCandidateLevels(topic, options)) {
48
+ const existing = level.filter((candidate) => isFile(candidate));
49
+ if (existing.length > 0) {
50
+ return { relativePath: existing[0], shadowedPath: existing[1] ?? null };
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ /**
56
+ * Derive the topic a handler file answers to. The inverse of
57
+ * {@link handlerCandidateLevels}' own level:
58
+ *
59
+ * - `mail/important/digest.md` → `mail:important:digest`
60
+ * - `mail/new/index.md` → `mail:new`
61
+ * - `mail/index.md` → `mail`
62
+ *
63
+ * @param relativePath Workspace-relative posix path ending in `.md`.
64
+ * @returns The topic, or `null` for workspace-root files, non-`.md`
65
+ * files and paths whose segments don't form a valid topic (e.g. a
66
+ * file name containing a dot or a space).
67
+ */
68
+ export function topicFromHandlerPath(relativePath) {
69
+ if (!relativePath.endsWith(".md")) {
70
+ return null;
71
+ }
72
+ const segments = relativePath.split("/");
73
+ if (segments.length < 2) {
74
+ return null;
75
+ }
76
+ const fileName = segments[segments.length - 1];
77
+ const folders = segments.slice(0, -1);
78
+ const topicSegments = fileName === INDEX_FILE ? folders : [...folders, fileName.slice(0, -".md".length)];
79
+ const topic = topicSegments.join(":");
80
+ return TOPIC_REGEXP.test(topic) ? topic : null;
81
+ }
82
+ /**
83
+ * The other form of the same handler: `a/b/c.md` ↔ `a/b/c/index.md`.
84
+ * Only one of the two may exist; seeding and the cron scheduler use
85
+ * this to detect the conflict.
86
+ *
87
+ * @param relativePath Workspace-relative posix path ending in `.md`.
88
+ * @returns The twin's path, or `null` when the twin would be a
89
+ * workspace-root file (`a/index.md` ↔ `a.md`) or the path is a root
90
+ * file / not markdown.
91
+ */
92
+ export function twinHandlerPath(relativePath) {
93
+ if (!relativePath.endsWith(".md")) {
94
+ return null;
95
+ }
96
+ const segments = relativePath.split("/");
97
+ if (segments.length < 2) {
98
+ return null;
99
+ }
100
+ const fileName = segments[segments.length - 1];
101
+ if (fileName === INDEX_FILE) {
102
+ return segments.length < 3 ? null : `${segments.slice(0, -1).join("/")}.md`;
103
+ }
104
+ return `${relativePath.slice(0, -".md".length)}/${INDEX_FILE}`;
105
+ }
package/build/Plugin.d.ts CHANGED
@@ -282,6 +282,13 @@ export interface HostContext {
282
282
  * `dataDir` is for plugin-private host-only state.
283
283
  */
284
284
  readonly dataDir: string;
285
+ /**
286
+ * Absolute host path of the project's `workspace/` directory — the
287
+ * assistant's markdown workspace (handlers, PROMPT.md, SOUL.md,
288
+ * knowledge files), bind-mounted into the agent container as
289
+ * `/workspace`.
290
+ */
291
+ readonly workspaceDir: string;
285
292
  /**
286
293
  * Absolute host path of the project's `tmp/` directory — the
287
294
  * gitignored, safe-to-wipe scratch root (it also holds the daemon
@@ -579,6 +586,24 @@ export interface PluginToolCallContext {
579
586
  */
580
587
  readonly toolRunContext: ToolRunContext;
581
588
  }
589
+ /**
590
+ * One kind of event a plugin emits onto the bus, as returned by
591
+ * {@link PluginHostManifest.getEmittedEvents}. Describes a topic, not an
592
+ * individual event — `familiar events list-topics` lists these.
593
+ */
594
+ export interface EmittedEventDescriptor {
595
+ /** Event topic, matching `TOPIC_PATTERN` (e.g. `mail:new:ms365`). */
596
+ readonly topic: string;
597
+ /** One-line, human-readable description of when the event fires. */
598
+ readonly description: string;
599
+ /**
600
+ * `true` when the plugin emits this topic with `isExactTopic` (it
601
+ * addresses one handler file, e.g. memory's `skills:memory:save`),
602
+ * so `familiar events list-topics` resolves it without falling back up
603
+ * the topic chain. Omit for world events.
604
+ */
605
+ readonly isExactTopic?: boolean;
606
+ }
582
607
  /**
583
608
  * Host-side surface a plugin may declare. All fields optional; a
584
609
  * plugin that's pure workspace template (no host code) leaves `host`
@@ -589,9 +614,18 @@ export interface PluginToolCallContext {
589
614
  */
590
615
  export interface PluginHostManifest {
591
616
  /**
592
- * Synchronous, side-effect-light setup. Runs once per process,
593
- * **before any plugin's `start`** and before any plugin one-shot
594
- * CLI command's `run`. Use this for module-level state that other
617
+ * Config subtrees outside the plugin's own `<plugin.id>.*` group
618
+ * whose change restarts the plugin on a config reload (dotted paths,
619
+ * e.g. `["inference.apiKeys.openai"]`). The own group is always
620
+ * watched; list a foreign key here only when the plugin captures it
621
+ * in `prepare` / `start` instead of reading it per use.
622
+ */
623
+ restartOnConfigChange?: readonly string[];
624
+ /**
625
+ * Synchronous, side-effect-light setup. Runs once per process start
626
+ * and again on every plugin restart (config reload, after
627
+ * {@link stop}), **before the plugin's `start`** and before any plugin
628
+ * one-shot CLI command's `run`. Use this for module-level state that other
595
629
  * plugins consume — e.g. populating an API-key constant the
596
630
  * plugin's library exports — so the order in which siblings call
597
631
  * each other doesn't depend on `start` finishing first.
@@ -614,12 +648,22 @@ export interface PluginHostManifest {
614
648
  */
615
649
  start?(ctx: HostContext): Promise<void>;
616
650
  /**
617
- * Drain hook called during daemon shutdown. Awaited in reverse
618
- * registration order (LIFO of `start`), wrapped in a `safeStop`
619
- * so a throw is logged but does not abort the rest of the drain.
620
- *
621
- * Use this for plugins that own dirty in-memory state which must
622
- * be flushed to disk before the process exits — the memory
651
+ * Drain hook called during daemon shutdown and before a plugin
652
+ * restart (config reload changed the plugin's config group). On
653
+ * shutdown it is awaited in reverse registration order (LIFO of
654
+ * `start`), wrapped in a `safeStop` so a throw is logged but does
655
+ * not abort the rest of the drain.
656
+ *
657
+ * Must release everything `start` set up — timers, poll loops,
658
+ * sockets, chat / workspace subscriptions — and reset module state,
659
+ * because a restart runs `prepare` → `start` → `tools` again in the
660
+ * same process. Providers, prompt appenders / placeholders and tools
661
+ * the plugin registered are unregistered by the host. A plugin with
662
+ * a `start` but no `stop` cannot be restarted: a config reload that
663
+ * touches it reports "daemon restart required" instead.
664
+ *
665
+ * Use this also for plugins that own dirty in-memory state which
666
+ * must be flushed to disk before the process exits — the memory
623
667
  * plugin's Orama index is the motivating case.
624
668
  *
625
669
  * Hard rules: must resolve within the daemon-wide drain budget
@@ -634,8 +678,9 @@ export interface PluginHostManifest {
634
678
  stop?(ctx: HostContext): Promise<void>;
635
679
  /**
636
680
  * Declarative list of {@link PluginTool}s this plugin contributes
637
- * to the agent. Collected once, **after** the plugin's `start`
638
- * resolves, so closures over `start`-time state are safe.
681
+ * to the agent. Collected **after** the plugin's `start` resolves
682
+ * (at boot and after every restart), so closures over `start`-time
683
+ * state are safe.
639
684
  *
640
685
  * Hard rules: synchronous, side-effect-free — just build and
641
686
  * return the tool list. The host registers each tool under
@@ -681,6 +726,19 @@ export interface PluginHostManifest {
681
726
  * collision between two plugins, the first registered wins (logged).
682
727
  */
683
728
  getModelProviders?(ctx: HostContext): readonly ModelProviderDescriptor[];
729
+ /**
730
+ * Declare the event topics this plugin emits onto the bus under the
731
+ * current configuration — e.g. ms365 omits `mail:new:ms365` when its
732
+ * mail feature is disabled. Backs `familiar events list-topics`, so the
733
+ * operator can see which topics exist and which handler file each
734
+ * one resolves to.
735
+ *
736
+ * Hard rules: synchronous, side-effect-free — derive the list from
737
+ * constants and `ctx.config`, no network, no DB. Called after
738
+ * `prepare`, also from one-shot CLI runs without the daemon. Topics
739
+ * that don't match `TOPIC_PATTERN` are logged and dropped.
740
+ */
741
+ getEmittedEvents?(ctx: HostContext): readonly EmittedEventDescriptor[];
684
742
  /**
685
743
  * Default command for the plugin's CLI root. When set, invoking
686
744
  * the plugin id with no subcommand (e.g. `familiar cli-chat`) runs
@@ -723,7 +781,7 @@ export interface PluginManifest {
723
781
  readonly host?: PluginHostManifest;
724
782
  /**
725
783
  * Absolute path to a directory of files copied into
726
- * `data/workspace/` on first install. Existing files are left
784
+ * the workspace (`<home>/workspace/`) on first install. Existing files are left
727
785
  * alone — diff/merge on plugin updates is a separate concern.
728
786
  * Plugins typically derive this with
729
787
  * `path.join(__dirname, "..", "workspace-template")` (or the ESM
@@ -30,7 +30,7 @@ export const CORE_PROMPT_PLACEHOLDERS = [
30
30
  },
31
31
  {
32
32
  name: "HANDLER_CONTENT",
33
- description: "Body of the resolved handler file (merged with its parents). Placeholders inside it are expanded too.",
33
+ description: "Body of the resolved handler file. Placeholders inside it are expanded too.",
34
34
  kind: "static",
35
35
  },
36
36
  {
@@ -40,12 +40,7 @@ export const CORE_PROMPT_PLACEHOLDERS = [
40
40
  },
41
41
  {
42
42
  name: "HANDLER_PATH",
43
- description: "Workspace-relative path of the handler file, e.g. `chat/telegram/index.md`.",
44
- kind: "static",
45
- },
46
- {
47
- name: "HANDLER_INHERITS",
48
- description: "Parent handler files this handler is merged with, as `a ← b`, or `(none)`.",
43
+ description: "Workspace-relative path of the handler file, e.g. `chat/telegram.md`.",
49
44
  kind: "static",
50
45
  },
51
46
  {
@@ -70,7 +65,7 @@ export const CORE_PROMPT_PLACEHOLDERS = [
70
65
  },
71
66
  {
72
67
  name: "RUNTIME_LIST",
73
- description: "Bullet list of run facts: current time, event topic, handler file, inheritance, outputChat, privileged.",
68
+ description: "Bullet list of run facts: current time, event topic, handler file, outputChat, privileged.",
74
69
  kind: "dynamic",
75
70
  },
76
71
  {
@@ -12,7 +12,7 @@
12
12
  * `scheduled_subagents_changed` NOTIFY and installs a Croner job for
13
13
  * `fireAt` with `maxRuns: 1`.
14
14
  * 3. When the job fires, the host atomically claims-and-deletes the
15
- * row, then emits a fresh event with the row's topic / handler /
15
+ * row, then emits a fresh exact-topic event with the row's topic /
16
16
  * prompt / payload / priority / privileged.
17
17
  *
18
18
  * `prompt` is optional at the table level so the agent can schedule
@@ -25,8 +25,8 @@ export interface ScheduledSubagentRow {
25
25
  readonly key: string;
26
26
  /** UTC ISO-8601 firing time (e.g. `2026-05-21T13:00:00Z`). */
27
27
  readonly fireAt: string;
28
+ /** Handler address; must resolve exactly (no fallback up the topic chain). */
28
29
  readonly topic: string;
29
- readonly handler: string;
30
30
  readonly prompt: string | null;
31
31
  readonly payload: unknown;
32
32
  readonly priority: number;
@@ -41,7 +41,6 @@ export interface NewScheduledSubagent {
41
41
  readonly key: string;
42
42
  readonly fireAt: string;
43
43
  readonly topic: string;
44
- readonly handler: string;
45
44
  readonly prompt?: string | null;
46
45
  readonly payload?: unknown;
47
46
  readonly priority?: number;
@@ -43,7 +43,7 @@ export declare class ScheduledSubagentBus {
43
43
  * old row is overwritten in one statement so the host scheduler
44
44
  * sees exactly one NOTIFY and installs exactly one job.
45
45
  *
46
- * @throws If `topic` does not match `\w+(:\w+)*` (CHECK constraint).
46
+ * @throws If `topic` does not match `TOPIC_PATTERN` (CHECK constraint).
47
47
  */
48
48
  upsert(row: NewScheduledSubagent): Promise<ScheduledSubagentRow>;
49
49
  /**
@@ -37,16 +37,15 @@ export class ScheduledSubagentBus {
37
37
  * old row is overwritten in one statement so the host scheduler
38
38
  * sees exactly one NOTIFY and installs exactly one job.
39
39
  *
40
- * @throws If `topic` does not match `\w+(:\w+)*` (CHECK constraint).
40
+ * @throws If `topic` does not match `TOPIC_PATTERN` (CHECK constraint).
41
41
  */
42
42
  async upsert(row) {
43
43
  const result = await this.connection.getPool().query(`INSERT INTO scheduled_subagents
44
- (key, fire_at, topic, handler, prompt, payload, priority, privileged)
45
- VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8)
44
+ (key, fire_at, topic, prompt, payload, priority, privileged)
45
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7)
46
46
  ON CONFLICT (key) DO UPDATE SET
47
47
  fire_at = EXCLUDED.fire_at,
48
48
  topic = EXCLUDED.topic,
49
- handler = EXCLUDED.handler,
50
49
  prompt = EXCLUDED.prompt,
51
50
  payload = EXCLUDED.payload,
52
51
  priority = EXCLUDED.priority,
@@ -55,7 +54,6 @@ export class ScheduledSubagentBus {
55
54
  row.key,
56
55
  row.fireAt,
57
56
  row.topic,
58
- row.handler,
59
57
  row.prompt ?? null,
60
58
  JSON.stringify(row.payload ?? {}),
61
59
  row.priority ?? 50,
@@ -177,7 +175,6 @@ function mapRow(raw) {
177
175
  key: raw.key,
178
176
  fireAt: raw.fire_at.toISOString(),
179
177
  topic: raw.topic,
180
- handler: raw.handler,
181
178
  prompt: raw.prompt,
182
179
  payload: raw.payload,
183
180
  priority: raw.priority,
package/build/Schema.d.ts CHANGED
@@ -50,17 +50,19 @@ export declare const CHATMESSAGES_NEW_CHANNEL = "chatmessages_new";
50
50
  */
51
51
  export declare const SCHEDULED_SUBAGENTS_CHANNEL = "scheduled_subagents_changed";
52
52
  /**
53
- * Topic regex used by the events check constraint and by the container
54
- * runtime when resolving handler files. One word, optionally followed
55
- * by any number of `:`-prefixed sub-topic words. Examples:
56
- * `mail`, `chat:cli`, `chat:telegram:group:reaction`.
53
+ * Topic regex used by the topic check constraints and by the container
54
+ * runtime when resolving handler files. One word (word characters and
55
+ * dashes), optionally followed by any number of `:`-prefixed sub-topic
56
+ * words. Examples: `mail`, `chat:cli`, `mail:extract-style`,
57
+ * `chat:telegram:group:reaction`.
57
58
  *
58
- * The container resolves a handler for topic `a:b:c` by walking the
59
- * chain `a/b/c/<basename>.md → a/b/<basename>.md → a/<basename>.md`
60
- * deepest-first and merging every existing layer (see HandlerFile.load).
59
+ * The topic is a handler's whole address: `a:b:c` names the single file
60
+ * `a/b/c.md` (or its folder form `a/b/c/index.md`). Events fall back up
61
+ * the chain (`a:b`, `a`) when that file is missing; everything else must
62
+ * match exactly (see `HandlerResolution.ts`).
61
63
  */
62
- export declare const TOPIC_PATTERN = "^\\w+(:\\w+)*$";
63
- export declare const SCHEMA_SQL = "\n-- Drop legacy single-table artifacts from before the events/agentruns\n-- split. Safe no-ops on fresh databases. The current schema reuses the\n-- name events_notify_new() for its own NOTIFY function, so we do NOT\n-- drop it here (CREATE OR REPLACE FUNCTION below handles the update);\n-- dropping would fail anyway once the current events_new_trg exists.\nDROP TRIGGER IF EXISTS events_notify_changed_trg ON events;\nDROP TRIGGER IF EXISTS events_notify_new_trg ON events;\nDROP FUNCTION IF EXISTS events_notify_changed();\nDROP INDEX IF EXISTS events_pending_priority_idx;\nDROP INDEX IF EXISTS events_state_priority_idx;\n\nCREATE TABLE IF NOT EXISTS events (\n id bigserial PRIMARY KEY,\n topic text NOT NULL,\n priority smallint NOT NULL DEFAULT 50,\n state text NOT NULL DEFAULT 'pending',\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n idempotency_key text UNIQUE,\n privileged boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT events_topic_format CHECK (topic ~ '^\\w+(:\\w+)*$')\n);\n\n-- Drop legacy columns from earlier schema generations. Single-instance\n-- project; column drops are fine.\nALTER TABLE events DROP COLUMN IF EXISTS supervisor_prompt;\nALTER TABLE events DROP COLUMN IF EXISTS causation_chain;\n\n-- Chat-aware event metadata. `is_chat` opts into automatic user-message\n-- persistence in EventBus.add; `preferred_chat_channel_id` names the\n-- channel an assistant reply should reach the user on. The container is\n-- channel-blind \u2014 both fields are read host-side (for routing) or via\n-- JOIN inside the chatmessages NOTIFY trigger.\nALTER TABLE events ADD COLUMN IF NOT EXISTS is_chat boolean NOT NULL DEFAULT false;\nALTER TABLE events ADD COLUMN IF NOT EXISTS preferred_chat_channel_id text;\n-- Required for non-chat events (validated at `EventBus.add` time and at\n-- the type level via `NewEvent`'s discriminated union); always null for\n-- chat events because the user's text in `chatmessages` already serves\n-- as the agent-visible \"what happened\" message. The EventWatcher copies\n-- this verbatim into the root `agentruns.prompt` so the agent sees the\n-- emitter's framing without the EventWatcher having to reach into the\n-- payload schema.\nALTER TABLE events ADD COLUMN IF NOT EXISTS prompt text;\n-- Optional override for the root agentrun's handler. When NULL the\n-- input-event watcher falls back to 'index' \u2014 same behaviour as\n-- before this column existed. Set by the emitter on NewEvent.\nALTER TABLE events ADD COLUMN IF NOT EXISTS start_handler text;\n-- Privileged events originate from a trusted user-input source (operator\n-- at the local terminal via cli-chat, operator on Telegram). The flag\n-- propagates verbatim to the root agentrun and to every child agentrun\n-- spawned via schedule_subagent / start_subagent, so future system tools can\n-- gate risky reads / writes (editing SOUL.md etc.) on whether the run\n-- descends from a trusted input. Default false: any plugin that doesn't\n-- explicitly opt in produces non-privileged events.\nALTER TABLE events ADD COLUMN IF NOT EXISTS privileged boolean NOT NULL DEFAULT false;\n-- When true and the event ends in 'failed', `HostContextImpl.emitAndAwait`\n-- writes a role='assistant' chatmessage with the failure text before\n-- rejecting handle.settled. Default false: only chat plugins (cli-chat,\n-- telegram) opt in. See [[outputChatOnFailure]] on NewEvent for the\n-- rationale.\nALTER TABLE events ADD COLUMN IF NOT EXISTS output_chat_on_failure boolean NOT NULL DEFAULT false;\n\nCREATE INDEX IF NOT EXISTS events_state_priority_idx\n ON events (state, priority DESC, id ASC);\nCREATE INDEX IF NOT EXISTS events_topic_idx ON events (topic);\n\nCREATE TABLE IF NOT EXISTS agentruns (\n id bigserial PRIMARY KEY,\n event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n parent_agentrun_id bigint REFERENCES agentruns(id) ON DELETE CASCADE,\n topic text NOT NULL,\n handler text NOT NULL,\n priority smallint NOT NULL DEFAULT 50,\n state text NOT NULL DEFAULT 'pending',\n prompt text,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n result jsonb,\n result_text text,\n error text,\n privileged boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT agentruns_topic_format CHECK (topic ~ '^\\w+(:\\w+)*$')\n);\n\n-- Forward-compat ALTER for databases created before result_text landed.\n-- Safe no-op on fresh databases where the column already exists.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS result_text text;\n-- Inherited from the originating event (root agentrun) or parent\n-- agentrun (children spawned via schedule_subagent / start_subagent). See\n-- the matching ALTER on the events table for the trust-model rationale.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS privileged boolean NOT NULL DEFAULT false;\n-- Number of retry attempts already made on this agentrun. Bumped each\n-- time AgentRunner postpones the row due to a retryable inference\n-- error (APICallError.isRetryable). Capped per-handler via maxRetries\n-- (frontmatter override) or globally via inference.maxRetries.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS retry_count int NOT NULL DEFAULT 0;\n-- Earliest moment the agentrun watcher is allowed to re-claim this\n-- row. NULL = claim immediately (default for fresh rows). A future\n-- timestamp = leave the row alone until then; the watcher's claim\n-- query gates on this so a parked row doesn't waste the slot while\n-- other agentruns (potentially using different models) can still run.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS not_before timestamptz;\n-- Resolved model id for this agentrun, of the form\n-- `<provider>/<modelId>` (e.g. `featherless/zai-org/GLM-5.1`).\n-- Populated by AgentRunner once it builds the model client; stays\n-- NULL for any agentrun that never reached generate() (failed at\n-- handler-load time, etc.).\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS model text;\n-- Resolved prompt (rendered prompt template: system prompt, then\n-- user-message head) used for this agentrun. Populated by AgentRunner\n-- only when core.logSystemPrompt is true; otherwise NULL. Surfaced\n-- by the report layer's Agentrun Start section when the consumer\n-- opts into withDetails.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS system_prompt text;\n-- Verbatim model-message array assembled for the first agent.generate()\n-- call (prior history + the run's leading user turn). Populated by\n-- AgentRunner only when inference.captureInitialMessageHistory is true;\n-- otherwise NULL. Surfaced by the report layer's -vv view so an operator\n-- can see exactly what was sent into the model loop.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS initial_messages jsonb;\n\n-- Distinguishes how a child agentrun was spawned.\n-- NULL \u2014 root agentrun (created by the input-event watcher, no parent).\n-- 'scheduled' \u2014 fire-and-forget child spawned by `schedule_subagent` without\n-- a future `when` (immediate dispatch under the same event).\n-- 'started' \u2014 synchronous child spawned via the `start_subagent` tool; the\n-- parent is parked in state='waiting' until this row settles.\n-- The Scheduler reads this column to decide whether settling a row should\n-- wake a waiting parent.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS calltype text;\nALTER TABLE agentruns DROP CONSTRAINT IF EXISTS agentruns_calltype_format;\nALTER TABLE agentruns ADD CONSTRAINT agentruns_calltype_format\n CHECK (calltype IS NULL OR calltype IN ('scheduled','started'));\n\n-- Partial index for the \"are all started children of $parent settled?\"\n-- check the Scheduler runs after every 'started' child settles. Indexes\n-- only the rows that actually participate in the question.\nCREATE INDEX IF NOT EXISTS agentruns_parent_calltype_state_idx\n ON agentruns (parent_agentrun_id, state)\n WHERE calltype = 'started';\n\n-- Forward-compat: relax the topic CHECK constraint to allow arbitrarily\n-- deep `a:b:c:\u2026` topics. CREATE TABLE IF NOT EXISTS above doesn't\n-- update existing constraints, so we drop+re-add unconditionally. The\n-- DROP IF EXISTS / ADD pair is idempotent.\nALTER TABLE events DROP CONSTRAINT IF EXISTS events_topic_format;\nALTER TABLE events ADD CONSTRAINT events_topic_format CHECK (topic ~ '^\\w+(:\\w+)*$');\nALTER TABLE agentruns DROP CONSTRAINT IF EXISTS agentruns_topic_format;\nALTER TABLE agentruns ADD CONSTRAINT agentruns_topic_format CHECK (topic ~ '^\\w+(:\\w+)*$');\n\nCREATE INDEX IF NOT EXISTS agentruns_state_priority_idx\n ON agentruns (state, priority DESC, id ASC);\nCREATE INDEX IF NOT EXISTS agentruns_event_id_idx ON agentruns (event_id);\nCREATE INDEX IF NOT EXISTS agentruns_parent_idx ON agentruns (parent_agentrun_id);\n\nCREATE TABLE IF NOT EXISTS stepresults (\n id bigserial PRIMARY KEY,\n agent_run_id bigint NOT NULL REFERENCES agentruns(id) ON DELETE CASCADE,\n event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n step_number int NOT NULL,\n finish_reason text NOT NULL,\n result_text text,\n reasoning_text text,\n input_tokens int,\n output_tokens int,\n total_tokens int,\n tool_call_count int NOT NULL DEFAULT 0,\n tool_calls jsonb NOT NULL DEFAULT '[]'::jsonb,\n tool_results jsonb NOT NULL DEFAULT '[]'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT stepresults_step_number_unique UNIQUE (agent_run_id, step_number)\n);\n\nCREATE INDEX IF NOT EXISTS stepresults_agent_run_id_idx ON stepresults (agent_run_id);\nCREATE INDEX IF NOT EXISTS stepresults_event_id_idx ON stepresults (event_id);\n\n-- AI SDK per-step usage breakdown\n-- (LanguageModelUsage.inputTokenDetails / outputTokenDetails). Each\n-- column is NULL when the provider doesn't report that detail.\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS input_tokens_no_cache int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS input_tokens_cache_read int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS input_tokens_cache_write int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS output_tokens_text int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS output_tokens_reasoning int;\n-- Full step object snapshot, populated only when the operator opts\n-- in via inference.captureRawStepResultToDatabase: true. Used to\n-- inspect provider-specific fields (Anthropic cache control,\n-- OpenAI logprobs, ...) that don't fit dedicated columns.\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS raw_result jsonb;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 chatmessages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Persistent chat history across all channels. Each row links to the\n-- event whose lifecycle the message belongs to:\n-- * For role='user': the event the host plugin emitted with isChat=true.\n-- * For role='assistant': the event whose agentrun called send_chat.\n-- The channel is reachable via JOIN to events.preferred_chat_channel_id;\n-- there is intentionally no channel_id column here \u2014 channel routing is\n-- a host-side concern, the container never names a channel.\n--\n-- delivered_at is set when ANY listener returns true from its async\n-- handler. New subscribers replay all undelivered matching rows on\n-- registration so a message produced when nobody was listening is still\n-- delivered later.\nCREATE TABLE IF NOT EXISTS chatmessages (\n id bigserial PRIMARY KEY,\n event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN ('user','assistant')),\n text_content text NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n delivered_at timestamptz\n);\n\nCREATE INDEX IF NOT EXISTS chatmessages_event_idx\n ON chatmessages (event_id);\nCREATE INDEX IF NOT EXISTS chatmessages_undelivered_idx\n ON chatmessages (created_at) WHERE delivered_at IS NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 NOTIFY triggers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE OR REPLACE FUNCTION events_notify_new() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify('events_new', NEW.id::text);\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS events_new_trg ON events;\nCREATE TRIGGER events_new_trg\n AFTER INSERT ON events\n FOR EACH ROW EXECUTE FUNCTION events_notify_new();\n\nCREATE OR REPLACE FUNCTION events_notify_state() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify('events_state', NEW.id::text || ':' || NEW.state);\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS events_state_trg ON events;\nCREATE TRIGGER events_state_trg\n AFTER UPDATE OF state ON events\n FOR EACH ROW EXECUTE FUNCTION events_notify_state();\n\nCREATE OR REPLACE FUNCTION agentruns_notify_changed() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify(\n 'agentruns_changed',\n NEW.event_id::text || ':' || NEW.id::text\n );\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS agentruns_notify_changed_trg ON agentruns;\nCREATE TRIGGER agentruns_notify_changed_trg\n AFTER INSERT OR UPDATE OF state ON agentruns\n FOR EACH ROW EXECUTE FUNCTION agentruns_notify_changed();\n\nCREATE OR REPLACE FUNCTION stepresults_notify_new() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify(\n 'stepresults_new',\n NEW.event_id::text || ':' || NEW.agent_run_id::text || ':' || NEW.id::text\n );\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS stepresults_new_trg ON stepresults;\nCREATE TRIGGER stepresults_new_trg\n AFTER INSERT ON stepresults\n FOR EACH ROW EXECUTE FUNCTION stepresults_notify_new();\n\n-- chatmessages NOTIFY: payload is \"<channel_id>:<role>:<id>\". Channel\n-- comes from the parent event's preferred_chat_channel_id, JOINed at\n-- trigger time. Empty channel becomes empty prefix \u2014 listeners will\n-- filter it out unless they explicitly subscribe to channelId=\"\".\nCREATE OR REPLACE FUNCTION chatmessages_notify_new() RETURNS trigger AS $$\nDECLARE\n channel text;\nBEGIN\n SELECT preferred_chat_channel_id INTO channel FROM events WHERE id = NEW.event_id;\n PERFORM pg_notify(\n 'chatmessages_new',\n COALESCE(channel, '') || ':' || NEW.role || ':' || NEW.id::text\n );\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS chatmessages_new_trg ON chatmessages;\nCREATE TRIGGER chatmessages_new_trg\n AFTER INSERT ON chatmessages\n FOR EACH ROW EXECUTE FUNCTION chatmessages_notify_new();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 calendars + calendar_events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Plugin-agnostic calendar data layer. `calendars` lists every\n-- subscription (own or shared) any provider plugin (ms365, future\n-- google/caldav) has surfaced; `calendar_events` stores every\n-- instance. Providers persist every occurrence of a recurring\n-- series independently (no master-expansion in core).\n--\n-- Identity:\n-- * `calendars.unique_key` is the provider's stable id. Uniqueness\n-- within `(plugin_id, unique_key)` lets multiple providers\n-- coexist.\n-- * `calendar_events.id` is provider-prefixed (e.g. `ms365:<graph-id>`)\n-- so collisions across providers are impossible by construction.\n--\n-- Refresh reconciliation uses a scan-generation tag: a refresh walk\n-- bumps `calendars.scan_generation`, upserts every event in window\n-- with the new value, then DELETEs events whose generation is older\n-- than the calendar's current value.\n\nCREATE TABLE IF NOT EXISTS calendars (\n id bigserial PRIMARY KEY,\n plugin_id text NOT NULL,\n unique_key text NOT NULL,\n name text NOT NULL,\n type text NOT NULL CHECK (type IN ('own','shared')),\n owner_name text,\n scan_generation int NOT NULL DEFAULT 0,\n created_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (plugin_id, unique_key)\n);\n\n-- The provider's own \"default calendar\" flag (Graph's\n-- `isDefaultCalendar`). Used by `CalendarService.resolveDefaultCalendar`\n-- as the fallback when `core.defaultCalendar` is unset \u2014 we pick the\n-- first registered calendar (lowest id) flagged here. Added via\n-- ALTER so existing databases don't need a schema reset.\nALTER TABLE calendars ADD COLUMN IF NOT EXISTS is_default smallint NOT NULL DEFAULT 0;\n\nCREATE INDEX IF NOT EXISTS calendars_plugin_id_idx ON calendars (plugin_id);\n\nCREATE TABLE IF NOT EXISTS calendar_events (\n id text PRIMARY KEY,\n calendar_id bigint NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,\n series_master_id text,\n type text NOT NULL CHECK (type IN ('singleInstance','occurrence','exception','seriesMaster')),\n subject text,\n start_dt text NOT NULL,\n end_dt text NOT NULL,\n event_tz text,\n is_all_day smallint NOT NULL DEFAULT 0,\n is_cancelled smallint NOT NULL DEFAULT 0,\n show_as text,\n sensitivity text,\n importance text,\n location text,\n is_online_meeting smallint NOT NULL DEFAULT 0,\n online_meeting_url text,\n organizer_name text,\n organizer_email text,\n response_status text,\n attendees_json jsonb,\n body text,\n attachments jsonb,\n scan_generation int NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS calendar_events_window_idx\n ON calendar_events (calendar_id, start_dt);\nCREATE INDEX IF NOT EXISTS calendar_events_series_idx\n ON calendar_events (series_master_id);\nCREATE INDEX IF NOT EXISTS calendar_events_generation_idx\n ON calendar_events (calendar_id, scan_generation);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 scheduled_subagents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- One-off agent-scheduled wake-ups. Each row is upserted by the\n-- container's `schedule_subagent` tool and read by the host-side\n-- `ScheduledSubagentScheduler`, which installs a Croner job per row\n-- and atomically deletes the row when it fires. `key` is the\n-- agent-supplied unique id used for replace-on-conflict semantics\n-- (\"rescheduling a key overwrites the previous timing\"). `fire_at`\n-- is the UTC firing time; the agent passes wall-clock-in-`core.timezone`\n-- and the container projects to UTC before insert. On daemon startup,\n-- past-due rows are deleted with a warn log; future rows are\n-- reinstalled. See [[feedback_agent_facing_format]] \u2014 UTC storage,\n-- local-tz projection at every agent-facing edge.\nCREATE TABLE IF NOT EXISTS scheduled_subagents (\n key text PRIMARY KEY,\n fire_at timestamptz NOT NULL,\n topic text NOT NULL,\n handler text NOT NULL,\n prompt text,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n priority smallint NOT NULL DEFAULT 50,\n privileged boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT scheduled_subagents_topic_format CHECK (topic ~ '^\\w+(:\\w+)*$')\n);\n\nCREATE INDEX IF NOT EXISTS scheduled_subagents_fire_at_idx\n ON scheduled_subagents (fire_at);\n\nCREATE OR REPLACE FUNCTION scheduled_subagents_notify_changed() RETURNS trigger AS $$\nDECLARE\n payload_key text;\n op text;\nBEGIN\n IF TG_OP = 'DELETE' THEN\n payload_key := OLD.key;\n op := 'd';\n ELSE\n payload_key := NEW.key;\n op := 'u';\n END IF;\n PERFORM pg_notify('scheduled_subagents_changed', payload_key || ':' || op);\n RETURN COALESCE(NEW, OLD);\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS scheduled_subagents_notify_changed_trg ON scheduled_subagents;\nCREATE TRIGGER scheduled_subagents_notify_changed_trg\n AFTER INSERT OR UPDATE OR DELETE ON scheduled_subagents\n FOR EACH ROW EXECUTE FUNCTION scheduled_subagents_notify_changed();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 inference_events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Append-only audit trail of every upstream model HTTP call attempt.\n-- Written by AgentRunner on each generate() outcome (success / retryable /\n-- fatal). Read by the host-side `inference_status` reflection tool to\n-- answer \"is this model misbehaving right now?\". One row per attempt;\n-- retries write multiple rows. NULL agent_run_id is kept for runs whose\n-- row was later deleted (parent event cascade) so the historical signal\n-- survives.\nCREATE TABLE IF NOT EXISTS inference_events (\n id bigserial PRIMARY KEY,\n provider text NOT NULL,\n model text NOT NULL,\n agent_run_id bigint REFERENCES agentruns(id) ON DELETE SET NULL,\n outcome text NOT NULL CHECK (outcome IN ('success','retryable','fatal')),\n status_code int,\n error_excerpt text,\n occurred_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS inference_events_model_time_idx\n ON inference_events (model, occurred_at DESC);\nCREATE INDEX IF NOT EXISTS inference_events_occurred_at_idx\n ON inference_events (occurred_at DESC);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 tool_calls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Append-only record of every tool invocation an agentrun attempted,\n-- tagged with the resolved handler markdown path and whether the call\n-- threw. Written by the tool wrapper in the container's ToolsFactory on\n-- each execute() outcome. Read by the heuristic tool-preloader\n-- (ToolCallBus.topToolsForHandler): the tools a handler successfully\n-- used across its recent non-error runs get loaded up front on the next\n-- run, so warm handlers rarely need a tool_list / tool_call round-trip.\n-- NULL agent_run_id survives a parent-event cascade so the historical\n-- usage signal outlives the run row.\nCREATE TABLE IF NOT EXISTS tool_calls (\n id bigserial PRIMARY KEY,\n agent_run_id bigint REFERENCES agentruns(id) ON DELETE SET NULL,\n handler_path text NOT NULL,\n tool_name text NOT NULL,\n successful boolean NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS tool_calls_handler_run_idx\n ON tool_calls (handler_path, agent_run_id DESC);\nCREATE INDEX IF NOT EXISTS tool_calls_created_at_idx\n ON tool_calls (created_at DESC);\n";
64
+ export declare const TOPIC_PATTERN = "^[\\w-]+(:[\\w-]+)*$";
65
+ export declare const SCHEMA_SQL = "\n-- Drop legacy single-table artifacts from before the events/agentruns\n-- split. Safe no-ops on fresh databases. The current schema reuses the\n-- name events_notify_new() for its own NOTIFY function, so we do NOT\n-- drop it here (CREATE OR REPLACE FUNCTION below handles the update);\n-- dropping would fail anyway once the current events_new_trg exists.\nDROP TRIGGER IF EXISTS events_notify_changed_trg ON events;\nDROP TRIGGER IF EXISTS events_notify_new_trg ON events;\nDROP FUNCTION IF EXISTS events_notify_changed();\nDROP INDEX IF EXISTS events_pending_priority_idx;\nDROP INDEX IF EXISTS events_state_priority_idx;\n\nCREATE TABLE IF NOT EXISTS events (\n id bigserial PRIMARY KEY,\n topic text NOT NULL,\n priority smallint NOT NULL DEFAULT 50,\n state text NOT NULL DEFAULT 'pending',\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n idempotency_key text UNIQUE,\n privileged boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT events_topic_format CHECK (topic ~ '^[\\w-]+(:[\\w-]+)*$')\n);\n\n-- Drop legacy columns from earlier schema generations. Single-instance\n-- project; column drops are fine.\nALTER TABLE events DROP COLUMN IF EXISTS supervisor_prompt;\nALTER TABLE events DROP COLUMN IF EXISTS causation_chain;\n\n-- Chat-aware event metadata. `is_chat` opts into automatic user-message\n-- persistence in EventBus.add; `preferred_chat_channel_id` names the\n-- channel an assistant reply should reach the user on. The container is\n-- channel-blind \u2014 both fields are read host-side (for routing) or via\n-- JOIN inside the chatmessages NOTIFY trigger.\nALTER TABLE events ADD COLUMN IF NOT EXISTS is_chat boolean NOT NULL DEFAULT false;\nALTER TABLE events ADD COLUMN IF NOT EXISTS preferred_chat_channel_id text;\n-- Required for non-chat events (validated at `EventBus.add` time and at\n-- the type level via `NewEvent`'s discriminated union); always null for\n-- chat events because the user's text in `chatmessages` already serves\n-- as the agent-visible \"what happened\" message. The EventWatcher copies\n-- this verbatim into the root `agentruns.prompt` so the agent sees the\n-- emitter's framing without the EventWatcher having to reach into the\n-- payload schema.\nALTER TABLE events ADD COLUMN IF NOT EXISTS prompt text;\n-- The topic is the handler's whole address now; the separate start\n-- handler basename is gone.\nALTER TABLE events DROP COLUMN IF EXISTS start_handler;\n-- When true the topic must name an existing handler file exactly; when\n-- false (world events) handler resolution falls back up the topic chain.\n-- Copied onto the root agentrun by the input-event watcher.\nALTER TABLE events ADD COLUMN IF NOT EXISTS is_exact_topic boolean NOT NULL DEFAULT false;\n-- Privileged events originate from a trusted user-input source (operator\n-- at the local terminal via cli-chat, operator on Telegram). The flag\n-- propagates verbatim to the root agentrun and to every child agentrun\n-- spawned via schedule_subagent / start_subagent, so future system tools can\n-- gate risky reads / writes (editing SOUL.md etc.) on whether the run\n-- descends from a trusted input. Default false: any plugin that doesn't\n-- explicitly opt in produces non-privileged events.\nALTER TABLE events ADD COLUMN IF NOT EXISTS privileged boolean NOT NULL DEFAULT false;\n-- When true and the event ends in 'failed', `HostContextImpl.emitAndAwait`\n-- writes a role='assistant' chatmessage with the failure text before\n-- rejecting handle.settled. Default false: only chat plugins (cli-chat,\n-- telegram) opt in. See [[outputChatOnFailure]] on NewEvent for the\n-- rationale.\nALTER TABLE events ADD COLUMN IF NOT EXISTS output_chat_on_failure boolean NOT NULL DEFAULT false;\n\nCREATE INDEX IF NOT EXISTS events_state_priority_idx\n ON events (state, priority DESC, id ASC);\nCREATE INDEX IF NOT EXISTS events_topic_idx ON events (topic);\n\nCREATE TABLE IF NOT EXISTS agentruns (\n id bigserial PRIMARY KEY,\n event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n parent_agentrun_id bigint REFERENCES agentruns(id) ON DELETE CASCADE,\n topic text NOT NULL,\n priority smallint NOT NULL DEFAULT 50,\n state text NOT NULL DEFAULT 'pending',\n prompt text,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n result jsonb,\n result_text text,\n error text,\n privileged boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT agentruns_topic_format CHECK (topic ~ '^[\\w-]+(:[\\w-]+)*$')\n);\n\n-- Forward-compat ALTER for databases created before result_text landed.\n-- Safe no-op on fresh databases where the column already exists.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS result_text text;\n-- The topic is the handler's whole address; the basename column is gone.\nALTER TABLE agentruns DROP COLUMN IF EXISTS handler;\n-- Whether handler resolution must match the topic exactly. Root\n-- agentruns copy the event's flag; spawned children are always exact.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS is_exact_topic boolean NOT NULL DEFAULT true;\n-- Inherited from the originating event (root agentrun) or parent\n-- agentrun (children spawned via schedule_subagent / start_subagent). See\n-- the matching ALTER on the events table for the trust-model rationale.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS privileged boolean NOT NULL DEFAULT false;\n-- Number of retry attempts already made on this agentrun. Bumped each\n-- time AgentRunner postpones the row due to a retryable inference\n-- error (APICallError.isRetryable). Capped per-handler via maxRetries\n-- (frontmatter override) or globally via inference.maxRetries.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS retry_count int NOT NULL DEFAULT 0;\n-- Earliest moment the agentrun watcher is allowed to re-claim this\n-- row. NULL = claim immediately (default for fresh rows). A future\n-- timestamp = leave the row alone until then; the watcher's claim\n-- query gates on this so a parked row doesn't waste the slot while\n-- other agentruns (potentially using different models) can still run.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS not_before timestamptz;\n-- Resolved model id for this agentrun, of the form\n-- `<provider>/<modelId>` (e.g. `featherless/zai-org/GLM-5.1`).\n-- Populated by AgentRunner once it builds the model client; stays\n-- NULL for any agentrun that never reached generate() (failed at\n-- handler-load time, etc.).\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS model text;\n-- Resolved prompt (rendered prompt template: system prompt, then\n-- user-message head) used for this agentrun. Populated by AgentRunner\n-- only when core.logSystemPrompt is true; otherwise NULL. Surfaced\n-- by the report layer's Agentrun Start section when the consumer\n-- opts into withDetails.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS system_prompt text;\n-- Verbatim model-message array assembled for the first agent.generate()\n-- call (prior history + the run's leading user turn). Populated by\n-- AgentRunner only when inference.captureInitialMessageHistory is true;\n-- otherwise NULL. Surfaced by the report layer's -vv view so an operator\n-- can see exactly what was sent into the model loop.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS initial_messages jsonb;\n\n-- Distinguishes how a child agentrun was spawned.\n-- NULL \u2014 root agentrun (created by the input-event watcher, no parent).\n-- 'scheduled' \u2014 fire-and-forget child spawned by `schedule_subagent` without\n-- a future `when` (immediate dispatch under the same event).\n-- 'started' \u2014 synchronous child spawned via the `start_subagent` tool; the\n-- parent is parked in state='waiting' until this row settles.\n-- The Scheduler reads this column to decide whether settling a row should\n-- wake a waiting parent.\nALTER TABLE agentruns ADD COLUMN IF NOT EXISTS calltype text;\nALTER TABLE agentruns DROP CONSTRAINT IF EXISTS agentruns_calltype_format;\nALTER TABLE agentruns ADD CONSTRAINT agentruns_calltype_format\n CHECK (calltype IS NULL OR calltype IN ('scheduled','started'));\n\n-- Partial index for the \"are all started children of $parent settled?\"\n-- check the Scheduler runs after every 'started' child settles. Indexes\n-- only the rows that actually participate in the question.\nCREATE INDEX IF NOT EXISTS agentruns_parent_calltype_state_idx\n ON agentruns (parent_agentrun_id, state)\n WHERE calltype = 'started';\n\n-- Forward-compat: relax the topic CHECK constraint to allow arbitrarily\n-- deep `a:b:c:\u2026` topics. CREATE TABLE IF NOT EXISTS above doesn't\n-- update existing constraints, so we drop+re-add unconditionally. The\n-- DROP IF EXISTS / ADD pair is idempotent.\nALTER TABLE events DROP CONSTRAINT IF EXISTS events_topic_format;\nALTER TABLE events ADD CONSTRAINT events_topic_format CHECK (topic ~ '^[\\w-]+(:[\\w-]+)*$');\nALTER TABLE agentruns DROP CONSTRAINT IF EXISTS agentruns_topic_format;\nALTER TABLE agentruns ADD CONSTRAINT agentruns_topic_format CHECK (topic ~ '^[\\w-]+(:[\\w-]+)*$');\n\nCREATE INDEX IF NOT EXISTS agentruns_state_priority_idx\n ON agentruns (state, priority DESC, id ASC);\nCREATE INDEX IF NOT EXISTS agentruns_event_id_idx ON agentruns (event_id);\nCREATE INDEX IF NOT EXISTS agentruns_parent_idx ON agentruns (parent_agentrun_id);\n\nCREATE TABLE IF NOT EXISTS stepresults (\n id bigserial PRIMARY KEY,\n agent_run_id bigint NOT NULL REFERENCES agentruns(id) ON DELETE CASCADE,\n event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n step_number int NOT NULL,\n finish_reason text NOT NULL,\n result_text text,\n reasoning_text text,\n input_tokens int,\n output_tokens int,\n total_tokens int,\n tool_call_count int NOT NULL DEFAULT 0,\n tool_calls jsonb NOT NULL DEFAULT '[]'::jsonb,\n tool_results jsonb NOT NULL DEFAULT '[]'::jsonb,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT stepresults_step_number_unique UNIQUE (agent_run_id, step_number)\n);\n\nCREATE INDEX IF NOT EXISTS stepresults_agent_run_id_idx ON stepresults (agent_run_id);\nCREATE INDEX IF NOT EXISTS stepresults_event_id_idx ON stepresults (event_id);\n\n-- AI SDK per-step usage breakdown\n-- (LanguageModelUsage.inputTokenDetails / outputTokenDetails). Each\n-- column is NULL when the provider doesn't report that detail.\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS input_tokens_no_cache int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS input_tokens_cache_read int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS input_tokens_cache_write int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS output_tokens_text int;\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS output_tokens_reasoning int;\n-- Full step object snapshot, populated only when the operator opts\n-- in via inference.captureRawStepResultToDatabase: true. Used to\n-- inspect provider-specific fields (Anthropic cache control,\n-- OpenAI logprobs, ...) that don't fit dedicated columns.\nALTER TABLE stepresults ADD COLUMN IF NOT EXISTS raw_result jsonb;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 chatmessages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Persistent chat history across all channels. Each row links to the\n-- event whose lifecycle the message belongs to:\n-- * For role='user': the event the host plugin emitted with isChat=true.\n-- * For role='assistant': the event whose agentrun called send_chat.\n-- The channel is reachable via JOIN to events.preferred_chat_channel_id;\n-- there is intentionally no channel_id column here \u2014 channel routing is\n-- a host-side concern, the container never names a channel.\n--\n-- delivered_at is set when ANY listener returns true from its async\n-- handler. New subscribers replay all undelivered matching rows on\n-- registration so a message produced when nobody was listening is still\n-- delivered later.\nCREATE TABLE IF NOT EXISTS chatmessages (\n id bigserial PRIMARY KEY,\n event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,\n role text NOT NULL CHECK (role IN ('user','assistant')),\n text_content text NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n delivered_at timestamptz\n);\n\nCREATE INDEX IF NOT EXISTS chatmessages_event_idx\n ON chatmessages (event_id);\nCREATE INDEX IF NOT EXISTS chatmessages_undelivered_idx\n ON chatmessages (created_at) WHERE delivered_at IS NULL;\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 NOTIFY triggers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nCREATE OR REPLACE FUNCTION events_notify_new() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify('events_new', NEW.id::text);\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS events_new_trg ON events;\nCREATE TRIGGER events_new_trg\n AFTER INSERT ON events\n FOR EACH ROW EXECUTE FUNCTION events_notify_new();\n\nCREATE OR REPLACE FUNCTION events_notify_state() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify('events_state', NEW.id::text || ':' || NEW.state);\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS events_state_trg ON events;\nCREATE TRIGGER events_state_trg\n AFTER UPDATE OF state ON events\n FOR EACH ROW EXECUTE FUNCTION events_notify_state();\n\nCREATE OR REPLACE FUNCTION agentruns_notify_changed() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify(\n 'agentruns_changed',\n NEW.event_id::text || ':' || NEW.id::text\n );\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS agentruns_notify_changed_trg ON agentruns;\nCREATE TRIGGER agentruns_notify_changed_trg\n AFTER INSERT OR UPDATE OF state ON agentruns\n FOR EACH ROW EXECUTE FUNCTION agentruns_notify_changed();\n\nCREATE OR REPLACE FUNCTION stepresults_notify_new() RETURNS trigger AS $$\nBEGIN\n PERFORM pg_notify(\n 'stepresults_new',\n NEW.event_id::text || ':' || NEW.agent_run_id::text || ':' || NEW.id::text\n );\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS stepresults_new_trg ON stepresults;\nCREATE TRIGGER stepresults_new_trg\n AFTER INSERT ON stepresults\n FOR EACH ROW EXECUTE FUNCTION stepresults_notify_new();\n\n-- chatmessages NOTIFY: payload is \"<channel_id>:<role>:<id>\". Channel\n-- comes from the parent event's preferred_chat_channel_id, JOINed at\n-- trigger time. Empty channel becomes empty prefix \u2014 listeners will\n-- filter it out unless they explicitly subscribe to channelId=\"\".\nCREATE OR REPLACE FUNCTION chatmessages_notify_new() RETURNS trigger AS $$\nDECLARE\n channel text;\nBEGIN\n SELECT preferred_chat_channel_id INTO channel FROM events WHERE id = NEW.event_id;\n PERFORM pg_notify(\n 'chatmessages_new',\n COALESCE(channel, '') || ':' || NEW.role || ':' || NEW.id::text\n );\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS chatmessages_new_trg ON chatmessages;\nCREATE TRIGGER chatmessages_new_trg\n AFTER INSERT ON chatmessages\n FOR EACH ROW EXECUTE FUNCTION chatmessages_notify_new();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 calendars + calendar_events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Plugin-agnostic calendar data layer. `calendars` lists every\n-- subscription (own or shared) any provider plugin (ms365, future\n-- google/caldav) has surfaced; `calendar_events` stores every\n-- instance. Providers persist every occurrence of a recurring\n-- series independently (no master-expansion in core).\n--\n-- Identity:\n-- * `calendars.unique_key` is the provider's stable id. Uniqueness\n-- within `(plugin_id, unique_key)` lets multiple providers\n-- coexist.\n-- * `calendar_events.id` is provider-prefixed (e.g. `ms365:<graph-id>`)\n-- so collisions across providers are impossible by construction.\n--\n-- Refresh reconciliation uses a scan-generation tag: a refresh walk\n-- bumps `calendars.scan_generation`, upserts every event in window\n-- with the new value, then DELETEs events whose generation is older\n-- than the calendar's current value.\n\nCREATE TABLE IF NOT EXISTS calendars (\n id bigserial PRIMARY KEY,\n plugin_id text NOT NULL,\n unique_key text NOT NULL,\n name text NOT NULL,\n type text NOT NULL CHECK (type IN ('own','shared')),\n owner_name text,\n scan_generation int NOT NULL DEFAULT 0,\n created_at timestamptz NOT NULL DEFAULT now(),\n UNIQUE (plugin_id, unique_key)\n);\n\n-- The provider's own \"default calendar\" flag (Graph's\n-- `isDefaultCalendar`). Used by `CalendarService.resolveDefaultCalendar`\n-- as the fallback when `core.defaultCalendar` is unset \u2014 we pick the\n-- first registered calendar (lowest id) flagged here. Added via\n-- ALTER so existing databases don't need a schema reset.\nALTER TABLE calendars ADD COLUMN IF NOT EXISTS is_default smallint NOT NULL DEFAULT 0;\n\nCREATE INDEX IF NOT EXISTS calendars_plugin_id_idx ON calendars (plugin_id);\n\nCREATE TABLE IF NOT EXISTS calendar_events (\n id text PRIMARY KEY,\n calendar_id bigint NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,\n series_master_id text,\n type text NOT NULL CHECK (type IN ('singleInstance','occurrence','exception','seriesMaster')),\n subject text,\n start_dt text NOT NULL,\n end_dt text NOT NULL,\n event_tz text,\n is_all_day smallint NOT NULL DEFAULT 0,\n is_cancelled smallint NOT NULL DEFAULT 0,\n show_as text,\n sensitivity text,\n importance text,\n location text,\n is_online_meeting smallint NOT NULL DEFAULT 0,\n online_meeting_url text,\n organizer_name text,\n organizer_email text,\n response_status text,\n attendees_json jsonb,\n body text,\n attachments jsonb,\n scan_generation int NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now(),\n updated_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS calendar_events_window_idx\n ON calendar_events (calendar_id, start_dt);\nCREATE INDEX IF NOT EXISTS calendar_events_series_idx\n ON calendar_events (series_master_id);\nCREATE INDEX IF NOT EXISTS calendar_events_generation_idx\n ON calendar_events (calendar_id, scan_generation);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 scheduled_subagents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- One-off agent-scheduled wake-ups. Each row is upserted by the\n-- container's `schedule_subagent` tool and read by the host-side\n-- `ScheduledSubagentScheduler`, which installs a Croner job per row\n-- and atomically deletes the row when it fires. `key` is the\n-- agent-supplied unique id used for replace-on-conflict semantics\n-- (\"rescheduling a key overwrites the previous timing\"). `fire_at`\n-- is the UTC firing time; the agent passes wall-clock-in-`core.timezone`\n-- and the container projects to UTC before insert. On daemon startup,\n-- past-due rows are deleted with a warn log; future rows are\n-- reinstalled. See [[feedback_agent_facing_format]] \u2014 UTC storage,\n-- local-tz projection at every agent-facing edge.\nCREATE TABLE IF NOT EXISTS scheduled_subagents (\n key text PRIMARY KEY,\n fire_at timestamptz NOT NULL,\n topic text NOT NULL,\n prompt text,\n payload jsonb NOT NULL DEFAULT '{}'::jsonb,\n priority smallint NOT NULL DEFAULT 50,\n privileged boolean NOT NULL DEFAULT false,\n created_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT scheduled_subagents_topic_format CHECK (topic ~ '^[\\w-]+(:[\\w-]+)*$')\n);\n\n-- The topic is the handler's whole address; the basename column is gone.\nALTER TABLE scheduled_subagents DROP COLUMN IF EXISTS handler;\n-- Re-add the topic CHECK so a widened TOPIC_PATTERN reaches existing tables.\nALTER TABLE scheduled_subagents DROP CONSTRAINT IF EXISTS scheduled_subagents_topic_format;\nALTER TABLE scheduled_subagents ADD CONSTRAINT scheduled_subagents_topic_format CHECK (topic ~ '^[\\w-]+(:[\\w-]+)*$');\n\nCREATE INDEX IF NOT EXISTS scheduled_subagents_fire_at_idx\n ON scheduled_subagents (fire_at);\n\nCREATE OR REPLACE FUNCTION scheduled_subagents_notify_changed() RETURNS trigger AS $$\nDECLARE\n payload_key text;\n op text;\nBEGIN\n IF TG_OP = 'DELETE' THEN\n payload_key := OLD.key;\n op := 'd';\n ELSE\n payload_key := NEW.key;\n op := 'u';\n END IF;\n PERFORM pg_notify('scheduled_subagents_changed', payload_key || ':' || op);\n RETURN COALESCE(NEW, OLD);\nEND;\n$$ LANGUAGE plpgsql;\n\nDROP TRIGGER IF EXISTS scheduled_subagents_notify_changed_trg ON scheduled_subagents;\nCREATE TRIGGER scheduled_subagents_notify_changed_trg\n AFTER INSERT OR UPDATE OR DELETE ON scheduled_subagents\n FOR EACH ROW EXECUTE FUNCTION scheduled_subagents_notify_changed();\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 inference_events \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Append-only audit trail of every upstream model HTTP call attempt.\n-- Written by AgentRunner on each generate() outcome (success / retryable /\n-- fatal). Read by the host-side `inference_status` reflection tool to\n-- answer \"is this model misbehaving right now?\". One row per attempt;\n-- retries write multiple rows. NULL agent_run_id is kept for runs whose\n-- row was later deleted (parent event cascade) so the historical signal\n-- survives.\nCREATE TABLE IF NOT EXISTS inference_events (\n id bigserial PRIMARY KEY,\n provider text NOT NULL,\n model text NOT NULL,\n agent_run_id bigint REFERENCES agentruns(id) ON DELETE SET NULL,\n outcome text NOT NULL CHECK (outcome IN ('success','retryable','fatal')),\n status_code int,\n error_excerpt text,\n occurred_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS inference_events_model_time_idx\n ON inference_events (model, occurred_at DESC);\nCREATE INDEX IF NOT EXISTS inference_events_occurred_at_idx\n ON inference_events (occurred_at DESC);\n\n-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 tool_calls \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n--\n-- Append-only record of every tool invocation an agentrun attempted,\n-- tagged with the resolved handler markdown path and whether the call\n-- threw. Written by the tool wrapper in the container's ToolsFactory on\n-- each execute() outcome. Read by the heuristic tool-preloader\n-- (ToolCallBus.topToolsForHandler): the tools a handler successfully\n-- used across its recent non-error runs get loaded up front on the next\n-- run, so warm handlers rarely need a tool_list / tool_call round-trip.\n-- NULL agent_run_id survives a parent-event cascade so the historical\n-- usage signal outlives the run row.\nCREATE TABLE IF NOT EXISTS tool_calls (\n id bigserial PRIMARY KEY,\n agent_run_id bigint REFERENCES agentruns(id) ON DELETE SET NULL,\n handler_path text NOT NULL,\n tool_name text NOT NULL,\n successful boolean NOT NULL,\n created_at timestamptz NOT NULL DEFAULT now()\n);\n\nCREATE INDEX IF NOT EXISTS tool_calls_handler_run_idx\n ON tool_calls (handler_path, agent_run_id DESC);\nCREATE INDEX IF NOT EXISTS tool_calls_created_at_idx\n ON tool_calls (created_at DESC);\n";
64
66
  /**
65
67
  * SQL fragment that flips the parent event's terminal state when the
66
68
  * **root** agentrun settles. Run inside the same transaction as the
package/build/Schema.js CHANGED
@@ -50,16 +50,18 @@ export const CHATMESSAGES_NEW_CHANNEL = "chatmessages_new";
50
50
  */
51
51
  export const SCHEDULED_SUBAGENTS_CHANNEL = "scheduled_subagents_changed";
52
52
  /**
53
- * Topic regex used by the events check constraint and by the container
54
- * runtime when resolving handler files. One word, optionally followed
55
- * by any number of `:`-prefixed sub-topic words. Examples:
56
- * `mail`, `chat:cli`, `chat:telegram:group:reaction`.
53
+ * Topic regex used by the topic check constraints and by the container
54
+ * runtime when resolving handler files. One word (word characters and
55
+ * dashes), optionally followed by any number of `:`-prefixed sub-topic
56
+ * words. Examples: `mail`, `chat:cli`, `mail:extract-style`,
57
+ * `chat:telegram:group:reaction`.
57
58
  *
58
- * The container resolves a handler for topic `a:b:c` by walking the
59
- * chain `a/b/c/<basename>.md → a/b/<basename>.md → a/<basename>.md`
60
- * deepest-first and merging every existing layer (see HandlerFile.load).
59
+ * The topic is a handler's whole address: `a:b:c` names the single file
60
+ * `a/b/c.md` (or its folder form `a/b/c/index.md`). Events fall back up
61
+ * the chain (`a:b`, `a`) when that file is missing; everything else must
62
+ * match exactly (see `HandlerResolution.ts`).
61
63
  */
62
- export const TOPIC_PATTERN = "^\\w+(:\\w+)*$";
64
+ export const TOPIC_PATTERN = "^[\\w-]+(:[\\w-]+)*$";
63
65
  export const SCHEMA_SQL = `
64
66
  -- Drop legacy single-table artifacts from before the events/agentruns
65
67
  -- split. Safe no-ops on fresh databases. The current schema reuses the
@@ -105,10 +107,13 @@ ALTER TABLE events ADD COLUMN IF NOT EXISTS preferred_chat_channel_id text;
105
107
  -- emitter's framing without the EventWatcher having to reach into the
106
108
  -- payload schema.
107
109
  ALTER TABLE events ADD COLUMN IF NOT EXISTS prompt text;
108
- -- Optional override for the root agentrun's handler. When NULL the
109
- -- input-event watcher falls back to 'index' — same behaviour as
110
- -- before this column existed. Set by the emitter on NewEvent.
111
- ALTER TABLE events ADD COLUMN IF NOT EXISTS start_handler text;
110
+ -- The topic is the handler's whole address now; the separate start
111
+ -- handler basename is gone.
112
+ ALTER TABLE events DROP COLUMN IF EXISTS start_handler;
113
+ -- When true the topic must name an existing handler file exactly; when
114
+ -- false (world events) handler resolution falls back up the topic chain.
115
+ -- Copied onto the root agentrun by the input-event watcher.
116
+ ALTER TABLE events ADD COLUMN IF NOT EXISTS is_exact_topic boolean NOT NULL DEFAULT false;
112
117
  -- Privileged events originate from a trusted user-input source (operator
113
118
  -- at the local terminal via cli-chat, operator on Telegram). The flag
114
119
  -- propagates verbatim to the root agentrun and to every child agentrun
@@ -133,7 +138,6 @@ CREATE TABLE IF NOT EXISTS agentruns (
133
138
  event_id bigint NOT NULL REFERENCES events(id) ON DELETE CASCADE,
134
139
  parent_agentrun_id bigint REFERENCES agentruns(id) ON DELETE CASCADE,
135
140
  topic text NOT NULL,
136
- handler text NOT NULL,
137
141
  priority smallint NOT NULL DEFAULT 50,
138
142
  state text NOT NULL DEFAULT 'pending',
139
143
  prompt text,
@@ -150,6 +154,11 @@ CREATE TABLE IF NOT EXISTS agentruns (
150
154
  -- Forward-compat ALTER for databases created before result_text landed.
151
155
  -- Safe no-op on fresh databases where the column already exists.
152
156
  ALTER TABLE agentruns ADD COLUMN IF NOT EXISTS result_text text;
157
+ -- The topic is the handler's whole address; the basename column is gone.
158
+ ALTER TABLE agentruns DROP COLUMN IF EXISTS handler;
159
+ -- Whether handler resolution must match the topic exactly. Root
160
+ -- agentruns copy the event's flag; spawned children are always exact.
161
+ ALTER TABLE agentruns ADD COLUMN IF NOT EXISTS is_exact_topic boolean NOT NULL DEFAULT true;
153
162
  -- Inherited from the originating event (root agentrun) or parent
154
163
  -- agentrun (children spawned via schedule_subagent / start_subagent). See
155
164
  -- the matching ALTER on the events table for the trust-model rationale.
@@ -452,7 +461,6 @@ CREATE TABLE IF NOT EXISTS scheduled_subagents (
452
461
  key text PRIMARY KEY,
453
462
  fire_at timestamptz NOT NULL,
454
463
  topic text NOT NULL,
455
- handler text NOT NULL,
456
464
  prompt text,
457
465
  payload jsonb NOT NULL DEFAULT '{}'::jsonb,
458
466
  priority smallint NOT NULL DEFAULT 50,
@@ -461,6 +469,12 @@ CREATE TABLE IF NOT EXISTS scheduled_subagents (
461
469
  CONSTRAINT scheduled_subagents_topic_format CHECK (topic ~ '${TOPIC_PATTERN}')
462
470
  );
463
471
 
472
+ -- The topic is the handler's whole address; the basename column is gone.
473
+ ALTER TABLE scheduled_subagents DROP COLUMN IF EXISTS handler;
474
+ -- Re-add the topic CHECK so a widened TOPIC_PATTERN reaches existing tables.
475
+ ALTER TABLE scheduled_subagents DROP CONSTRAINT IF EXISTS scheduled_subagents_topic_format;
476
+ ALTER TABLE scheduled_subagents ADD CONSTRAINT scheduled_subagents_topic_format CHECK (topic ~ '${TOPIC_PATTERN}');
477
+
464
478
  CREATE INDEX IF NOT EXISTS scheduled_subagents_fire_at_idx
465
479
  ON scheduled_subagents (fire_at);
466
480
 
package/build/index.d.ts CHANGED
@@ -4,14 +4,15 @@ export { BASTION_TOKEN_HEADER } from "./BastionAuth.js";
4
4
  export { buildCalendarEventId, type CalendarApi, type CalendarAttachmentMeta, type CalendarAttendee, type CalendarChangePayload, type CalendarEventRow, type CalendarEventType, type CalendarImportance, type CalendarProvider, type CalendarResponseStatus, type CalendarRow, type CalendarSensitivity, type CalendarShowAs, type CalendarType, type CreateEventInput, type FindEventsQuery, type NewCalendarEvent, parseCalendarEventId, type UpdateEventInput, type UpsertCalendarInput, } from "./Calendar.js";
5
5
  export type { ChatFilter, ChatMessage, ChatRole, NewChatMessage } from "./ChatMessage.js";
6
6
  export { type ChatHandler, ChatMessageBus, type ChatUnsubscribe } from "./ChatMessageBus.js";
7
- export type { ConfigService } from "./Config.js";
7
+ export { type ConfigService, DEFAULT_WRITABLE_PATHS } from "./Config.js";
8
8
  export type { ContainerToolInfo } from "./ContainerTool.js";
9
9
  export { type ParsedCron, parseCron } from "./CronExpression.js";
10
10
  export type { EventFile, EventFilter, EventPatch, EventRow, EventState, NewEvent, } from "./Event.js";
11
11
  export { EVENT_PRIORITY } from "./Event.js";
12
12
  export { DuplicateIdempotencyKeyError, EventBus } from "./EventBus.js";
13
13
  export { estimateTokens } from "./estimateTokens.js";
14
- export { HandlerCatalog, type HandlerPath } from "./HandlerCatalog.js";
14
+ export { HandlerCatalog, type HandlerPath, type ResolvedHandler } from "./HandlerCatalog.js";
15
+ export { type HandlerResolution, type HandlerResolutionOptions, handlerCandidateLevels, resolveHandlerPath, topicFromHandlerPath, twinHandlerPath, } from "./HandlerResolution.js";
15
16
  export type { InferenceEventRow, InferenceOutcome, NewInferenceEvent, } from "./InferenceEvent.js";
16
17
  export { InferenceEventBus } from "./InferenceEventBus.js";
17
18
  export { type CreateLoggerOptions, createLogger, jsonStdoutStream, type Logger, type LogLevel, type LogStream, prettyStdoutStream, } from "./logging/Logger.js";
@@ -22,7 +23,7 @@ export { renderMarkdownToHtml } from "./markdownHtml.js";
22
23
  export { escapeTableCell, markdownTable, writeMarkdown } from "./markdownOutput.js";
23
24
  export { renderMarkdown } from "./markdownTerminal.js";
24
25
  export { matchesAnyGlob, matchesGlob } from "./PathGlob.js";
25
- export type { AnyCommandDef, EmitHandle, EmitOptions, HostContext, McpClient, McpInfo, PluginHostManifest, PluginManifest, PluginTool, PluginToolCallContext, PromptAppender, PromptPlaceholderRegistration, } from "./Plugin.js";
26
+ export type { AnyCommandDef, EmitHandle, EmitOptions, EmittedEventDescriptor, HostContext, McpClient, McpInfo, PluginHostManifest, PluginManifest, PluginTool, PluginToolCallContext, PromptAppender, PromptPlaceholderRegistration, } from "./Plugin.js";
26
27
  export { DaemonStoppedError, definePlugin } from "./Plugin.js";
27
28
  export { type NotificationHandler, POSTGRES_DB, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, PostgresConnection, type PostgresConnectionConfig, } from "./PostgresConnection.js";
28
29
  export { CACHE_MARKER_PLACEHOLDER, CORE_PROMPT_PLACEHOLDERS, isCorePromptPlaceholder, PROMPT_PLACEHOLDER_NAME_PATTERN, PROMPT_TEMPLATE_FILE, PromptPlaceholderConflictError, type PromptPlaceholderDefinition, type PromptPlaceholderKind, } from "./PromptPlaceholders.js";
package/build/index.js CHANGED
@@ -2,11 +2,13 @@ export { AgentRunBus } from "./AgentRunBus.js";
2
2
  export { BASTION_TOKEN_HEADER } from "./BastionAuth.js";
3
3
  export { buildCalendarEventId, parseCalendarEventId, } from "./Calendar.js";
4
4
  export { ChatMessageBus } from "./ChatMessageBus.js";
5
+ export { DEFAULT_WRITABLE_PATHS } from "./Config.js";
5
6
  export { parseCron } from "./CronExpression.js";
6
7
  export { EVENT_PRIORITY } from "./Event.js";
7
8
  export { DuplicateIdempotencyKeyError, EventBus } from "./EventBus.js";
8
9
  export { estimateTokens } from "./estimateTokens.js";
9
10
  export { HandlerCatalog } from "./HandlerCatalog.js";
11
+ export { handlerCandidateLevels, resolveHandlerPath, topicFromHandlerPath, twinHandlerPath, } from "./HandlerResolution.js";
10
12
  export { InferenceEventBus } from "./InferenceEventBus.js";
11
13
  export { createLogger, jsonStdoutStream, prettyStdoutStream, } from "./logging/Logger.js";
12
14
  export { buildMailId, parseMailId, } from "./Mail.js";
@@ -33,7 +33,7 @@ export class MockAgentRunBus {
33
33
  eventId: run.eventId,
34
34
  parentAgentrunId: run.parentAgentrunId ?? null,
35
35
  topic: run.topic,
36
- handler: run.handler,
36
+ isExactTopic: run.isExactTopic ?? true,
37
37
  priority: run.priority ?? 50,
38
38
  state: run.state ?? "pending",
39
39
  prompt: run.prompt ?? null,
@@ -29,7 +29,7 @@ export class MockEventBus {
29
29
  isChat: event.isChat === true,
30
30
  preferredChatChannelId: event.preferredChatChannelId ?? null,
31
31
  prompt: event.prompt ?? null,
32
- startHandler: event.startHandler ?? null,
32
+ isExactTopic: event.isExactTopic ?? false,
33
33
  privileged: event.privileged === true,
34
34
  outputChatOnFailure: event.outputChatOnFailure === true,
35
35
  createdAt: now,