@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.
@@ -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/**"];
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
  }
@@ -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;