@getfamiliar/shared 0.1.12 → 0.1.15

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.
@@ -1,5 +1,6 @@
1
- import { promises as fs } from "node:fs";
1
+ import { promises as fs, statSync } from "node:fs";
2
2
  import path from "node:path";
3
+ import { resolveHandlerPath, topicFromHandlerPath, twinHandlerPath, } from "./HandlerResolution.js";
3
4
  import { matchesAnyGlob } from "./PathGlob.js";
4
5
  /**
5
6
  * Workspace subtrees excluded from {@link HandlerCatalog.list} (cli-chat
@@ -13,11 +14,11 @@ const RESERVED_TOP_DIRS = new Set(["skills"]);
13
14
  /**
14
15
  * Read-only view over the `workspace/` directory's handler files.
15
16
  *
16
- * Mirrors the resolution semantics implemented by
17
- * `container/src/HandlerFile.ts` so host-side code (CLI, cron
17
+ * Shares the resolution rules of `container/src/HandlerFile.ts` (both
18
+ * build on `HandlerResolution.ts`) so host-side code (CLI, cron
18
19
  * scheduler introspection, tab-completion) can answer "does this
19
20
  * handler exist?" and "list every handler" without depending on
20
- * container internals or reimplementing the topic-walk inheritance.
21
+ * container internals.
21
22
  *
22
23
  * The catalog is stateless on the disk — every method re-scans —
23
24
  * so live workspace edits are reflected without a daemon restart.
@@ -41,12 +42,14 @@ export class HandlerCatalog {
41
42
  }
42
43
  /**
43
44
  * Walk `workspace/` and return one {@link HandlerPath} per
44
- * candidate handler file. Excludes every file at the workspace root
45
- * (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they are
46
- * never handlers) and the `skills/` subtree. Every other `.md` file under a topic
47
- * directory is included, even if it's a knowledge file (e.g.
48
- * `people/anna.md`) — `HandlerCatalog` does not police what is or
49
- * isn't a "proper" handler; that's a handler-loading concern.
45
+ * addressable handler file. Excludes every file at the workspace
46
+ * root (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they
47
+ * are never handlers), the `skills/` subtree, writable paths, file
48
+ * names that don't form a valid topic, and a folder-form
49
+ * `x/index.md` shadowed by its `x.md` twin. Every other `.md` file
50
+ * under a topic directory is included, even if it's a knowledge file
51
+ * (e.g. `people/anna.md`) — `HandlerCatalog` does not police what is
52
+ * or isn't a "proper" handler; that's a handler-loading concern.
50
53
  *
51
54
  * Sorted by `slashPath` for stable display ordering in
52
55
  * tab-completion. Returns an empty array when the workspace
@@ -57,50 +60,66 @@ export class HandlerCatalog {
57
60
  if (!exists) {
58
61
  return [];
59
62
  }
63
+ const relativePaths = [];
64
+ await walkMarkdown(this.workspaceDir, [], relativePaths);
65
+ const present = new Set(relativePaths);
60
66
  const results = [];
61
- await walkMarkdown(this.workspaceDir, [], results, this.workspaceDir, this.writablePathGlobs);
67
+ for (const relativePath of relativePaths) {
68
+ // Files under `core.writablePaths` (e.g. `wiki/**`) are writable by
69
+ // non-privileged runs and are never handlers — never propose them.
70
+ if (matchesAnyGlob(this.writablePathGlobs, relativePath)) {
71
+ continue;
72
+ }
73
+ const topic = topicFromHandlerPath(relativePath);
74
+ if (topic === null) {
75
+ continue;
76
+ }
77
+ const twin = twinHandlerPath(relativePath);
78
+ if (relativePath.endsWith("/index.md") && twin !== null && present.has(twin)) {
79
+ continue;
80
+ }
81
+ results.push({
82
+ topic,
83
+ absolutePath: path.join(this.workspaceDir, relativePath),
84
+ relativePath,
85
+ slashPath: `/${topic.split(":").join("/")}`,
86
+ });
87
+ }
62
88
  results.sort((a, b) => a.slashPath.localeCompare(b.slashPath));
63
89
  return results;
64
90
  }
65
91
  /**
66
- * Resolve a handler call: given a topic (e.g. `chat:telegram`)
67
- * and a basename (e.g. `index`), return the absolute path of the
68
- * deepest existing handler file, mirroring the container's
69
- * inheritance walk.
92
+ * Resolve a topic to the one handler file that runs for it, with
93
+ * the same rules as the container's `HandlerFile.load`: file form
94
+ * `a/b/c.md` before folder form `a/b/c/index.md`, and — unless
95
+ * `isExactTopic` — falling back up the topic chain.
70
96
  *
71
- * For topic `a:b:c` and basename `x` the resolution tries, in
72
- * order: `a/b/c/x.md`, `a/b/x.md`, `a/x.md`. Returns the first
73
- * existing path or `null` if none exist. The deepest existing
74
- * file is what `HandlerFile.load` would treat as the leaf — for
75
- * cli-chat existence checks, that's exactly the question we
76
- * want answered.
97
+ * @param topic Colon-separated topic, e.g. `chat:telegram`.
98
+ * @param options See {@link HandlerResolutionOptions}.
99
+ * @returns The resolution, or `null` when nothing matches or the
100
+ * winning file lives under a writable path (the container refuses
101
+ * to run it, so it does not resolve).
77
102
  */
78
- async resolve(topic, handler) {
79
- const segments = topic.split(":");
80
- for (let depth = segments.length; depth >= 1; depth--) {
81
- const rel = path.join(...segments.slice(0, depth), `${handler}.md`);
82
- const relPosix = rel.split(path.sep).join("/");
83
- const absolute = path.join(this.workspaceDir, rel);
84
- if (await fileExists(absolute)) {
85
- // A file under `core.writablePaths` is never a handler, even
86
- // when it exists — mirror the container's `HandlerFile.load`
87
- // guard so host-side callers agree it does not resolve.
88
- if (matchesAnyGlob(this.writablePathGlobs, relPosix)) {
89
- return null;
90
- }
91
- return absolute;
92
- }
103
+ async resolve(topic, options) {
104
+ const resolution = resolveHandlerPath(topic, options, (relativePath) => isFileSync(path.join(this.workspaceDir, relativePath)));
105
+ if (resolution === null ||
106
+ matchesAnyGlob(this.writablePathGlobs, resolution.relativePath)) {
107
+ return null;
93
108
  }
94
- return null;
109
+ return {
110
+ ...resolution,
111
+ absolutePath: path.join(this.workspaceDir, resolution.relativePath),
112
+ };
95
113
  }
96
114
  }
97
115
  /**
98
- * Recursively walk `dir`, accumulating one {@link HandlerPath} per
99
- * `.md` file. `segments` tracks the topic path from the workspace
100
- * root; an empty array means we're at the root, whose files are
101
- * skipped (no topic, never a handler).
116
+ * Recursively walk `dir`, accumulating the workspace-relative posix
117
+ * path of every `.md` file below a topic folder. `segments` tracks the
118
+ * folder path from the workspace root; files at the root itself are
119
+ * skipped (no topic, never a handler), and so is the reserved
120
+ * `skills/` subtree.
102
121
  */
103
- async function walkMarkdown(dir, segments, out, workspaceDir, writablePathGlobs) {
122
+ async function walkMarkdown(dir, segments, out) {
104
123
  let entries;
105
124
  try {
106
125
  entries = await fs.readdir(dir, { withFileTypes: true });
@@ -113,35 +132,22 @@ async function walkMarkdown(dir, segments, out, workspaceDir, writablePathGlobs)
113
132
  if (segments.length === 0 && RESERVED_TOP_DIRS.has(entry.name)) {
114
133
  continue;
115
134
  }
116
- await walkMarkdown(path.join(dir, entry.name), [...segments, entry.name], out, workspaceDir, writablePathGlobs);
135
+ await walkMarkdown(path.join(dir, entry.name), [...segments, entry.name], out);
117
136
  continue;
118
137
  }
119
- if (!entry.isFile() || !entry.name.endsWith(".md")) {
138
+ if (!entry.isFile() || !entry.name.endsWith(".md") || segments.length === 0) {
120
139
  continue;
121
140
  }
122
- if (segments.length === 0) {
123
- // `.md` at the workspace root (PROMPT.md, SOUL.md, …) has no
124
- // topic folder — never a handler. Skip silently rather than
125
- // reporting an empty topic.
126
- continue;
127
- }
128
- const handlerBase = entry.name.slice(0, -".md".length);
129
- const topic = segments.join(":");
130
- const absolutePath = path.join(dir, entry.name);
131
- const relativePath = path.relative(workspaceDir, absolutePath).split(path.sep).join("/");
132
- // Files under `core.writablePaths` (e.g. `wiki/**`) are writable by
133
- // non-privileged runs and are never handlers — never propose them.
134
- if (matchesAnyGlob(writablePathGlobs, relativePath)) {
135
- continue;
136
- }
137
- const slashPath = `/${[...segments, handlerBase].join("/")}`;
138
- out.push({ topic, handler: handlerBase, absolutePath, relativePath, slashPath });
141
+ out.push([...segments, entry.name].join("/"));
139
142
  }
140
143
  }
141
- async function fileExists(absolute) {
144
+ /**
145
+ * Synchronous regular-file probe, used as the existence callback of
146
+ * {@link resolveHandlerPath}.
147
+ */
148
+ function isFileSync(absolute) {
142
149
  try {
143
- const stat = await fs.stat(absolute);
144
- return stat.isFile();
150
+ return statSync(absolute).isFile();
145
151
  }
146
152
  catch {
147
153
  return false;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Outcome of {@link resolveHandlerPath}: the one handler file a topic
3
+ * resolves to.
4
+ */
5
+ export interface HandlerResolution {
6
+ /** Workspace-relative posix path of the winning file, e.g. `mail/new/ms365.md`. */
7
+ readonly relativePath: string;
8
+ /**
9
+ * Workspace-relative path of the twin that lost against
10
+ * {@link relativePath} on the same level (`mail/new/ms365/index.md`
11
+ * when `mail/new/ms365.md` also exists), or `null` when the level
12
+ * held only one file. Only one of the two forms is allowed; callers
13
+ * warn when this is set.
14
+ */
15
+ readonly shadowedPath: string | null;
16
+ }
17
+ /**
18
+ * Options for {@link handlerCandidateLevels} / {@link resolveHandlerPath}.
19
+ */
20
+ export interface HandlerResolutionOptions {
21
+ /**
22
+ * `true` ⇒ only the topic's own level is tried (cron, subagents,
23
+ * scheduled wake-ups, addressed emissions). `false` ⇒ the resolver
24
+ * walks up the topic chain (`a:b:c → a:b → a`), which is what
25
+ * world events get.
26
+ */
27
+ readonly isExactTopic: boolean;
28
+ }
29
+ /**
30
+ * Build the candidate handler files for a topic, one entry per topic
31
+ * level (most specific first). Each level lists its candidates in
32
+ * precedence order: the file form `a/b/c.md` before the folder form
33
+ * `a/b/c/index.md`. A single-segment topic `a` has only `a/index.md`
34
+ * — workspace-root files are never handlers.
35
+ *
36
+ * For topic `mail:new:ms365` without `isExactTopic`:
37
+ *
38
+ * 1. `mail/new/ms365.md`, `mail/new/ms365/index.md`
39
+ * 2. `mail/new.md`, `mail/new/index.md`
40
+ * 3. `mail/index.md`
41
+ *
42
+ * @param topic Colon-separated topic, e.g. `mail:new:ms365`.
43
+ * @param options See {@link HandlerResolutionOptions}.
44
+ * @returns The candidate levels, most specific first.
45
+ */
46
+ export declare function handlerCandidateLevels(topic: string, options: HandlerResolutionOptions): readonly (readonly string[])[];
47
+ /**
48
+ * Resolve a topic to the single handler file that runs for it. The
49
+ * first level of {@link handlerCandidateLevels} holding any existing
50
+ * file wins; within a level the file form beats the folder form and
51
+ * the loser is reported as {@link HandlerResolution.shadowedPath}.
52
+ * Nothing is merged — exactly one file is the handler.
53
+ *
54
+ * @param topic Colon-separated topic, e.g. `mail:new:ms365`.
55
+ * @param options See {@link HandlerResolutionOptions}.
56
+ * @param isFile Existence probe for a workspace-relative path.
57
+ * @returns The resolution, or `null` when no candidate exists.
58
+ */
59
+ export declare function resolveHandlerPath(topic: string, options: HandlerResolutionOptions, isFile: (relativePath: string) => boolean): HandlerResolution | null;
60
+ /**
61
+ * Derive the topic a handler file answers to. The inverse of
62
+ * {@link handlerCandidateLevels}' own level:
63
+ *
64
+ * - `mail/important/digest.md` → `mail:important:digest`
65
+ * - `mail/new/index.md` → `mail:new`
66
+ * - `mail/index.md` → `mail`
67
+ *
68
+ * @param relativePath Workspace-relative posix path ending in `.md`.
69
+ * @returns The topic, or `null` for workspace-root files, non-`.md`
70
+ * files and paths whose segments don't form a valid topic (e.g. a
71
+ * file name containing a dot or a space).
72
+ */
73
+ export declare function topicFromHandlerPath(relativePath: string): string | null;
74
+ /**
75
+ * The other form of the same handler: `a/b/c.md` ↔ `a/b/c/index.md`.
76
+ * Only one of the two may exist; seeding and the cron scheduler use
77
+ * this to detect the conflict.
78
+ *
79
+ * @param relativePath Workspace-relative posix path ending in `.md`.
80
+ * @returns The twin's path, or `null` when the twin would be a
81
+ * workspace-root file (`a/index.md` ↔ `a.md`) or the path is a root
82
+ * file / not markdown.
83
+ */
84
+ export declare function twinHandlerPath(relativePath: string): string | null;
@@ -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,