@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.
- 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/DockerTools.d.ts +119 -0
- package/build/DockerTools.js +175 -0
- 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 -28
- package/build/HandlerCatalog.js +70 -64
- 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/WebServerDefaults.d.ts +9 -0
- package/build/WebServerDefaults.js +9 -0
- package/build/index.d.ts +6 -3
- package/build/index.js +4 -0
- package/build/testing/MockAgentRunBus.js +1 -1
- package/build/testing/MockEventBus.js +1 -1
- package/package.json +1 -1
package/build/HandlerCatalog.js
CHANGED
|
@@ -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
|
-
*
|
|
17
|
-
* `
|
|
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
|
|
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
|
-
*
|
|
45
|
-
* (`PROMPT.md`, `SOUL.md`, … — root files have no topic, so they
|
|
46
|
-
* never handlers)
|
|
47
|
-
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
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
|
-
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* file
|
|
75
|
-
*
|
|
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,
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
109
|
+
return {
|
|
110
|
+
...resolution,
|
|
111
|
+
absolutePath: path.join(this.workspaceDir, resolution.relativePath),
|
|
112
|
+
};
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
115
|
/**
|
|
98
|
-
* Recursively walk `dir`, accumulating
|
|
99
|
-
* `.md` file. `segments` tracks the
|
|
100
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Synchronous regular-file probe, used as the existence callback of
|
|
146
|
+
* {@link resolveHandlerPath}.
|
|
147
|
+
*/
|
|
148
|
+
function isFileSync(absolute) {
|
|
142
149
|
try {
|
|
143
|
-
|
|
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
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
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
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
621
|
-
*
|
|
622
|
-
*
|
|
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
|
|
638
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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,
|
|
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 /
|
|
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
|
|
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
|
|
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,
|
|
45
|
-
VALUES ($1, $2, $3, $4, $5, $6
|
|
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,
|