@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.
@@ -40,18 +40,19 @@ export interface AgentRunRow {
40
40
  */
41
41
  readonly parentAgentrunId: string | null;
42
42
  /**
43
- * Topic copied from the originating event at queue time. Combined
44
- * with {@link handler} to resolve a workspace markdown file.
43
+ * The handler's address. Root agentruns copy the event's topic;
44
+ * spawned children carry the topic the spawning tool named. For
45
+ * topic `chat:whatsapp` the handler file is
46
+ * `workspace/chat/whatsapp.md` (or `workspace/chat/whatsapp/index.md`).
45
47
  */
46
48
  readonly topic: string;
47
49
  /**
48
- * Basename of the handler file in the workspace (e.g. `index`,
49
- * `analyze`). Resolution rules — for topic `chat:whatsapp` and
50
- * handler `analyze`:
51
- * 1. `workspace/chat/whatsapp/analyze.md`
52
- * 2. fallback `workspace/chat/analyze.md`
50
+ * `true` when the topic must resolve to its own handler file;
51
+ * `false` when resolution may fall back up the topic chain
52
+ * (`chat:whatsapp` → `chat`). Root agentruns copy the event's
53
+ * {@link EventRow.isExactTopic}; spawned children are always exact.
53
54
  */
54
- readonly handler: string;
55
+ readonly isExactTopic: boolean;
55
56
  /**
56
57
  * Resolved model id actually used for this run, of the form
57
58
  * `<provider>/<modelId>` (e.g. `featherless/zai-org/GLM-5.1`).
@@ -147,10 +148,14 @@ export interface NewAgentRun {
147
148
  readonly eventId: string;
148
149
  /** FK to the parent agentrun, or omitted/null for the root agentrun. */
149
150
  readonly parentAgentrunId?: string | null;
150
- /** Topic copied from the event. Required. */
151
+ /** Handler address — the event's topic for roots, the named topic for children. Required. */
151
152
  readonly topic: string;
152
- /** Handler basename (e.g. `index`). Required. */
153
- readonly handler: string;
153
+ /**
154
+ * Whether handler resolution must match the topic exactly. Default
155
+ * `true` (the column default) — only the input-event watcher passes
156
+ * `false`, for events whose topic may fall back up the chain.
157
+ */
158
+ readonly isExactTopic?: boolean;
154
159
  /** Defaults to 50; usually inherited from the event. */
155
160
  readonly priority?: number;
156
161
  /** Initial state; default `"pending"`. */
@@ -44,13 +44,13 @@ export class AgentRunBus {
44
44
  */
45
45
  async add(run) {
46
46
  const result = await this.connection.getPool().query(`INSERT INTO agentruns
47
- (event_id, parent_agentrun_id, topic, handler, priority, state, prompt, payload, privileged, calltype)
47
+ (event_id, parent_agentrun_id, topic, is_exact_topic, priority, state, prompt, payload, privileged, calltype)
48
48
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10)
49
49
  RETURNING *`, [
50
50
  run.eventId,
51
51
  run.parentAgentrunId ?? null,
52
52
  run.topic,
53
- run.handler,
53
+ run.isExactTopic ?? true,
54
54
  run.priority ?? 50,
55
55
  run.state ?? "pending",
56
56
  run.prompt ?? null,
@@ -339,7 +339,7 @@ function mapRow(raw) {
339
339
  eventId: raw.event_id,
340
340
  parentAgentrunId: raw.parent_agentrun_id,
341
341
  topic: raw.topic,
342
- handler: raw.handler,
342
+ isExactTopic: raw.is_exact_topic,
343
343
  priority: raw.priority,
344
344
  state: raw.state,
345
345
  prompt: raw.prompt,
package/build/Config.d.ts CHANGED
@@ -19,6 +19,11 @@
19
19
  * boot. Plugin-side keys are not policed by the platform — plugins own
20
20
  * the validation of their own subtrees.
21
21
  */
22
+ /**
23
+ * Fallback for `core.writablePaths` when `config.yml` doesn't set it:
24
+ * the workspace globs the agent may write to, which are never handlers.
25
+ */
26
+ export declare const DEFAULT_WRITABLE_PATHS: readonly string[];
22
27
  export interface ConfigService {
23
28
  /**
24
29
  * Read a string under a dotted path. Throws when the key is
package/build/Config.js CHANGED
@@ -1 +1,26 @@
1
- export {};
1
+ /**
2
+ * Plugin-agnostic configuration surface exposed by the host.
3
+ *
4
+ * Plugins reach configuration through `HostContext.config` rather than
5
+ * touching `process.env` or reading the YAML file themselves. The
6
+ * service intentionally has no plugin-specific types; everything is
7
+ * keyed by dotted path strings (e.g. `"core.postgresPassword"`,
8
+ * `"telegram.botToken"`) and validated to a primitive shape at the
9
+ * call site.
10
+ *
11
+ * Required-vs-optional semantics live in the call signature, not in
12
+ * the schema: omitting `defaultValue` makes the read throw on missing
13
+ * or wrong-shaped data, while supplying one widens the return type to
14
+ * include the default (so plugins can pass `null` for fields they
15
+ * tolerate being absent).
16
+ *
17
+ * The platform-level minimum (the keys without which the daemon can't
18
+ * start) is enforced by a separate `ConfigLinter` on the host side at
19
+ * boot. Plugin-side keys are not policed by the platform — plugins own
20
+ * the validation of their own subtrees.
21
+ */
22
+ /**
23
+ * Fallback for `core.writablePaths` when `config.yml` doesn't set it:
24
+ * the workspace globs the agent may write to, which are never handlers.
25
+ */
26
+ export const DEFAULT_WRITABLE_PATHS = ["wiki/**", "files/**"];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Shared bridge network all Familiar containers join. Owned
3
+ * by the daemon (see `commands/Start.ts`); created once via
4
+ * {@link ensureNetwork} before any container is started.
5
+ */
6
+ export declare const SHARED_NETWORK_NAME = "familiar-net";
7
+ /**
8
+ * Internal (egress-less) bridge network the locked-down agent container
9
+ * joins. Created with `--internal` so the agent has no route to the
10
+ * internet; it reaches postgres (dual-homed onto this net) and the host
11
+ * bastion (via the `familiar-bastion-bridge` socat sidecar, also on this
12
+ * net) by container name. Owned by the daemon, created via
13
+ * {@link ensureNetwork} with `{ internal: true }`.
14
+ */
15
+ export declare const ISOLATED_NETWORK_NAME = "familiar-isolated";
16
+ /** Options shared by {@link dockerExec}. */
17
+ export interface DockerExecOptions {
18
+ /** If true, non-zero exit codes resolve instead of throwing. */
19
+ readonly allowFailure?: boolean;
20
+ }
21
+ /** Output of {@link dockerCapture}. */
22
+ export interface DockerCaptureResult {
23
+ readonly code: number | null;
24
+ readonly stdout: string;
25
+ }
26
+ /**
27
+ * Run a docker CLI command and discard its stdio. Resolves on exit
28
+ * code 0 (or always when `allowFailure` is set); rejects otherwise.
29
+ */
30
+ export declare function dockerExec(args: readonly string[], options?: DockerExecOptions): Promise<void>;
31
+ /**
32
+ * Run a docker CLI command and capture stdout. Never throws on non-zero
33
+ * exit; callers inspect the returned `code`.
34
+ */
35
+ export declare function dockerCapture(args: readonly string[]): Promise<DockerCaptureResult>;
36
+ /** Whether a container with the given name is currently running. */
37
+ export declare function isContainerRunning(name: string): Promise<boolean>;
38
+ /** Whether a Docker network with the given name already exists. */
39
+ export declare function isNetworkPresent(name: string): Promise<boolean>;
40
+ /**
41
+ * Whether a Docker image with the given reference is present in the
42
+ * local image store. Used by the pull-mode image lifecycle to avoid
43
+ * re-pulling a version-pinned image that's already local.
44
+ *
45
+ * @param ref Image reference (tag or registry-qualified `name:tag`).
46
+ * @returns true if `docker image inspect` succeeds.
47
+ */
48
+ export declare function isImagePresent(ref: string): Promise<boolean>;
49
+ /** Options for {@link buildNetworkCreateArgs} / {@link ensureNetwork}. */
50
+ export interface NetworkCreateOptions {
51
+ /**
52
+ * Create the network with `--internal` so containers on it have no
53
+ * egress to the host or the internet — only to other containers on
54
+ * the same network. Used for the agent's `familiar-isolated` net.
55
+ */
56
+ readonly internal?: boolean;
57
+ }
58
+ /**
59
+ * Build the `docker network create` argument vector. Pure (no side
60
+ * effects) so it can be unit-tested without a daemon.
61
+ *
62
+ * @param name Network name to create.
63
+ * @param options Network creation flags (e.g. `internal`).
64
+ * @returns The full docker CLI argv, e.g. `["network", "create", "--internal", "familiar-isolated"]`.
65
+ */
66
+ export declare function buildNetworkCreateArgs(name: string, options?: NetworkCreateOptions): string[];
67
+ /**
68
+ * Create the named bridge network if it doesn't already exist. Idempotent.
69
+ *
70
+ * @param name Network name to ensure.
71
+ * @param options Network creation flags applied only on first creation
72
+ * (an existing network is left as-is).
73
+ */
74
+ export declare function ensureNetwork(name: string, options?: NetworkCreateOptions): Promise<void>;
75
+ /**
76
+ * Attach an already-running container to an additional network by name.
77
+ * Callers recreate their container on every `start()` (via
78
+ * {@link removeContainer} first), so the attachment is always fresh —
79
+ * this throws on failure rather than swallowing it, so a genuine
80
+ * problem (missing network, missing container) surfaces instead of
81
+ * silently leaving the container unable to reach its peer.
82
+ *
83
+ * @param network Network to connect the container to.
84
+ * @param container Name of the running container to attach.
85
+ * @throws If the docker command fails (network/container missing, etc.).
86
+ */
87
+ export declare function connectNetwork(network: string, container: string): Promise<void>;
88
+ /**
89
+ * Force-remove a container by name. Default `allowFailure: true` because
90
+ * "remove if it exists" is the common idempotent-cleanup intent.
91
+ */
92
+ export declare function removeContainer(name: string, options?: DockerExecOptions): Promise<void>;
93
+ /** Send `docker stop` to a container. Ignores already-stopped/missing containers. */
94
+ export declare function stopContainer(name: string): Promise<void>;
95
+ /**
96
+ * Run a docker CLI command with the parent process's stdin/stdout/
97
+ * stderr inherited, blocking until the child exits. Used by
98
+ * subcommands that hand the user's terminal to a foreground docker
99
+ * process (`familiar psql`'s psql shell, `familiar mcp call`'s
100
+ * one-shot CLI invocations against an MCP runtime container).
101
+ *
102
+ * Returns the child's exit status — `null`-on-signal collapses to
103
+ * 1 so the caller's `process.exit(...)` always sees a usable
104
+ * number. A failure to launch docker itself (e.g. binary missing)
105
+ * surfaces as a thrown {@link Error} the caller can format.
106
+ *
107
+ * @throws If the docker binary couldn't be spawned at all.
108
+ */
109
+ export declare function dockerInteractive(args: readonly string[]): number;
110
+ /**
111
+ * On Linux, `host.docker.internal` isn't built-in and must be mapped to
112
+ * the host gateway explicitly. macOS and Windows resolve it natively.
113
+ *
114
+ * Used by sidecars that dial a host-side listener (the bastion bridge,
115
+ * the tailscale plugin's serve proxy to the webserver).
116
+ *
117
+ * @returns Extra `docker run` args, or an empty array on macOS/Windows.
118
+ */
119
+ export declare function hostGatewayArgs(): string[];
@@ -0,0 +1,175 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import os from "node:os";
3
+ /**
4
+ * Shared bridge network all Familiar containers join. Owned
5
+ * by the daemon (see `commands/Start.ts`); created once via
6
+ * {@link ensureNetwork} before any container is started.
7
+ */
8
+ export const SHARED_NETWORK_NAME = "familiar-net";
9
+ /**
10
+ * Internal (egress-less) bridge network the locked-down agent container
11
+ * joins. Created with `--internal` so the agent has no route to the
12
+ * internet; it reaches postgres (dual-homed onto this net) and the host
13
+ * bastion (via the `familiar-bastion-bridge` socat sidecar, also on this
14
+ * net) by container name. Owned by the daemon, created via
15
+ * {@link ensureNetwork} with `{ internal: true }`.
16
+ */
17
+ export const ISOLATED_NETWORK_NAME = "familiar-isolated";
18
+ /**
19
+ * Run a docker CLI command and discard its stdio. Resolves on exit
20
+ * code 0 (or always when `allowFailure` is set); rejects otherwise.
21
+ */
22
+ export function dockerExec(args, options = {}) {
23
+ return new Promise((resolve, reject) => {
24
+ const proc = spawn("docker", [...args], { stdio: ["ignore", "ignore", "pipe"] });
25
+ let stderr = "";
26
+ proc.stderr.on("data", (chunk) => {
27
+ stderr += chunk.toString();
28
+ });
29
+ proc.on("close", (code) => {
30
+ if (code === 0 || options.allowFailure) {
31
+ resolve();
32
+ return;
33
+ }
34
+ const trimmed = stderr.trim();
35
+ const suffix = trimmed.length > 0 ? `: ${trimmed}` : "";
36
+ reject(new Error(`docker ${args.join(" ")} exited with code ${code}${suffix}`));
37
+ });
38
+ proc.on("error", reject);
39
+ });
40
+ }
41
+ /**
42
+ * Run a docker CLI command and capture stdout. Never throws on non-zero
43
+ * exit; callers inspect the returned `code`.
44
+ */
45
+ export function dockerCapture(args) {
46
+ return new Promise((resolve, reject) => {
47
+ const proc = spawn("docker", [...args], { stdio: ["ignore", "pipe", "ignore"] });
48
+ let stdout = "";
49
+ proc.stdout.on("data", (chunk) => {
50
+ stdout += chunk.toString();
51
+ });
52
+ proc.on("close", (code) => {
53
+ resolve({ code, stdout });
54
+ });
55
+ proc.on("error", reject);
56
+ });
57
+ }
58
+ /** Whether a container with the given name is currently running. */
59
+ export async function isContainerRunning(name) {
60
+ const { code, stdout } = await dockerCapture([
61
+ "container",
62
+ "inspect",
63
+ "-f",
64
+ "{{.State.Running}}",
65
+ name,
66
+ ]);
67
+ return code === 0 && stdout.trim() === "true";
68
+ }
69
+ /** Whether a Docker network with the given name already exists. */
70
+ export async function isNetworkPresent(name) {
71
+ const { code } = await dockerCapture(["network", "inspect", name]);
72
+ return code === 0;
73
+ }
74
+ /**
75
+ * Whether a Docker image with the given reference is present in the
76
+ * local image store. Used by the pull-mode image lifecycle to avoid
77
+ * re-pulling a version-pinned image that's already local.
78
+ *
79
+ * @param ref Image reference (tag or registry-qualified `name:tag`).
80
+ * @returns true if `docker image inspect` succeeds.
81
+ */
82
+ export async function isImagePresent(ref) {
83
+ const { code } = await dockerCapture(["image", "inspect", ref]);
84
+ return code === 0;
85
+ }
86
+ /**
87
+ * Build the `docker network create` argument vector. Pure (no side
88
+ * effects) so it can be unit-tested without a daemon.
89
+ *
90
+ * @param name Network name to create.
91
+ * @param options Network creation flags (e.g. `internal`).
92
+ * @returns The full docker CLI argv, e.g. `["network", "create", "--internal", "familiar-isolated"]`.
93
+ */
94
+ export function buildNetworkCreateArgs(name, options = {}) {
95
+ const args = ["network", "create"];
96
+ if (options.internal) {
97
+ args.push("--internal");
98
+ }
99
+ args.push(name);
100
+ return args;
101
+ }
102
+ /**
103
+ * Create the named bridge network if it doesn't already exist. Idempotent.
104
+ *
105
+ * @param name Network name to ensure.
106
+ * @param options Network creation flags applied only on first creation
107
+ * (an existing network is left as-is).
108
+ */
109
+ export async function ensureNetwork(name, options = {}) {
110
+ if (!(await isNetworkPresent(name))) {
111
+ await dockerExec(buildNetworkCreateArgs(name, options));
112
+ }
113
+ }
114
+ /**
115
+ * Attach an already-running container to an additional network by name.
116
+ * Callers recreate their container on every `start()` (via
117
+ * {@link removeContainer} first), so the attachment is always fresh —
118
+ * this throws on failure rather than swallowing it, so a genuine
119
+ * problem (missing network, missing container) surfaces instead of
120
+ * silently leaving the container unable to reach its peer.
121
+ *
122
+ * @param network Network to connect the container to.
123
+ * @param container Name of the running container to attach.
124
+ * @throws If the docker command fails (network/container missing, etc.).
125
+ */
126
+ export function connectNetwork(network, container) {
127
+ return dockerExec(["network", "connect", network, container]);
128
+ }
129
+ /**
130
+ * Force-remove a container by name. Default `allowFailure: true` because
131
+ * "remove if it exists" is the common idempotent-cleanup intent.
132
+ */
133
+ export function removeContainer(name, options = { allowFailure: true }) {
134
+ return dockerExec(["rm", "-f", name], options);
135
+ }
136
+ /** Send `docker stop` to a container. Ignores already-stopped/missing containers. */
137
+ export function stopContainer(name) {
138
+ return dockerExec(["stop", name], { allowFailure: true });
139
+ }
140
+ /**
141
+ * Run a docker CLI command with the parent process's stdin/stdout/
142
+ * stderr inherited, blocking until the child exits. Used by
143
+ * subcommands that hand the user's terminal to a foreground docker
144
+ * process (`familiar psql`'s psql shell, `familiar mcp call`'s
145
+ * one-shot CLI invocations against an MCP runtime container).
146
+ *
147
+ * Returns the child's exit status — `null`-on-signal collapses to
148
+ * 1 so the caller's `process.exit(...)` always sees a usable
149
+ * number. A failure to launch docker itself (e.g. binary missing)
150
+ * surfaces as a thrown {@link Error} the caller can format.
151
+ *
152
+ * @throws If the docker binary couldn't be spawned at all.
153
+ */
154
+ export function dockerInteractive(args) {
155
+ const result = spawnSync("docker", [...args], { stdio: "inherit" });
156
+ if (result.error) {
157
+ throw new Error(`failed to launch docker: ${result.error.message}`);
158
+ }
159
+ return result.status ?? 1;
160
+ }
161
+ /**
162
+ * On Linux, `host.docker.internal` isn't built-in and must be mapped to
163
+ * the host gateway explicitly. macOS and Windows resolve it natively.
164
+ *
165
+ * Used by sidecars that dial a host-side listener (the bastion bridge,
166
+ * the tailscale plugin's serve proxy to the webserver).
167
+ *
168
+ * @returns Extra `docker run` args, or an empty array on macOS/Windows.
169
+ */
170
+ export function hostGatewayArgs() {
171
+ if (os.platform() === "linux") {
172
+ return ["--add-host=host.docker.internal:host-gateway"];
173
+ }
174
+ return [];
175
+ }
package/build/Event.d.ts CHANGED
@@ -41,9 +41,9 @@ export interface EventRow {
41
41
  */
42
42
  readonly id: string;
43
43
  /**
44
- * Event topic, matching `\w+(:\w+)*` (e.g. `mail`, `chat:whatsapp`,
45
- * `chat:telegram:group:reaction`). Each `:`-separated segment maps
46
- * to a folder when the container resolves the handler markdown.
44
+ * Event topic, matching `TOPIC_PATTERN` (e.g. `mail`, `chat:whatsapp`,
45
+ * `chat:telegram:group:reaction`). The topic is the handler's whole
46
+ * address: `a:b:c` resolves to `a/b/c.md` (or `a/b/c/index.md`).
47
47
  */
48
48
  readonly topic: string;
49
49
  /**
@@ -94,12 +94,12 @@ export interface EventRow {
94
94
  */
95
95
  readonly prompt: string;
96
96
  /**
97
- * Override for the root agentrun's handler. `null` means the
98
- * input-event watcher uses `'index'` (i.e. resolves
99
- * `<topic>/index.md`). Set by the emitter via
100
- * {@link NewEvent.startHandler}; never mutated after insert.
97
+ * `true` when the topic must name an existing handler file exactly;
98
+ * `false` when handler resolution may fall back up the topic chain.
99
+ * Set by the emitter via {@link NewEvent.isExactTopic}; never
100
+ * mutated after insert.
101
101
  */
102
- readonly startHandler: string | null;
102
+ readonly isExactTopic: boolean;
103
103
  /**
104
104
  * `true` when this event was emitted by a trusted user-input source
105
105
  * (the operator at the local terminal via cli-chat, the operator on
@@ -236,24 +236,24 @@ export interface NewEvent {
236
236
  *
237
237
  * Use this for plugin emitters that want a chatmessage text
238
238
  * different from `prompt` (e.g. cli-chat persists the slash-
239
- * prefixed `/topic/handler …` line while the handler sees only
239
+ * prefixed `/topic/sub …` line while the handler sees only
240
240
  * the trailing prompt text). When unset and `isChat: true`, the
241
241
  * existing path inserts `prompt` itself.
242
242
  */
243
243
  readonly userChatMessage?: string;
244
244
  /**
245
- * Override which handler markdown the root agentrun runs. When
246
- * omitted, the input-event watcher uses `'index'` (i.e. resolves
247
- * `<topic>/index.md`). Useful when an emitter wants to skip the
248
- * triage `index.md` and invoke a specific handler directly.
245
+ * When `true`, the topic addresses one handler file exactly
246
+ * (`a:b:c` → `a/b/c.md` or `a/b/c/index.md`) and the run fails if
247
+ * it is missing. Default `false`: world events fall back up the
248
+ * topic chain (`a:b`, then `a`) to the most specific existing
249
+ * handler. Set it for emissions that name a handler rather than
250
+ * report a world change — cron firings, scheduled wake-ups,
251
+ * memory's `skills:memory:save`, cli-chat slash commands.
249
252
  *
250
- * Pass the basename without `.md` (e.g. `'analyze'` resolves to
251
- * `<topic>/analyze.md`). The agentrun fails loud at handler-load
252
- * time if no such file exists — there is no shape validation
253
- * here, mirroring how `topic` is only regex-checked, not
254
- * existence-checked, at emit time.
253
+ * Like `topic` itself, existence is not checked at emit time; the
254
+ * agentrun fails loud at handler-load time.
255
255
  */
256
- readonly startHandler?: string;
256
+ readonly isExactTopic?: boolean;
257
257
  /**
258
258
  * Auxiliary files to stage at `/scratch/<event-id>/<name>` for this
259
259
  * event. Visible to the agent and to every MCP container under the
@@ -116,7 +116,7 @@ export declare class EventBus {
116
116
  */
117
117
  listLatest(limit: number): Promise<EventRow[]>;
118
118
  /**
119
- * Fetch the most recently created N event rows whose topic, start_handler,
119
+ * Fetch the most recently created N event rows whose topic,
120
120
  * prompt, or payload (cast to text) case-insensitively contains `needle`.
121
121
  * Used by `familiar events list <search>` so the operator can grep the
122
122
  * recent bus without piping through `grep`.
@@ -138,7 +138,7 @@ export declare class EventBus {
138
138
  * pair can't express. Both of those stay as the CLI's call sites.
139
139
  *
140
140
  * Substring match (when `search` is set) is the same ILIKE-against-
141
- * `(topic, start_handler, prompt, payload::text)` predicate
141
+ * `(topic, prompt, payload::text)` predicate
142
142
  * {@link searchLatest} uses. `dateFrom` is inclusive, `dateTo` is
143
143
  * exclusive (`created_at >= $from AND created_at < $to`), so the
144
144
  * agent can pass two midnights to get a single local day without
package/build/EventBus.js CHANGED
@@ -139,7 +139,7 @@ export class EventBus {
139
139
  const result = await client.query(`INSERT INTO events
140
140
  (topic, payload, priority, state, idempotency_key,
141
141
  is_chat, preferred_chat_channel_id, prompt, privileged,
142
- start_handler, output_chat_on_failure)
142
+ is_exact_topic, output_chat_on_failure)
143
143
  VALUES ($1, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10, $11)
144
144
  RETURNING *`, [
145
145
  event.topic,
@@ -151,7 +151,7 @@ export class EventBus {
151
151
  event.preferredChatChannelId ?? null,
152
152
  prompt,
153
153
  event.privileged ?? false,
154
- event.startHandler ?? null,
154
+ event.isExactTopic ?? false,
155
155
  event.outputChatOnFailure ?? false,
156
156
  ]);
157
157
  const row = mapRow(result.rows[0]);
@@ -242,7 +242,7 @@ export class EventBus {
242
242
  return result.rows.map(mapRow);
243
243
  }
244
244
  /**
245
- * Fetch the most recently created N event rows whose topic, start_handler,
245
+ * Fetch the most recently created N event rows whose topic,
246
246
  * prompt, or payload (cast to text) case-insensitively contains `needle`.
247
247
  * Used by `familiar events list <search>` so the operator can grep the
248
248
  * recent bus without piping through `grep`.
@@ -259,7 +259,6 @@ export class EventBus {
259
259
  const pattern = `%${needle.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
260
260
  const result = await this.connection.getPool().query(`SELECT * FROM events
261
261
  WHERE topic ILIKE $2
262
- OR coalesce(start_handler, '') ILIKE $2
263
262
  OR coalesce(prompt, '') ILIKE $2
264
263
  OR payload::text ILIKE $2
265
264
  ORDER BY created_at DESC, id DESC
@@ -274,7 +273,7 @@ export class EventBus {
274
273
  * pair can't express. Both of those stay as the CLI's call sites.
275
274
  *
276
275
  * Substring match (when `search` is set) is the same ILIKE-against-
277
- * `(topic, start_handler, prompt, payload::text)` predicate
276
+ * `(topic, prompt, payload::text)` predicate
278
277
  * {@link searchLatest} uses. `dateFrom` is inclusive, `dateTo` is
279
278
  * exclusive (`created_at >= $from AND created_at < $to`), so the
280
279
  * agent can pass two midnights to get a single local day without
@@ -286,8 +285,7 @@ export class EventBus {
286
285
  let n = 1;
287
286
  if (filter.search !== undefined && filter.search.length > 0) {
288
287
  const pattern = `%${filter.search.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
289
- conditions.push(`(topic ILIKE $${n} OR coalesce(start_handler, '') ILIKE $${n} ` +
290
- `OR coalesce(prompt, '') ILIKE $${n} OR payload::text ILIKE $${n})`);
288
+ conditions.push(`(topic ILIKE $${n} OR coalesce(prompt, '') ILIKE $${n} OR payload::text ILIKE $${n})`);
291
289
  values.push(pattern);
292
290
  n++;
293
291
  }
@@ -473,7 +471,7 @@ function mapRow(raw) {
473
471
  isChat: raw.is_chat,
474
472
  preferredChatChannelId: raw.preferred_chat_channel_id,
475
473
  prompt: raw.prompt ?? "",
476
- startHandler: raw.start_handler,
474
+ isExactTopic: raw.is_exact_topic,
477
475
  privileged: raw.privileged,
478
476
  outputChatOnFailure: raw.output_chat_on_failure,
479
477
  createdAt: raw.created_at,
@@ -1,31 +1,35 @@
1
+ import { type HandlerResolution, type HandlerResolutionOptions } from "./HandlerResolution.js";
1
2
  /**
2
3
  * One handler entry surfaced by {@link HandlerCatalog.list}.
3
4
  *
4
- * Encodes the same notion as the container's `HandlerFile`: a topic
5
- * (one or more `:`-separated segments) plus a basename. `slashPath`
6
- * is the user-facing form the cli-chat prompt accepts as a direct
7
- * handler call (e.g. `/grocery/fruits/order`).
5
+ * The topic is the handler's whole address (`chat:telegram` for both
6
+ * `chat/telegram.md` and `chat/telegram/index.md`). `slashPath` is the
7
+ * user-facing form the cli-chat prompt accepts as a direct handler call
8
+ * (e.g. `/grocery/fruits/order`).
8
9
  */
9
10
  export interface HandlerPath {
10
11
  /** Colon-separated topic, e.g. `chat:telegram`. */
11
12
  readonly topic: string;
12
- /** Handler basename without `.md`, e.g. `index`, `analyze`. */
13
- readonly handler: string;
14
13
  /** Absolute path on disk of the handler file. */
15
14
  readonly absolutePath: string;
16
- /** Workspace-relative posix path, e.g. `chat/telegram/index.md`. */
15
+ /** Workspace-relative posix path, e.g. `chat/telegram.md`. */
17
16
  readonly relativePath: string;
18
- /** User-facing slash form, e.g. `/chat/telegram/index`. */
17
+ /** User-facing slash form of the topic, e.g. `/chat/telegram`. */
19
18
  readonly slashPath: string;
20
19
  }
20
+ /** A {@link HandlerResolution} plus the winning file's absolute path. */
21
+ export interface ResolvedHandler extends HandlerResolution {
22
+ /** Absolute path on disk of the winning handler file. */
23
+ readonly absolutePath: string;
24
+ }
21
25
  /**
22
26
  * Read-only view over the `workspace/` directory's handler files.
23
27
  *
24
- * Mirrors the resolution semantics implemented by
25
- * `container/src/HandlerFile.ts` so host-side code (CLI, cron
28
+ * Shares the resolution rules of `container/src/HandlerFile.ts` (both
29
+ * build on `HandlerResolution.ts`) so host-side code (CLI, cron
26
30
  * scheduler introspection, tab-completion) can answer "does this
27
31
  * handler exist?" and "list every handler" without depending on
28
- * container internals or reimplementing the topic-walk inheritance.
32
+ * container internals.
29
33
  *
30
34
  * The catalog is stateless on the disk — every method re-scans —
31
35
  * so live workspace edits are reflected without a daemon restart.
@@ -46,12 +50,14 @@ export declare class HandlerCatalog {
46
50
  constructor(workspaceDir: string, writablePathGlobs?: readonly string[]);
47
51
  /**
48
52
  * Walk `workspace/` and return one {@link HandlerPath} per
49
- * candidate handler file. Excludes every file at the workspace root
50
- * (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they are
51
- * never handlers) and the `skills/` subtree. Every other `.md` file under a topic
52
- * directory is included, even if it's a knowledge file (e.g.
53
- * `people/anna.md`) — `HandlerCatalog` does not police what is or
54
- * isn't a "proper" handler; that's a handler-loading concern.
53
+ * addressable handler file. Excludes every file at the workspace
54
+ * root (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they
55
+ * are never handlers), the `skills/` subtree, writable paths, file
56
+ * names that don't form a valid topic, and a folder-form
57
+ * `x/index.md` shadowed by its `x.md` twin. Every other `.md` file
58
+ * under a topic directory is included, even if it's a knowledge file
59
+ * (e.g. `people/anna.md`) — `HandlerCatalog` does not police what is
60
+ * or isn't a "proper" handler; that's a handler-loading concern.
55
61
  *
56
62
  * Sorted by `slashPath` for stable display ordering in
57
63
  * tab-completion. Returns an empty array when the workspace
@@ -59,17 +65,16 @@ export declare class HandlerCatalog {
59
65
  */
60
66
  list(): Promise<readonly HandlerPath[]>;
61
67
  /**
62
- * Resolve a handler call: given a topic (e.g. `chat:telegram`)
63
- * and a basename (e.g. `index`), return the absolute path of the
64
- * deepest existing handler file, mirroring the container's
65
- * inheritance walk.
68
+ * Resolve a topic to the one handler file that runs for it, with
69
+ * the same rules as the container's `HandlerFile.load`: file form
70
+ * `a/b/c.md` before folder form `a/b/c/index.md`, and — unless
71
+ * `isExactTopic` — falling back up the topic chain.
66
72
  *
67
- * For topic `a:b:c` and basename `x` the resolution tries, in
68
- * order: `a/b/c/x.md`, `a/b/x.md`, `a/x.md`. Returns the first
69
- * existing path or `null` if none exist. The deepest existing
70
- * file is what `HandlerFile.load` would treat as the leaf — for
71
- * cli-chat existence checks, that's exactly the question we
72
- * want answered.
73
+ * @param topic Colon-separated topic, e.g. `chat:telegram`.
74
+ * @param options See {@link HandlerResolutionOptions}.
75
+ * @returns The resolution, or `null` when nothing matches or the
76
+ * winning file lives under a writable path (the container refuses
77
+ * to run it, so it does not resolve).
73
78
  */
74
- resolve(topic: string, handler: string): Promise<string | null>;
79
+ resolve(topic: string, options: HandlerResolutionOptions): Promise<ResolvedHandler | null>;
75
80
  }