@getfamiliar/shared 0.1.15 → 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.
- package/build/ChatInput.d.ts +42 -0
- package/build/ChatInput.js +45 -0
- package/build/ChatMessage.d.ts +11 -0
- package/build/ChatMessage.js +2 -1
- package/build/ChatMessageBus.d.ts +11 -1
- package/build/ChatMessageBus.js +26 -0
- package/build/Plugin.d.ts +103 -1
- package/build/PromptTemplate.d.ts +105 -0
- package/build/PromptTemplate.js +194 -0
- package/build/Schema.d.ts +1 -1
- package/build/Schema.js +23 -0
- package/build/Scratch.d.ts +36 -0
- package/build/Scratch.js +29 -0
- package/build/WebAuth.d.ts +166 -0
- package/build/WebAuth.js +18 -0
- package/build/WebMenu.d.ts +54 -0
- package/build/WebMenu.js +23 -0
- package/build/WebPlugin.d.ts +137 -0
- package/build/WebPlugin.js +13 -0
- package/build/WebStaticAssets.d.ts +72 -0
- package/build/WebStaticAssets.js +204 -0
- package/build/index.d.ts +9 -2
- package/build/index.js +8 -0
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/build/ChatMessage.d.ts
CHANGED
|
@@ -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
|
/**
|
package/build/ChatMessage.js
CHANGED
|
@@ -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.
|
package/build/ChatMessageBus.js
CHANGED
|
@@ -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
|
+
}
|