@getfamiliar/shared 0.1.15 → 0.1.17

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.
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
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Maximum nesting of template sources (includes and template-valued
3
+ * placeholders like `HANDLER_CONTENT`) below the top-level template.
4
+ */
5
+ export declare const MAX_TEMPLATE_DEPTH = 3;
6
+ /** One node of a parsed template. */
7
+ export type TemplateNode = {
8
+ readonly type: "text";
9
+ readonly text: string;
10
+ } | {
11
+ readonly type: "placeholder";
12
+ readonly name: string;
13
+ } | {
14
+ readonly type: "include";
15
+ readonly path: string;
16
+ readonly children: readonly TemplateNode[];
17
+ } | {
18
+ readonly type: "source";
19
+ readonly name: string;
20
+ readonly children: readonly TemplateNode[];
21
+ };
22
+ /** A placeholder reference found while parsing, with its position relative to the marker. */
23
+ export interface PlaceholderUse {
24
+ readonly name: string;
25
+ /** `true` when the reference ends up in the system prompt (before `{CACHE_MARKER}`). */
26
+ readonly isBeforeMarker: boolean;
27
+ }
28
+ /** Result of {@link parseTemplate}. */
29
+ export interface ParsedTemplate {
30
+ /** Nodes rendered into the system prompt. */
31
+ readonly systemNodes: readonly TemplateNode[];
32
+ /** Nodes rendered into the head of the current user message. */
33
+ readonly userHeadNodes: readonly TemplateNode[];
34
+ /** Every placeholder reference, in template order (duplicates kept). */
35
+ readonly uses: readonly PlaceholderUse[];
36
+ /** Workspace files pulled in via `{path.md}`, in template order. */
37
+ readonly includes: readonly string[];
38
+ /** Problems found while parsing, phrased for a log line. */
39
+ readonly warnings: readonly string[];
40
+ }
41
+ /** Inputs to {@link parseTemplate}. */
42
+ export interface ParseTemplateOptions {
43
+ /** Human-readable name of the template, used in warnings (e.g. `PROMPT.md`). */
44
+ readonly templateName: string;
45
+ /**
46
+ * Read a workspace-relative include. Returns the (already truncated)
47
+ * text, or `null` when the file does not exist.
48
+ */
49
+ readonly readInclude: (relativePath: string) => string | null;
50
+ /**
51
+ * Placeholders whose value is itself template text (workspace
52
+ * markdown, e.g. `HANDLER_CONTENT`). They are expanded like includes
53
+ * but not redacted.
54
+ */
55
+ readonly templateSources: Readonly<Record<string, string>>;
56
+ }
57
+ /** Rendered output of {@link renderTemplate}. */
58
+ export interface RenderedTemplate {
59
+ readonly system: string;
60
+ readonly userHead: string;
61
+ }
62
+ /**
63
+ * Parse a prompt template into nodes, expanding workspace includes and
64
+ * template-valued placeholders recursively.
65
+ *
66
+ * Only workspace markdown is template: the top-level template, files it
67
+ * includes, and {@link ParseTemplateOptions.templateSources}. Values
68
+ * produced by code or plugins are inserted later by
69
+ * {@link renderTemplate} and never scanned for tokens, so text from an
70
+ * event (a mail containing `{SOUL.md}`) cannot pull files into the
71
+ * prompt.
72
+ *
73
+ * The first `{CACHE_MARKER}` in the top-level template splits system
74
+ * prompt from user-message head. A marker anywhere else (an include, the
75
+ * handler body, a second one) is dropped with a warning.
76
+ *
77
+ * Problems never throw: an invalid include path, a missing include, a
78
+ * cycle or excessive nesting leave a warning and keep the token
79
+ * verbatim (or empty, for a missing file) so the run still proceeds.
80
+ *
81
+ * @param source The top-level template text.
82
+ * @param options Include reader, template sources and naming.
83
+ * @returns The parsed template.
84
+ */
85
+ export declare function parseTemplate(source: string, options: ParseTemplateOptions): ParsedTemplate;
86
+ /**
87
+ * Render a parsed template with resolved placeholder values. A
88
+ * placeholder without a value stays as its literal `{NAME}` token.
89
+ *
90
+ * @param parsed The parsed template.
91
+ * @param values Resolved value per placeholder name.
92
+ * @param shouldRedactIncludes When `true`, each `{path.md}` include is
93
+ * rendered as `<content of file path>` instead of its body — used for
94
+ * the audit-log copy under `core.logSystemPrompt: non-static`.
95
+ * @returns System prompt and user-message head, each trimmed.
96
+ */
97
+ export declare function renderTemplate(parsed: ParsedTemplate, values: ReadonlyMap<string, string>, shouldRedactIncludes?: boolean): RenderedTemplate;
98
+ /**
99
+ * Whether an include path stays inside the workspace: relative, no
100
+ * `..` segment, no empty segment.
101
+ *
102
+ * @param includePath The path inside the braces.
103
+ * @returns `true` when the path is acceptable.
104
+ */
105
+ export declare function isSafeIncludePath(includePath: string): boolean;
@@ -0,0 +1,194 @@
1
+ import { CACHE_MARKER_PLACEHOLDER } from "./PromptPlaceholders.js";
2
+ /**
3
+ * Matches one template token: `{NAME}` (upper-case placeholder) or
4
+ * `{path.md}` (workspace include). Anything else in braces — JSON
5
+ * examples, `{lowercase}` prose — is left untouched.
6
+ */
7
+ const TOKEN_PATTERN = /\{([A-Z][A-Z0-9_]*|[\w./-]+\.md)\}/g;
8
+ /**
9
+ * Maximum nesting of template sources (includes and template-valued
10
+ * placeholders like `HANDLER_CONTENT`) below the top-level template.
11
+ */
12
+ export const MAX_TEMPLATE_DEPTH = 3;
13
+ /**
14
+ * Parse a prompt template into nodes, expanding workspace includes and
15
+ * template-valued placeholders recursively.
16
+ *
17
+ * Only workspace markdown is template: the top-level template, files it
18
+ * includes, and {@link ParseTemplateOptions.templateSources}. Values
19
+ * produced by code or plugins are inserted later by
20
+ * {@link renderTemplate} and never scanned for tokens, so text from an
21
+ * event (a mail containing `{SOUL.md}`) cannot pull files into the
22
+ * prompt.
23
+ *
24
+ * The first `{CACHE_MARKER}` in the top-level template splits system
25
+ * prompt from user-message head. A marker anywhere else (an include, the
26
+ * handler body, a second one) is dropped with a warning.
27
+ *
28
+ * Problems never throw: an invalid include path, a missing include, a
29
+ * cycle or excessive nesting leave a warning and keep the token
30
+ * verbatim (or empty, for a missing file) so the run still proceeds.
31
+ *
32
+ * @param source The top-level template text.
33
+ * @param options Include reader, template sources and naming.
34
+ * @returns The parsed template.
35
+ */
36
+ export function parseTemplate(source, options) {
37
+ const state = {
38
+ options,
39
+ uses: [],
40
+ includes: [],
41
+ warnings: [],
42
+ isMarkerSeen: false,
43
+ systemNodes: [],
44
+ userHeadNodes: [],
45
+ };
46
+ parseInto(source, 0, [options.templateName], state, true);
47
+ return {
48
+ systemNodes: state.systemNodes,
49
+ userHeadNodes: state.userHeadNodes,
50
+ uses: state.uses,
51
+ includes: state.includes,
52
+ warnings: state.warnings,
53
+ };
54
+ }
55
+ /**
56
+ * Render a parsed template with resolved placeholder values. A
57
+ * placeholder without a value stays as its literal `{NAME}` token.
58
+ *
59
+ * @param parsed The parsed template.
60
+ * @param values Resolved value per placeholder name.
61
+ * @param shouldRedactIncludes When `true`, each `{path.md}` include is
62
+ * rendered as `<content of file path>` instead of its body — used for
63
+ * the audit-log copy under `core.logSystemPrompt: non-static`.
64
+ * @returns System prompt and user-message head, each trimmed.
65
+ */
66
+ export function renderTemplate(parsed, values, shouldRedactIncludes = false) {
67
+ return {
68
+ system: renderNodes(parsed.systemNodes, values, shouldRedactIncludes).trim(),
69
+ userHead: renderNodes(parsed.userHeadNodes, values, shouldRedactIncludes).trim(),
70
+ };
71
+ }
72
+ /**
73
+ * Parse `source` and append its nodes to the active top-level list (or
74
+ * to `into`, for nested sources).
75
+ *
76
+ * @param source Text to parse.
77
+ * @param depth Nesting depth; 0 is the top-level template.
78
+ * @param chain Names of the sources currently being expanded, for cycle
79
+ * detection and warning context.
80
+ * @param state Shared parse state.
81
+ * @param isTopLevel Whether nodes go to the top-level lists (`true`) or
82
+ * are returned for a nested node.
83
+ * @returns The nodes parsed from a nested source (empty at top level).
84
+ */
85
+ function parseInto(source, depth, chain, state, isTopLevel) {
86
+ const nested = [];
87
+ const push = (node) => {
88
+ if (!isTopLevel) {
89
+ nested.push(node);
90
+ return;
91
+ }
92
+ (state.isMarkerSeen ? state.userHeadNodes : state.systemNodes).push(node);
93
+ };
94
+ const where = chain[chain.length - 1];
95
+ let cursor = 0;
96
+ for (const match of source.matchAll(TOKEN_PATTERN)) {
97
+ const index = match.index ?? 0;
98
+ if (index > cursor) {
99
+ push({ type: "text", text: source.slice(cursor, index) });
100
+ }
101
+ cursor = index + match[0].length;
102
+ const token = match[1] ?? "";
103
+ if (token === CACHE_MARKER_PLACEHOLDER) {
104
+ if (depth === 0 && !state.isMarkerSeen) {
105
+ state.isMarkerSeen = true;
106
+ continue;
107
+ }
108
+ state.warnings.push(depth === 0
109
+ ? `${where}: second {${CACHE_MARKER_PLACEHOLDER}} ignored — only the first one splits the prompt`
110
+ : `${where}: {${CACHE_MARKER_PLACEHOLDER}} ignored — it only works in the top-level template`);
111
+ continue;
112
+ }
113
+ if (token.endsWith(".md")) {
114
+ push(parseInclude(token, match[0], depth, chain, state));
115
+ continue;
116
+ }
117
+ const templateSource = state.options.templateSources[token];
118
+ if (templateSource !== undefined) {
119
+ if (depth >= MAX_TEMPLATE_DEPTH || chain.includes(`{${token}}`)) {
120
+ state.warnings.push(`${where}: {${token}} not expanded — nested too deep or recursive (${chain.join(" → ")})`);
121
+ push({ type: "text", text: match[0] });
122
+ continue;
123
+ }
124
+ const children = parseInto(templateSource, depth + 1, [...chain, `{${token}}`], state, false);
125
+ push({ type: "source", name: token, children });
126
+ continue;
127
+ }
128
+ state.uses.push({ name: token, isBeforeMarker: !state.isMarkerSeen });
129
+ push({ type: "placeholder", name: token });
130
+ }
131
+ if (cursor < source.length) {
132
+ push({ type: "text", text: source.slice(cursor) });
133
+ }
134
+ return nested;
135
+ }
136
+ /**
137
+ * Build the node for one `{path.md}` token, reading and recursively
138
+ * parsing the file.
139
+ */
140
+ function parseInclude(includePath, literal, depth, chain, state) {
141
+ const where = chain[chain.length - 1];
142
+ if (!isSafeIncludePath(includePath)) {
143
+ state.warnings.push(`${where}: include {${includePath}} rejected — use a workspace-relative path without ".."`);
144
+ return { type: "text", text: literal };
145
+ }
146
+ if (depth >= MAX_TEMPLATE_DEPTH || chain.includes(includePath)) {
147
+ state.warnings.push(`${where}: include {${includePath}} not expanded — nested too deep or recursive (${[...chain, includePath].join(" → ")})`);
148
+ return { type: "text", text: literal };
149
+ }
150
+ const body = state.options.readInclude(includePath);
151
+ if (body === null) {
152
+ state.warnings.push(`${where}: include {${includePath}} not found in the workspace`);
153
+ return { type: "include", path: includePath, children: [] };
154
+ }
155
+ state.includes.push(includePath);
156
+ const children = parseInto(body, depth + 1, [...chain, includePath], state, false);
157
+ return { type: "include", path: includePath, children };
158
+ }
159
+ /**
160
+ * Whether an include path stays inside the workspace: relative, no
161
+ * `..` segment, no empty segment.
162
+ *
163
+ * @param includePath The path inside the braces.
164
+ * @returns `true` when the path is acceptable.
165
+ */
166
+ export function isSafeIncludePath(includePath) {
167
+ if (includePath.startsWith("/")) {
168
+ return false;
169
+ }
170
+ return includePath.split("/").every((segment) => segment !== ".." && segment.length > 0);
171
+ }
172
+ /** Concatenate the rendered text of `nodes`. */
173
+ function renderNodes(nodes, values, shouldRedactIncludes) {
174
+ let out = "";
175
+ for (const node of nodes) {
176
+ switch (node.type) {
177
+ case "text":
178
+ out += node.text;
179
+ break;
180
+ case "placeholder":
181
+ out += values.get(node.name) ?? `{${node.name}}`;
182
+ break;
183
+ case "include":
184
+ out += shouldRedactIncludes
185
+ ? `<content of file ${node.path}>`
186
+ : renderNodes(node.children, values, shouldRedactIncludes);
187
+ break;
188
+ case "source":
189
+ out += renderNodes(node.children, values, shouldRedactIncludes);
190
+ break;
191
+ }
192
+ }
193
+ return out;
194
+ }