@getfamiliar/shared 0.1.11 → 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.
- package/build/AgentRun.d.ts +16 -11
- package/build/AgentRunBus.js +3 -3
- package/build/Config.d.ts +5 -0
- package/build/Config.js +26 -1
- package/build/Event.d.ts +19 -19
- package/build/EventBus.d.ts +2 -2
- package/build/EventBus.js +6 -8
- package/build/HandlerCatalog.d.ts +33 -38
- package/build/HandlerCatalog.js +78 -87
- package/build/HandlerResolution.d.ts +84 -0
- package/build/HandlerResolution.js +105 -0
- package/build/Plugin.d.ts +70 -12
- package/build/PromptPlaceholders.js +3 -8
- package/build/ScheduledSubagent.d.ts +2 -3
- package/build/ScheduledSubagentBus.d.ts +1 -1
- package/build/ScheduledSubagentBus.js +3 -6
- package/build/Schema.d.ts +11 -9
- package/build/Schema.js +28 -14
- package/build/index.d.ts +4 -3
- package/build/index.js +3 -1
- package/build/testing/MockAgentRunBus.js +1 -1
- package/build/testing/MockEventBus.js +1 -1
- package/package.json +1 -1
package/build/AgentRun.d.ts
CHANGED
|
@@ -40,18 +40,19 @@ export interface AgentRunRow {
|
|
|
40
40
|
*/
|
|
41
41
|
readonly parentAgentrunId: string | null;
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
-
*
|
|
49
|
-
* `
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
151
|
+
/** Handler address — the event's topic for roots, the named topic for children. Required. */
|
|
151
152
|
readonly topic: string;
|
|
152
|
-
/**
|
|
153
|
-
|
|
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"`. */
|
package/build/AgentRunBus.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
45
|
-
* `chat:telegram:group:reaction`).
|
|
46
|
-
*
|
|
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
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
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/
|
|
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
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
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
|
-
*
|
|
251
|
-
*
|
|
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
|
|
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
|
package/build/EventBus.d.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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,41 +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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
15
|
+
/** Workspace-relative posix path, e.g. `chat/telegram.md`. */
|
|
17
16
|
readonly relativePath: string;
|
|
18
|
-
/** User-facing slash form, e.g. `/chat/telegram
|
|
17
|
+
/** User-facing slash form of the topic, e.g. `/chat/telegram`. */
|
|
19
18
|
readonly slashPath: string;
|
|
20
19
|
}
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
* @param relativePath Workspace-relative posix path, e.g. `skills/jira-issue/SKILL.md`.
|
|
28
|
-
* @returns `true` when the first path segment is `skills`.
|
|
29
|
-
*/
|
|
30
|
-
export declare function isUnderSkillsFolder(relativePath: string): boolean;
|
|
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
|
+
}
|
|
31
25
|
/**
|
|
32
26
|
* Read-only view over the `workspace/` directory's handler files.
|
|
33
27
|
*
|
|
34
|
-
*
|
|
35
|
-
* `
|
|
28
|
+
* Shares the resolution rules of `container/src/HandlerFile.ts` (both
|
|
29
|
+
* build on `HandlerResolution.ts`) so host-side code (CLI, cron
|
|
36
30
|
* scheduler introspection, tab-completion) can answer "does this
|
|
37
31
|
* handler exist?" and "list every handler" without depending on
|
|
38
|
-
* container internals
|
|
32
|
+
* container internals.
|
|
39
33
|
*
|
|
40
34
|
* The catalog is stateless on the disk — every method re-scans —
|
|
41
35
|
* so live workspace edits are reflected without a daemon restart.
|
|
@@ -56,12 +50,14 @@ export declare class HandlerCatalog {
|
|
|
56
50
|
constructor(workspaceDir: string, writablePathGlobs?: readonly string[]);
|
|
57
51
|
/**
|
|
58
52
|
* Walk `workspace/` and return one {@link HandlerPath} per
|
|
59
|
-
*
|
|
60
|
-
* (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they
|
|
61
|
-
* never handlers)
|
|
62
|
-
*
|
|
63
|
-
* `
|
|
64
|
-
*
|
|
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.
|
|
65
61
|
*
|
|
66
62
|
* Sorted by `slashPath` for stable display ordering in
|
|
67
63
|
* tab-completion. Returns an empty array when the workspace
|
|
@@ -69,17 +65,16 @@ export declare class HandlerCatalog {
|
|
|
69
65
|
*/
|
|
70
66
|
list(): Promise<readonly HandlerPath[]>;
|
|
71
67
|
/**
|
|
72
|
-
* Resolve a handler
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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.
|
|
76
72
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* file
|
|
81
|
-
*
|
|
82
|
-
* 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).
|
|
83
78
|
*/
|
|
84
|
-
resolve(topic: string,
|
|
79
|
+
resolve(topic: string, options: HandlerResolutionOptions): Promise<ResolvedHandler | null>;
|
|
85
80
|
}
|
package/build/HandlerCatalog.js
CHANGED
|
@@ -1,33 +1,24 @@
|
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Workspace subtrees excluded from {@link HandlerCatalog.list} (cli-chat
|
|
7
|
+
* tab-completion), so `SKILL.md` recipes don't clutter the proposals.
|
|
8
|
+
* This is a listing concern only: handlers that live next to a skill
|
|
9
|
+
* (e.g. `skills/memory/save.md`, or a `skills/memory/dream.md` with a
|
|
10
|
+
* `cron:` field) are deliberately loadable, resolvable and
|
|
11
|
+
* cron-scheduled like any other handler.
|
|
9
12
|
*/
|
|
10
|
-
const
|
|
11
|
-
/**
|
|
12
|
-
* Whether a workspace-relative path lies inside the top-level `skills/`
|
|
13
|
-
* folder. Shared by host (catalog, cron scheduler, `cron list`) and
|
|
14
|
-
* container (`HandlerFile.load`) so every side agrees that skills are
|
|
15
|
-
* never handlers.
|
|
16
|
-
*
|
|
17
|
-
* @param relativePath Workspace-relative posix path, e.g. `skills/jira-issue/SKILL.md`.
|
|
18
|
-
* @returns `true` when the first path segment is `skills`.
|
|
19
|
-
*/
|
|
20
|
-
export function isUnderSkillsFolder(relativePath) {
|
|
21
|
-
return relativePath.split("/")[0] === SKILLS_FOLDER;
|
|
22
|
-
}
|
|
13
|
+
const RESERVED_TOP_DIRS = new Set(["skills"]);
|
|
23
14
|
/**
|
|
24
15
|
* Read-only view over the `workspace/` directory's handler files.
|
|
25
16
|
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
17
|
+
* Shares the resolution rules of `container/src/HandlerFile.ts` (both
|
|
18
|
+
* build on `HandlerResolution.ts`) so host-side code (CLI, cron
|
|
28
19
|
* scheduler introspection, tab-completion) can answer "does this
|
|
29
20
|
* handler exist?" and "list every handler" without depending on
|
|
30
|
-
* container internals
|
|
21
|
+
* container internals.
|
|
31
22
|
*
|
|
32
23
|
* The catalog is stateless on the disk — every method re-scans —
|
|
33
24
|
* so live workspace edits are reflected without a daemon restart.
|
|
@@ -51,12 +42,14 @@ export class HandlerCatalog {
|
|
|
51
42
|
}
|
|
52
43
|
/**
|
|
53
44
|
* Walk `workspace/` and return one {@link HandlerPath} per
|
|
54
|
-
*
|
|
55
|
-
* (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they
|
|
56
|
-
* never handlers)
|
|
57
|
-
*
|
|
58
|
-
* `
|
|
59
|
-
*
|
|
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.
|
|
60
53
|
*
|
|
61
54
|
* Sorted by `slashPath` for stable display ordering in
|
|
62
55
|
* tab-completion. Returns an empty array when the workspace
|
|
@@ -67,55 +60,66 @@ export class HandlerCatalog {
|
|
|
67
60
|
if (!exists) {
|
|
68
61
|
return [];
|
|
69
62
|
}
|
|
63
|
+
const relativePaths = [];
|
|
64
|
+
await walkMarkdown(this.workspaceDir, [], relativePaths);
|
|
65
|
+
const present = new Set(relativePaths);
|
|
70
66
|
const results = [];
|
|
71
|
-
|
|
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
|
+
}
|
|
72
88
|
results.sort((a, b) => a.slashPath.localeCompare(b.slashPath));
|
|
73
89
|
return results;
|
|
74
90
|
}
|
|
75
91
|
/**
|
|
76
|
-
* Resolve a handler
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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.
|
|
80
96
|
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* file
|
|
85
|
-
*
|
|
86
|
-
* 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).
|
|
87
102
|
*/
|
|
88
|
-
async resolve(topic,
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const absolute = path.join(this.workspaceDir, rel);
|
|
94
|
-
if (await fileExists(absolute)) {
|
|
95
|
-
// Skills are shared recipes, never handlers — mirror the
|
|
96
|
-
// container's `HandlerFile.load` guard.
|
|
97
|
-
if (isUnderSkillsFolder(relPosix)) {
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
// A file under `core.writablePaths` is never a handler, even
|
|
101
|
-
// when it exists — mirror the container's `HandlerFile.load`
|
|
102
|
-
// guard so host-side callers agree it does not resolve.
|
|
103
|
-
if (matchesAnyGlob(this.writablePathGlobs, relPosix)) {
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
return absolute;
|
|
107
|
-
}
|
|
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;
|
|
108
108
|
}
|
|
109
|
-
return
|
|
109
|
+
return {
|
|
110
|
+
...resolution,
|
|
111
|
+
absolutePath: path.join(this.workspaceDir, resolution.relativePath),
|
|
112
|
+
};
|
|
110
113
|
}
|
|
111
114
|
}
|
|
112
115
|
/**
|
|
113
|
-
* Recursively walk `dir`, accumulating
|
|
114
|
-
* `.md` file. `segments` tracks the
|
|
115
|
-
*
|
|
116
|
-
* 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.
|
|
117
121
|
*/
|
|
118
|
-
async function walkMarkdown(dir, segments, out
|
|
122
|
+
async function walkMarkdown(dir, segments, out) {
|
|
119
123
|
let entries;
|
|
120
124
|
try {
|
|
121
125
|
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
@@ -125,38 +129,25 @@ async function walkMarkdown(dir, segments, out, workspaceDir, writablePathGlobs)
|
|
|
125
129
|
}
|
|
126
130
|
for (const entry of entries) {
|
|
127
131
|
if (entry.isDirectory()) {
|
|
128
|
-
if (segments.length === 0 && entry.name
|
|
132
|
+
if (segments.length === 0 && RESERVED_TOP_DIRS.has(entry.name)) {
|
|
129
133
|
continue;
|
|
130
134
|
}
|
|
131
|
-
await walkMarkdown(path.join(dir, entry.name), [...segments, entry.name], out
|
|
135
|
+
await walkMarkdown(path.join(dir, entry.name), [...segments, entry.name], out);
|
|
132
136
|
continue;
|
|
133
137
|
}
|
|
134
|
-
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
138
|
+
if (!entry.isFile() || !entry.name.endsWith(".md") || segments.length === 0) {
|
|
135
139
|
continue;
|
|
136
140
|
}
|
|
137
|
-
|
|
138
|
-
// `.md` at the workspace root (PROMPT.md, SOUL.md, …) has no
|
|
139
|
-
// topic folder — never a handler. Skip silently rather than
|
|
140
|
-
// reporting an empty topic.
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
const handlerBase = entry.name.slice(0, -".md".length);
|
|
144
|
-
const topic = segments.join(":");
|
|
145
|
-
const absolutePath = path.join(dir, entry.name);
|
|
146
|
-
const relativePath = path.relative(workspaceDir, absolutePath).split(path.sep).join("/");
|
|
147
|
-
// Files under `core.writablePaths` (e.g. `wiki/**`) are writable by
|
|
148
|
-
// non-privileged runs and are never handlers — never propose them.
|
|
149
|
-
if (matchesAnyGlob(writablePathGlobs, relativePath)) {
|
|
150
|
-
continue;
|
|
151
|
-
}
|
|
152
|
-
const slashPath = `/${[...segments, handlerBase].join("/")}`;
|
|
153
|
-
out.push({ topic, handler: handlerBase, absolutePath, relativePath, slashPath });
|
|
141
|
+
out.push([...segments, entry.name].join("/"));
|
|
154
142
|
}
|
|
155
143
|
}
|
|
156
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Synchronous regular-file probe, used as the existence callback of
|
|
146
|
+
* {@link resolveHandlerPath}.
|
|
147
|
+
*/
|
|
148
|
+
function isFileSync(absolute) {
|
|
157
149
|
try {
|
|
158
|
-
|
|
159
|
-
return stat.isFile();
|
|
150
|
+
return statSync(absolute).isFile();
|
|
160
151
|
}
|
|
161
152
|
catch {
|
|
162
153
|
return false;
|