@getfamiliar/shared 0.1.13 → 0.1.16

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.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Parsing of one line a user typed into a chat (cli-chat, web-chat):
3
+ * a builtin command of the chat client, a direct handler call
4
+ * (`/topic/sub …`) or a plain message.
5
+ */
6
+ /**
7
+ * Result of {@link parseChatInput}.
8
+ *
9
+ * A `handler` arm covers both the plain message (no topic — the chat
10
+ * plugin's own topic applies) and the `/topic/sub/handler …` direct call,
11
+ * whose path is the handler's topic.
12
+ */
13
+ export type ParsedChatInput<Builtin extends string = string> = {
14
+ readonly kind: "handler";
15
+ readonly topic?: string;
16
+ readonly prompt: string;
17
+ /**
18
+ * Trimmed raw input line, before slash-command parsing. For direct
19
+ * calls this is what the chat persists as the `role='user'`
20
+ * chatmessage (`NewEvent.userChatMessage`), so later chat turns see
21
+ * exactly what the user typed. For plain messages it equals `prompt`.
22
+ */
23
+ readonly rawInput: string;
24
+ } | {
25
+ readonly kind: "builtin";
26
+ readonly command: Builtin;
27
+ };
28
+ /**
29
+ * Parse one raw input line.
30
+ *
31
+ * Rules:
32
+ * - A line whose first word is one of `builtins` → builtin.
33
+ * - `/a/b … rest` (any other `/` path) → handler call with topic `a:b`;
34
+ * anything after the first space is the prompt.
35
+ * - Anything else (and a bare `/`) → plain message, the trimmed line is
36
+ * the prompt.
37
+ *
38
+ * @param raw The typed line.
39
+ * @param builtins The chat client's own commands (e.g. `["/exit", "/clear"]`).
40
+ * @returns The parsed input.
41
+ */
42
+ export declare function parseChatInput<Builtin extends string>(raw: string, builtins: readonly Builtin[]): ParsedChatInput<Builtin>;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Parsing of one line a user typed into a chat (cli-chat, web-chat):
3
+ * a builtin command of the chat client, a direct handler call
4
+ * (`/topic/sub …`) or a plain message.
5
+ */
6
+ /**
7
+ * Parse one raw input line.
8
+ *
9
+ * Rules:
10
+ * - A line whose first word is one of `builtins` → builtin.
11
+ * - `/a/b … rest` (any other `/` path) → handler call with topic `a:b`;
12
+ * anything after the first space is the prompt.
13
+ * - Anything else (and a bare `/`) → plain message, the trimmed line is
14
+ * the prompt.
15
+ *
16
+ * @param raw The typed line.
17
+ * @param builtins The chat client's own commands (e.g. `["/exit", "/clear"]`).
18
+ * @returns The parsed input.
19
+ */
20
+ export function parseChatInput(raw, builtins) {
21
+ const line = raw.trim();
22
+ if (!line.startsWith("/")) {
23
+ return { kind: "handler", prompt: line, rawInput: line };
24
+ }
25
+ const spaceIdx = line.indexOf(" ");
26
+ const commandPart = spaceIdx === -1 ? line : line.slice(0, spaceIdx);
27
+ const messagePart = spaceIdx === -1 ? "" : line.slice(spaceIdx + 1).trim();
28
+ if (builtins.includes(commandPart)) {
29
+ return { kind: "builtin", command: commandPart };
30
+ }
31
+ // Empty segments (trailing `/` or `//`) produce no topic words.
32
+ const segments = commandPart
33
+ .slice(1)
34
+ .split("/")
35
+ .filter((segment) => segment.length > 0);
36
+ if (segments.length === 0) {
37
+ return { kind: "handler", prompt: line, rawInput: line };
38
+ }
39
+ return {
40
+ kind: "handler",
41
+ topic: segments.join(":"),
42
+ prompt: messagePart,
43
+ rawInput: line,
44
+ };
45
+ }
@@ -41,6 +41,17 @@ export interface NewChatMessage {
41
41
  readonly role: ChatRole;
42
42
  readonly textContent: string;
43
43
  }
44
+ /** Largest page {@link ChatHistoryQuery.limit} accepts. */
45
+ export declare const MAX_CHAT_HISTORY_PAGE = 200;
46
+ /** Query for one page of a channel's chat history (`ctx.chat.listMessages`). */
47
+ export interface ChatHistoryQuery {
48
+ /** Channel id (matched against the parent event's `preferred_chat_channel_id`). */
49
+ readonly channelId: string;
50
+ /** Only messages with a smaller id (cursor for paging backwards); omit for the newest page. */
51
+ readonly beforeId?: string;
52
+ /** Page size, 1 … {@link MAX_CHAT_HISTORY_PAGE}. */
53
+ readonly limit: number;
54
+ }
44
55
  /** Filter for {@link import("./ChatMessageBus").ChatMessageBus.subscribe}. */
45
56
  export interface ChatFilter {
46
57
  /**
@@ -8,4 +8,5 @@
8
8
  * keeps the container side channel-blind: the agent's `send_chat` tool
9
9
  * only needs to know the originating event id, never the channel.
10
10
  */
11
- export {};
11
+ /** Largest page {@link ChatHistoryQuery.limit} accepts. */
12
+ export const MAX_CHAT_HISTORY_PAGE = 200;
@@ -1,4 +1,4 @@
1
- import type { ChatFilter, ChatMessage, NewChatMessage } from "./ChatMessage.js";
1
+ import type { ChatFilter, ChatHistoryQuery, ChatMessage, NewChatMessage } from "./ChatMessage.js";
2
2
  import type { Logger } from "./logging/Logger.js";
3
3
  import type { PostgresConnection } from "./PostgresConnection.js";
4
4
  /**
@@ -68,6 +68,16 @@ export declare class ChatMessageBus {
68
68
  * thread to attach to).
69
69
  */
70
70
  fetchHistoryForEvent(eventId: string): Promise<ChatMessage[]>;
71
+ /**
72
+ * One page of a channel's chat history: the newest `limit` messages
73
+ * on `channelId` (joined via `events.preferred_chat_channel_id`)
74
+ * older than `beforeId`, returned oldest first. Page backwards by
75
+ * passing the first returned id as the next `beforeId`.
76
+ *
77
+ * @param query Channel, optional cursor and page size.
78
+ * @returns Up to `limit` messages in chronological order.
79
+ */
80
+ listByChannel(query: ChatHistoryQuery): Promise<ChatMessage[]>;
71
81
  /**
72
82
  * Subscribe to chat messages matching `filter`. Returns an
73
83
  * unsubscribe function.
@@ -85,6 +85,32 @@ export class ChatMessageBus {
85
85
  ORDER BY cm.created_at ASC, cm.id ASC`, [eventId]);
86
86
  return result.rows.map(mapRow);
87
87
  }
88
+ /**
89
+ * One page of a channel's chat history: the newest `limit` messages
90
+ * on `channelId` (joined via `events.preferred_chat_channel_id`)
91
+ * older than `beforeId`, returned oldest first. Page backwards by
92
+ * passing the first returned id as the next `beforeId`.
93
+ *
94
+ * @param query Channel, optional cursor and page size.
95
+ * @returns Up to `limit` messages in chronological order.
96
+ */
97
+ async listByChannel(query) {
98
+ const values = [query.channelId, query.limit];
99
+ const cursor = query.beforeId === undefined ? "" : "AND cm.id < $3";
100
+ if (query.beforeId !== undefined) {
101
+ values.push(query.beforeId);
102
+ }
103
+ const result = await this.connection.getPool().query(`SELECT * FROM (
104
+ SELECT cm.*
105
+ FROM chatmessages cm
106
+ JOIN events e ON e.id = cm.event_id
107
+ WHERE e.preferred_chat_channel_id = $1 ${cursor}
108
+ ORDER BY cm.id DESC
109
+ LIMIT $2
110
+ ) page
111
+ ORDER BY page.id ASC`, values);
112
+ return result.rows.map(mapRow);
113
+ }
88
114
  /**
89
115
  * Subscribe to chat messages matching `filter`. Returns an
90
116
  * unsubscribe function.
@@ -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/Plugin.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import type { CommandDef } from "citty";
3
3
  import type { AgentRunRow } from "./AgentRun.js";
4
4
  import type { CalendarApi } from "./Calendar.js";
5
- import type { ChatFilter } from "./ChatMessage.js";
5
+ import type { ChatFilter, ChatHistoryQuery, ChatMessage } from "./ChatMessage.js";
6
6
  import type { ChatHandler, ChatUnsubscribe } from "./ChatMessageBus.js";
7
7
  import type { ConfigService } from "./Config.js";
8
8
  import type { EventFile, EventRow, NewEvent } from "./Event.js";
@@ -10,10 +10,14 @@ import type { Logger } from "./logging/Logger.js";
10
10
  import type { MailApi } from "./Mail.js";
11
11
  import type { MailStyleTemplate } from "./MailStyleTemplate.js";
12
12
  import type { ModelMetaData, ModelProviderDescriptor } from "./ModelMetaData.js";
13
+ import type { ScratchFile } from "./Scratch.js";
13
14
  import type { StepResultRow } from "./StepResult.js";
14
15
  import type { StorageApi } from "./Storage.js";
15
16
  import type { ToolLevel } from "./ToolLevel.js";
16
17
  import type { ToolRunContext } from "./ToolRunner.js";
18
+ import type { WebApi, WebUi } from "./WebAuth.js";
19
+ import type { WebMenuContribution } from "./WebMenu.js";
20
+ import type { WebPluginUi } from "./WebPlugin.js";
17
21
  import type { WorkspaceWatcherApi } from "./WorkspaceFile.js";
18
22
  export type { ChatHandler, Client as McpClient };
19
23
  /**
@@ -234,6 +238,25 @@ export interface HostContext {
234
238
  * can deadlock the input-event watcher's claim.
235
239
  */
236
240
  appendAssistantMessage(eventId: string, text: string): Promise<void>;
241
+ /**
242
+ * Insert a chatmessage of either role attached to `eventId`, for
243
+ * recording a conversation that happened outside the agent — e.g.
244
+ * the transcript of a voice call. Channel is resolved via the
245
+ * event's `preferred_chat_channel_id`. The same rule as for
246
+ * {@link appendAssistantMessage} applies: only for events that
247
+ * are already settled — typically a record-only event emitted
248
+ * with `state: "done"`, which no handler ever runs for.
249
+ */
250
+ appendMessage(eventId: string, role: "user" | "assistant", text: string): Promise<void>;
251
+ /**
252
+ * Read one page of a channel's history (both roles), oldest first:
253
+ * the newest `limit` messages before `beforeId`. For chat UIs that
254
+ * show scrollback; the same per-channel history the model sees for
255
+ * that channel's chat events.
256
+ *
257
+ * @throws {Error} When `limit` is outside 1 … `MAX_CHAT_HISTORY_PAGE`.
258
+ */
259
+ listMessages(query: ChatHistoryQuery): Promise<readonly ChatMessage[]>;
237
260
  };
238
261
  /**
239
262
  * Per-event scratch directory operations. Each event the bus
@@ -262,6 +285,23 @@ export interface HostContext {
262
285
  * caller is responsible for unique names.
263
286
  */
264
287
  addFiles(eventId: string, files: readonly EventFile[]): Promise<readonly string[]>;
288
+ /**
289
+ * The regular files directly in `/scratch/<eventId>/` (no
290
+ * subdirectories), hidden ones included — filter them with
291
+ * `isHiddenScratchFile` before showing files to users. Sorted
292
+ * by name; empty when the folder does not exist.
293
+ *
294
+ * @throws {Error} When `eventId` is not a numeric event id.
295
+ */
296
+ listFiles(eventId: string): Promise<readonly ScratchFile[]>;
297
+ /**
298
+ * Host path of `/scratch/<eventId>/<name>`, for reading or
299
+ * streaming the file. The file may not exist.
300
+ *
301
+ * @throws {Error} When `eventId` is not a numeric event id or
302
+ * `name` is not a plain basename (same rules as `addFiles`).
303
+ */
304
+ resolveFile(eventId: string, name: string): string;
265
305
  };
266
306
  /**
267
307
  * The pino-style {@link Logger} for the plugin. Use `.info` for the
@@ -271,6 +311,14 @@ export interface HostContext {
271
311
  * plugin doing anything.
272
312
  */
273
313
  readonly logger: Logger;
314
+ /**
315
+ * Whether the daemon runs in dev mode (`FAMILIAR_DEV=1`, set by the
316
+ * monorepo's `npm run dev` launcher). Lets plugins enable developer
317
+ * conveniences such as the web UI's hot module reloading.
318
+ */
319
+ readonly isDevMode: boolean;
320
+ /** Version of the running familiar host (the `@getfamiliar/host` package version, e.g. `0.4.2`). */
321
+ readonly familiarVersion: string;
274
322
  /**
275
323
  * Absolute host path of the project's `data/` directory.
276
324
  *
@@ -399,6 +447,14 @@ export interface HostContext {
399
447
  * which maps the user's configured mounts onto providers.
400
448
  */
401
449
  readonly storage: StorageApi;
450
+ /**
451
+ * Core webserver hooks. A plugin that fronts the webserver with its
452
+ * own reverse proxy registers it during **`prepare()`** via
453
+ * `ctx.web.registerTrustedProxy({ secret, userHeader })`, the same
454
+ * contract a user-configured `webserver.auth.trustedProxies` entry
455
+ * follows (see {@link WebApi}).
456
+ */
457
+ readonly web: WebApi;
402
458
  /**
403
459
  * Read the per-mailbox style template the user (or the
404
460
  * extract-style handler) wrote at `data/mail/templates/<mailbox>/<name>.json`.
@@ -694,6 +750,52 @@ export interface PluginHostManifest {
694
750
  * call.
695
751
  */
696
752
  tools?(ctx: HostContext): readonly PluginTool[];
753
+ /**
754
+ * Provide the web UI: return its root controller, a Hono app mounted
755
+ * at `/` of the core webserver, and optionally a renderer for the
756
+ * login-flow pages ({@link WebUi.renderAuthPage}). Called after `start()` resolves (at
757
+ * boot and after every restart), so closures over `start`-time state
758
+ * (e.g. a dev server) are safe; a restart swaps the controller
759
+ * without rebinding the socket.
760
+ *
761
+ * Authentication stays in the core: every request reaching the
762
+ * controller passed the auth middleware, `c.get("principal")` is the
763
+ * signed-in user. The core routes `/login`, `/login/:token` and
764
+ * `/logout` take precedence over the controller's routes; the core
765
+ * decides what they do and only lets the UI render their markup.
766
+ *
767
+ * When several plugins provide a UI, `webserver.uiPlugin` picks one;
768
+ * without any, the webserver serves a plain page asking to install
769
+ * one (e.g. `@getfamiliar/plugin-web-ui`).
770
+ *
771
+ * Synchronous, side-effect-free: just build and return the app.
772
+ */
773
+ webUi?(ctx: HostContext): WebUi;
774
+ /**
775
+ * Contribute items to the web UI's sidebar menu: direct links or
776
+ * collapsible groups with sub-entries (icon, label, routes), plus a
777
+ * weight positioning this plugin's items among the others. Called
778
+ * after `start()` (at boot and after every restart); a disabled
779
+ * plugin contributes nothing. The core validates the items (lucide
780
+ * icon id, non-empty labels, routes starting with `/`) and disables
781
+ * the plugin in phase `webMenu` when they are malformed.
782
+ *
783
+ * Synchronous, side-effect-free: derive the items from constants and
784
+ * `ctx.config`.
785
+ */
786
+ webMenu?(ctx: HostContext): WebMenuContribution;
787
+ /**
788
+ * Serve HTTP routes and, optionally, a page in the web UI: return a
789
+ * controller (mounted at `/plugins/<plugin-id>/` behind the core
790
+ * authentication, concrete routes only, no catch-all) and an optional
791
+ * page module the web UI loads for browser routes under
792
+ * `/plugins/<plugin-id>` (see {@link WebPluginUi}). Called after
793
+ * `start()` (at boot and after every restart); a throw disables the
794
+ * plugin in phase `webPlugin`.
795
+ *
796
+ * Synchronous, side-effect-free: just build and return.
797
+ */
798
+ webPlugin?(ctx: HostContext): WebPluginUi;
697
799
  /**
698
800
  * Supply {@link ModelMetaData} for a model the default (models.dev)
699
801
  * database does not cover. The host calls this on every plugin that