herdr-plugin-amq 0.1.7 → 0.1.9

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/src/server.mjs CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { findAmqRoot, getAgentHandles } from "./config.mjs";
6
+ import { markMaildirMessageRead } from "./protocol.mjs";
6
7
  import {
7
8
  loadAllMessages,
8
9
  loadThreads,
@@ -75,6 +76,7 @@ export function startWebServer({
75
76
  // Map<handle, { herdrStatus, herdrPaneId, herdrWorkspaceId, ... }>
76
77
  let herdrStatusCache = new Map();
77
78
  let herdrSubscription = null;
79
+ let herdrRefreshDebounce = null;
78
80
 
79
81
  function broadcastSSE(payload) {
80
82
  const msg = `data: ${JSON.stringify(payload)}\n\n`;
@@ -89,6 +91,15 @@ export function startWebServer({
89
91
  } catch {}
90
92
  }
91
93
 
94
+ function scheduleHerdrRefresh(reason = "event") {
95
+ if (isClosing) return;
96
+ clearTimeout(herdrRefreshDebounce);
97
+ herdrRefreshDebounce = setTimeout(async () => {
98
+ await refreshHerdrCache();
99
+ broadcastSSE({ type: "herdr_agents_refresh", reason, at: new Date().toISOString() });
100
+ }, 80);
101
+ }
102
+
92
103
  let isClosing = false;
93
104
  let herdrReconnectTimeout = null;
94
105
 
@@ -99,42 +110,22 @@ export function startWebServer({
99
110
  }
100
111
  herdrSubscription = subscribeHerdrEvents({
101
112
  onConnect: () => {
102
- // Refresh snapshot on connect so our cache is up to date
103
- refreshHerdrCache();
113
+ scheduleHerdrRefresh("connected");
104
114
  },
105
115
  onEvent: (event) => {
106
- const t = event.type;
107
- // Agent state changed — update cache and notify SSE clients
108
- if (t === "agent.state_changed" || t === "agent.updated") {
109
- const handle = event.name;
110
- if (handle) {
111
- const existing = herdrStatusCache.get(handle) || {};
112
- herdrStatusCache.set(handle, {
113
- ...existing,
114
- herdrStatus: event.agent_status || existing.herdrStatus,
115
- herdrPaneId: event.pane_id || existing.herdrPaneId,
116
- herdrWorkspaceId: event.workspace_id || existing.herdrWorkspaceId,
117
- herdrTabId: event.tab_id || existing.herdrTabId,
118
- herdrTitle: event.terminal_title_stripped || event.terminal_title || existing.herdrTitle,
119
- interactiveReady: event.interactive_ready ?? existing.interactiveReady,
120
- });
121
- broadcastSSE({
122
- type: "herdr_agent_update",
123
- handle,
124
- herdrStatus: event.agent_status,
125
- paneId: event.pane_id,
126
- at: new Date().toISOString(),
127
- });
128
- }
129
- }
130
- // Workspace/pane lifecycle — do a full agent refresh
131
- if (t === "workspace.created" || t === "workspace.closed" ||
132
- t === "pane.created" || t === "pane.closed") {
133
- refreshHerdrCache();
116
+ const type = String(event?.type || "");
117
+ if (
118
+ type.startsWith("pane.") ||
119
+ type.startsWith("agent.") ||
120
+ type === "workspace.created" ||
121
+ type === "workspace.closed"
122
+ ) {
123
+ scheduleHerdrRefresh(type);
134
124
  }
135
125
  },
136
126
  onDisconnect: () => {
137
- // Reconnect after 5s if Herdr socket drops
127
+ herdrStatusCache = new Map();
128
+ broadcastSSE({ type: "herdr_agents_refresh", reason: "disconnected", at: new Date().toISOString() });
138
129
  if (!isClosing) {
139
130
  herdrReconnectTimeout = setTimeout(startHerdrSubscription, 5000);
140
131
  }
@@ -285,9 +276,25 @@ export function startWebServer({
285
276
  herdrWorkspaceId: h.herdrWorkspaceId,
286
277
  herdrTabId: h.herdrTabId,
287
278
  herdrTitle: h.herdrTitle,
288
- interactiveReady: h.interactiveReady,
289
- agentType: h.agentType,
290
- // Promote herdrStatus as the primary status when available
279
+ herdrObservedAt: h.herdrObservedAt,
280
+ interactiveReady: h.interactiveReady,
281
+ agentType: h.agentType,
282
+ runtimeModel: h.herdrModel || null,
283
+ modelSource: h.herdrModelSource || null,
284
+ herdrActivity: {
285
+ status: h.herdrStatus,
286
+ title: h.herdrMetadataTitle || h.herdrTitle,
287
+ terminalTitle: h.herdrTerminalTitle,
288
+ metadataTitle: h.herdrMetadataTitle,
289
+ stateLabels: h.herdrStateLabels || {},
290
+ tokens: h.herdrTokens || [],
291
+ stateChangeSeq: h.herdrStateChangeSeq,
292
+ observedAt: h.herdrObservedAt,
293
+ focused: h.herdrFocused,
294
+ launchPending: h.herdrLaunchPending,
295
+ model: h.herdrModel || null,
296
+ modelSource: h.herdrModelSource || null,
297
+ },
291
298
  status: h.herdrStatus !== "unknown" ? h.herdrStatus : (a.status || "offline"),
292
299
  };
293
300
  });
@@ -306,6 +313,11 @@ export function startWebServer({
306
313
 
307
314
  if (pathname === "/api/agents" && req.method === "POST") {
308
315
  const body = await parseJsonBody(req);
316
+ if (body.__error) {
317
+ res.writeHead(413, { "Content-Type": "application/json" });
318
+ res.end(JSON.stringify({ ok: false, error: body.__error }));
319
+ return;
320
+ }
309
321
  const repoRoot = path.resolve(path.dirname(amqRoot));
310
322
  // Workspaces are the default under the hood: automatically isolate agent in .worktrees/<handle>
311
323
  const worktreeResult = ensureAgentWorktree(repoRoot, body.handle, body.branch);
@@ -314,7 +326,7 @@ export function startWebServer({
314
326
  worktree: worktreeResult?.ok ? worktreeResult.path : body.worktree,
315
327
  });
316
328
  // Refresh Herdr cache after registration
317
- refreshHerdrCache().catch(() => {});
329
+ scheduleHerdrRefresh("agent_registered");
318
330
  res.writeHead(result.ok ? 200 : 400, { "Content-Type": "application/json" });
319
331
  res.end(JSON.stringify({ ...result, worktreeResult }));
320
332
  return;
@@ -459,6 +471,24 @@ export function startWebServer({
459
471
  }
460
472
 
461
473
 
474
+ if (pathname.startsWith("/api/messages/") && pathname.endsWith("/read") && req.method === "POST") {
475
+ const messageId = decodeURIComponent(pathname.slice("/api/messages/".length, -"/read".length));
476
+ const account = url.searchParams.get("account") || "user";
477
+ if (account === "all" || !/^[A-Za-z0-9_-]{1,128}$/.test(account)) {
478
+ res.writeHead(400, { "Content-Type": "application/json" });
479
+ res.end(JSON.stringify({ ok: false, error: "A concrete mailbox account is required" }));
480
+ return;
481
+ }
482
+ const result = markMaildirMessageRead(amqRoot, account, messageId);
483
+ if (result.ok) {
484
+ invalidateMessageCache();
485
+ broadcastSSE({ type: "mail_update", reason: "message_read", account, messageId, at: new Date().toISOString() });
486
+ }
487
+ res.writeHead(result.ok ? 200 : 404, { "Content-Type": "application/json" });
488
+ res.end(JSON.stringify(result));
489
+ return;
490
+ }
491
+
462
492
  if (pathname === "/api/messages" && req.method === "GET") {
463
493
  const account = url.searchParams.get("account") || "all";
464
494
  const folder = url.searchParams.get("folder") || "inbox";
@@ -718,8 +748,11 @@ export function startWebServer({
718
748
  ? "text/css; charset=utf-8"
719
749
  : "application/javascript; charset=utf-8";
720
750
 
721
- res.writeHead(200, { "Content-Type": mime });
722
- fs.createReadStream(filePath).pipe(res);
751
+ res.writeHead(200, {
752
+ "Content-Type": mime,
753
+ "Cache-Control": "no-store, max-age=0",
754
+ });
755
+ fs.createReadStream(filePath).pipe(res);
723
756
  } else {
724
757
  res.writeHead(404, { "Content-Type": "text/plain" });
725
758
  res.end("File not found");
@@ -779,6 +812,7 @@ export function startWebServer({
779
812
  server6 = null;
780
813
  }
781
814
  if (herdrReconnectTimeout) clearTimeout(herdrReconnectTimeout);
815
+ if (herdrRefreshDebounce) clearTimeout(herdrRefreshDebounce);
782
816
  if (watchDebounce) clearTimeout(watchDebounce);
783
817
  if (herdrSubscription) {
784
818
  try { herdrSubscription.close(); } catch {}
@@ -801,10 +835,20 @@ export function startWebServer({
801
835
  function parseJsonBody(req) {
802
836
  return new Promise((resolve) => {
803
837
  let acc = "";
838
+ let tooLarge = false;
804
839
  req.on("data", (chunk) => {
840
+ if (tooLarge) return;
805
841
  acc += chunk;
842
+ if (Buffer.byteLength(acc) > 1024 * 1024) {
843
+ acc = "";
844
+ tooLarge = true;
845
+ }
806
846
  });
807
847
  req.on("end", () => {
848
+ if (tooLarge) {
849
+ resolve({ __error: "request body too large" });
850
+ return;
851
+ }
808
852
  try {
809
853
  resolve(JSON.parse(acc));
810
854
  } catch {
package/src/store.mjs CHANGED
@@ -5,7 +5,8 @@ import crypto from "node:crypto";
5
5
  import { execCmd, getHerdrBin, getAgentHandles, getRepoRootFromAmq } from "./config.mjs";
6
6
  import { scanAgentBriefs, getAgentBrief, saveAgentBrief } from "./briefs.mjs";
7
7
  import { ingestAttachment } from "./blobs.mjs";
8
- import { sendMaildirMessage, replyMaildirMessage, drainMaildir } from "./protocol.mjs";
8
+ import { sendMaildirMessage, replyMaildirMessage, drainMaildir, ensureAgentMailbox, findMessageForRecipient, isSafeMailIdentifier, readMaildirMessageFile, writeBoundedFileAtomic } from "./protocol.mjs";
9
+ import { renderLocalTemplate } from "./templates.mjs";
9
10
 
10
11
  const PALETTE = [
11
12
  "#1a73e8", "#ea4335", "#fbbc05", "#34a853", "#ff6d00",
@@ -856,58 +857,242 @@ export function loadAgentDirectory(amqRoot) {
856
857
  return list;
857
858
  }
858
859
 
860
+ function readAgentProfile(profilePath) {
861
+ try {
862
+ const content = readMaildirMessageFile(profilePath, 1024 * 1024);
863
+ if (content === null) return null;
864
+ const parsed = JSON.parse(content);
865
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
866
+ } catch {
867
+ return null;
868
+ }
869
+ }
870
+
871
+ function writeAgentProfile(agentDir, profileData) {
872
+ const profilePath = path.join(agentDir, "profile.json");
873
+ writeBoundedFileAtomic(profilePath, JSON.stringify(profileData, null, 2), 1024 * 1024);
874
+ }
875
+
876
+ function normalizeText(value, fallback, maxLength) {
877
+ if (typeof value !== "string") return fallback;
878
+ const normalized = value
879
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
880
+ .replace(/`/g, "'")
881
+ .replace(/\s+/g, " ")
882
+ .trim();
883
+ return normalized ? normalized.slice(0, maxLength) : fallback;
884
+ }
885
+
886
+ function normalizeMultiline(value, fallback, maxLength) {
887
+ if (typeof value !== "string") return fallback;
888
+ const normalized = value
889
+ .replace(/\0/g, "")
890
+ .replace(/\r\n/g, "\n")
891
+ .replace(/[\u0001-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
892
+ .trim();
893
+ return normalized ? normalized.slice(0, maxLength) : fallback;
894
+ }
895
+
896
+ function withAgentRegistrationLock(agentDir, operation) {
897
+ const lockDir = path.join(agentDir, ".registration.lock");
898
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
899
+ const deadline = Date.now() + 5000;
900
+
901
+ while (true) {
902
+ try {
903
+ fs.mkdirSync(lockDir);
904
+ break;
905
+ } catch (error) {
906
+ if (error.code !== "EEXIST") throw error;
907
+ try {
908
+ const stat = fs.statSync(lockDir);
909
+ if (Date.now() - stat.mtimeMs > 30000) fs.rmSync(lockDir, { recursive: true, force: true });
910
+ } catch {}
911
+ if (Date.now() >= deadline) throw new Error("Timed out waiting for agent registration lock");
912
+ Atomics.wait(sleeper, 0, 0, 25);
913
+ }
914
+ }
915
+
916
+ try {
917
+ return operation();
918
+ } finally {
919
+ fs.rmSync(lockDir, { recursive: true, force: true });
920
+ }
921
+ }
922
+
923
+ function defaultWelcomeBody(handle, name) {
924
+ return `Welcome to AMQ.\nAgent data: name=${JSON.stringify(name)}; handle=${JSON.stringify(handle)}.\nRun herdr-amq mail drain --me ${handle} --include-body to read messages. No reply is required.`.slice(0, 2048);
925
+ }
926
+
927
+ function selectProfileFields(profile) {
928
+ if (!profile || typeof profile !== "object") return {};
929
+ const selected = {};
930
+ for (const key of ["name", "role", "model", "emoji", "color", "worktree", "prompt", "createdAt", "registrationId"]) {
931
+ if (Object.prototype.hasOwnProperty.call(profile, key)) selected[key] = profile[key];
932
+ }
933
+ if (profile.welcome && typeof profile.welcome === "object" && !Array.isArray(profile.welcome)) {
934
+ selected.welcome = {};
935
+ for (const key of ["status", "messageId", "sentAt", "templatePath", "templateSha256", "source", "lastError"]) {
936
+ if (Object.prototype.hasOwnProperty.call(profile.welcome, key)) selected.welcome[key] = profile.welcome[key];
937
+ }
938
+ }
939
+ return selected;
940
+ }
941
+
859
942
  /**
860
943
  * Register a new agent or update an existing agent profile with model configuration and prompt
861
944
  */
862
- export function registerAgent(amqRoot, { handle, name, role, model = "claude-3-7-sonnet", emoji, color, worktree, prompt, brief, syncDisk = true }) {
863
- if (!amqRoot || !fs.existsSync(amqRoot)) {
864
- return { ok: false, error: "Invalid AMQ root" };
865
- }
945
+ export function registerAgent(amqRoot, options = {}) {
946
+ if (!amqRoot || !fs.existsSync(amqRoot)) return { ok: false, error: "Invalid AMQ root" };
866
947
 
867
- const safeHandle = (handle || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
868
- if (!safeHandle) {
869
- return { ok: false, error: "Invalid agent handle" };
870
- }
948
+ const safeHandle = (options.handle || "").trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
949
+ if (!isSafeMailIdentifier(safeHandle, 128)) return { ok: false, error: "Invalid agent handle" };
871
950
 
872
- const agentDir = path.join(amqRoot, "agents", safeHandle);
873
- if (!fs.existsSync(agentDir)) {
874
- // Create standard AMQ maildir structure
875
- fs.mkdirSync(path.join(agentDir, "inbox", "new"), { recursive: true });
876
- fs.mkdirSync(path.join(agentDir, "inbox", "cur"), { recursive: true });
877
- fs.mkdirSync(path.join(agentDir, "inbox", "tmp"), { recursive: true });
878
- fs.mkdirSync(path.join(agentDir, "outbox", "sent"), { recursive: true });
879
- fs.mkdirSync(path.join(agentDir, "outbox", "tmp"), { recursive: true });
880
- fs.mkdirSync(path.join(agentDir, "receipts"), { recursive: true });
951
+ try {
952
+ const agentDir = ensureAgentMailbox(amqRoot, safeHandle);
953
+ return withAgentRegistrationLock(agentDir, () => registerAgentLocked(amqRoot, agentDir, safeHandle, options));
954
+ } catch (error) {
955
+ return { ok: false, error: error.message };
881
956
  }
957
+ }
882
958
 
883
- const promptContent = prompt || brief || undefined;
959
+ function registerAgentLocked(amqRoot, agentDir, safeHandle, options) {
960
+ const { name, role, model, emoji, color, worktree, prompt, brief, syncDisk = true } = options;
961
+ const profilePath = path.join(agentDir, "profile.json");
962
+ const existingProfile = readAgentProfile(profilePath);
963
+ const now = new Date().toISOString();
964
+ const registrationId = isSafeMailIdentifier(existingProfile?.registrationId, 128)
965
+ ? existingProfile.registrationId
966
+ : crypto.randomUUID();
967
+ const shouldDeliverWelcome = !existingProfile || existingProfile?.welcome?.status === "pending";
968
+ const welcomeMessageId = isSafeMailIdentifier(existingProfile?.welcome?.messageId)
969
+ ? existingProfile.welcome.messageId
970
+ : `welcome-${registrationId}`;
971
+ const defaultName = formatAgentTitle(safeHandle);
972
+ const defaultEmoji = safeHandle === "user" ? "👤" : safeHandle.slice(0, 1).toUpperCase();
973
+ const promptContent = normalizeMultiline(
974
+ prompt ?? brief,
975
+ normalizeMultiline(existingProfile?.prompt, "", 256 * 1024),
976
+ 256 * 1024
977
+ );
884
978
  const profileData = {
979
+ ...selectProfileFields(existingProfile),
885
980
  handle: safeHandle,
886
- name: name || formatAgentTitle(safeHandle),
887
- role: role || "Autonomous Specialist",
888
- model: model || "claude-3-7-sonnet",
889
- emoji: emoji || (safeHandle === "user" ? "👤" : safeHandle.slice(0, 1).toUpperCase()),
890
- color: color || getAgentColor(safeHandle),
891
- worktree: worktree || null,
981
+ name: normalizeText(name, normalizeText(existingProfile?.name, defaultName, 120), 120),
982
+ role: normalizeText(role, normalizeText(existingProfile?.role, "Autonomous Specialist", 240), 240),
983
+ model: normalizeText(model, normalizeText(existingProfile?.model, "claude-3-7-sonnet", 200), 200),
984
+ emoji: normalizeText(emoji, normalizeText(existingProfile?.emoji, defaultEmoji, 16), 16),
985
+ color: normalizeText(color, normalizeText(existingProfile?.color, getAgentColor(safeHandle), 64), 64),
986
+ worktree: normalizeText(worktree, normalizeText(existingProfile?.worktree, "", 1024), 1024) || null,
892
987
  prompt: promptContent,
893
- updatedAt: new Date().toISOString(),
988
+ createdAt: existingProfile?.createdAt || now,
989
+ registrationId,
990
+ updatedAt: now,
894
991
  };
895
992
 
896
- fs.writeFileSync(
897
- path.join(agentDir, "profile.json"),
898
- JSON.stringify(profileData, null, 2),
899
- "utf8"
900
- );
993
+ if (shouldDeliverWelcome) {
994
+ profileData.welcome = {
995
+ status: "pending",
996
+ messageId: welcomeMessageId,
997
+ ...(typeof existingProfile?.welcome?.lastError === "string"
998
+ ? { lastError: normalizeText(existingProfile.welcome.lastError, "delivery pending", 512) }
999
+ : {}),
1000
+ };
1001
+ }
1002
+ writeAgentProfile(agentDir, profileData);
901
1003
 
902
- // Sync to disk brief file if prompt is provided
903
1004
  if (syncDisk && promptContent) {
904
1005
  try {
905
1006
  const repoRoot = getRepoRootFromAmq(amqRoot);
906
- saveAgentBrief(repoRoot, safeHandle, { description: role, prompt: promptContent, model, role });
1007
+ saveAgentBrief(repoRoot, safeHandle, {
1008
+ description: profileData.role,
1009
+ prompt: promptContent,
1010
+ model: profileData.model,
1011
+ role: profileData.role,
1012
+ });
907
1013
  } catch {}
908
1014
  }
909
1015
 
910
- return { ok: true, agent: profileData };
1016
+ if (!shouldDeliverWelcome) return { ok: true, agent: profileData, welcome: { ok: true, skipped: true } };
1017
+
1018
+ const rendered = renderLocalTemplate(
1019
+ amqRoot,
1020
+ "welcome",
1021
+ {
1022
+ agent: {
1023
+ handle: safeHandle,
1024
+ name: profileData.name,
1025
+ role: profileData.role,
1026
+ model: profileData.model,
1027
+ emoji: profileData.emoji,
1028
+ color: profileData.color,
1029
+ worktree: profileData.worktree || "",
1030
+ },
1031
+ },
1032
+ defaultWelcomeBody(safeHandle, profileData.name)
1033
+ );
1034
+
1035
+ let deliveredMessage = findMessageForRecipient(amqRoot, safeHandle, welcomeMessageId);
1036
+ let deliveryError = null;
1037
+ if (!deliveredMessage) {
1038
+ try {
1039
+ sendMaildirMessage(amqRoot, {
1040
+ id: welcomeMessageId,
1041
+ from: "coordinator",
1042
+ to: [safeHandle],
1043
+ subject: `Welcome to AMQ, ${safeHandle}`,
1044
+ thread: `welcome/${safeHandle}`,
1045
+ body: rendered.text,
1046
+ priority: "normal",
1047
+ kind: "status",
1048
+ labels: ["welcome", "registration"],
1049
+ context: {
1050
+ template: "welcome",
1051
+ templateSource: rendered.source,
1052
+ templateSha256: rendered.sha256,
1053
+ registrationId,
1054
+ },
1055
+ created: now,
1056
+ });
1057
+ deliveredMessage = findMessageForRecipient(amqRoot, safeHandle, welcomeMessageId);
1058
+ } catch (error) {
1059
+ deliveryError = error;
1060
+ deliveredMessage = findMessageForRecipient(amqRoot, safeHandle, welcomeMessageId);
1061
+ }
1062
+ }
1063
+
1064
+ if (!deliveredMessage) {
1065
+ profileData.welcome = {
1066
+ status: "pending",
1067
+ messageId: welcomeMessageId,
1068
+ lastError: normalizeText(
1069
+ deliveryError?.message,
1070
+ "Welcome delivery was not observed",
1071
+ 512
1072
+ ),
1073
+ };
1074
+ writeAgentProfile(agentDir, profileData);
1075
+ return {
1076
+ ok: true,
1077
+ agent: profileData,
1078
+ welcome: { ok: false, pending: true, error: profileData.welcome.lastError },
1079
+ };
1080
+ }
1081
+
1082
+ profileData.welcome = {
1083
+ status: "sent",
1084
+ messageId: welcomeMessageId,
1085
+ sentAt: deliveredMessage.header?.created || now,
1086
+ templatePath: rendered.source === "template" && rendered.path ? path.relative(amqRoot, rendered.path) : null,
1087
+ templateSha256: rendered.source === "template" ? rendered.sha256 : null,
1088
+ source: rendered.source,
1089
+ };
1090
+ writeAgentProfile(agentDir, profileData);
1091
+ return {
1092
+ ok: true,
1093
+ agent: profileData,
1094
+ welcome: { ok: true, id: welcomeMessageId, sentAt: profileData.welcome.sentAt },
1095
+ };
911
1096
  }
912
1097
 
913
1098
  /**
@@ -0,0 +1,118 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ const ALLOWED_TEMPLATES = new Set(["welcome", "doorbell"]);
6
+ const MAX_TEMPLATE_BYTES = 64 * 1024;
7
+ const VARIABLE_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
8
+ const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
9
+
10
+ function normalizeScalar(value) {
11
+ if (typeof value === "string") {
12
+ return value
13
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
14
+ .replace(/`/g, "'")
15
+ .replace(/\s+/g, " ")
16
+ .trim();
17
+ }
18
+ if (typeof value === "number" && Number.isFinite(value)) return value;
19
+ if (typeof value === "boolean") return value;
20
+ throw new Error("Template values must be finite scalars");
21
+ }
22
+
23
+ function boundedFallback(fallback) {
24
+ const text = typeof fallback === "string" ? fallback : String(fallback ?? "");
25
+ const bytes = Buffer.from(text, "utf8");
26
+ return bytes.length <= MAX_TEMPLATE_BYTES ? text : bytes.subarray(0, MAX_TEMPLATE_BYTES).toString("utf8");
27
+ }
28
+
29
+ function resolveVariable(context, expression) {
30
+ const key = expression.trim();
31
+ if (!VARIABLE_PATTERN.test(key)) throw new Error(`Unsupported template variable: ${key}`);
32
+ const segments = key.split(".");
33
+ if (segments.some((segment) => FORBIDDEN_SEGMENTS.has(segment))) {
34
+ throw new Error(`Unsupported template variable: ${key}`);
35
+ }
36
+
37
+ let value = context;
38
+ for (const segment of segments) {
39
+ if (!value || typeof value !== "object" || !Object.prototype.hasOwnProperty.call(value, segment)) {
40
+ throw new Error(`Missing template variable: ${key}`);
41
+ }
42
+ value = value[segment];
43
+ }
44
+ return normalizeScalar(value);
45
+ }
46
+
47
+ export function renderTemplate(source, context) {
48
+ if (typeof source !== "string" || !source.trim()) throw new Error("Template is empty");
49
+ if (Buffer.byteLength(source, "utf8") > MAX_TEMPLATE_BYTES) throw new Error("Template is too large");
50
+
51
+ if (source.includes("{%") || source.includes("{#")) throw new Error("Unsupported template syntax");
52
+ const withoutExpressions = source.replace(/{{([\s\S]*?)}}/g, "");
53
+ if (withoutExpressions.includes("{") || withoutExpressions.includes("}")) {
54
+ throw new Error("Malformed template expression");
55
+ }
56
+
57
+ const rendered = source.replace(/{{([\s\S]*?)}}/g, (_, expression) => String(resolveVariable(context, expression)));
58
+ if (Buffer.byteLength(rendered, "utf8") > MAX_TEMPLATE_BYTES) throw new Error("Rendered template is too large");
59
+ return rendered;
60
+ }
61
+
62
+ export function loadLocalTemplate(amqRoot, name) {
63
+ if (!amqRoot || !ALLOWED_TEMPLATES.has(name)) return null;
64
+
65
+ try {
66
+ const root = fs.realpathSync(path.resolve(amqRoot));
67
+ const templateDir = path.join(root, "templates");
68
+ const dirStat = fs.lstatSync(templateDir);
69
+ if (!dirStat.isDirectory() || dirStat.isSymbolicLink()) return null;
70
+
71
+ const realTemplateDir = fs.realpathSync(templateDir);
72
+ if (realTemplateDir !== templateDir) return null;
73
+
74
+ const templatePath = path.join(templateDir, `${name}.md`);
75
+ const realTemplatePath = fs.realpathSync(templatePath);
76
+ if (path.dirname(realTemplatePath) !== realTemplateDir) return null;
77
+
78
+ const noFollow = fs.constants.O_NOFOLLOW || 0;
79
+ const fd = fs.openSync(templatePath, fs.constants.O_RDONLY | noFollow);
80
+ try {
81
+ const stat = fs.fstatSync(fd);
82
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_TEMPLATE_BYTES) return null;
83
+ const source = fs
84
+ .readFileSync(fd, "utf8")
85
+ .replace(/^\uFEFF/, "")
86
+ .replace(/\r\n/g, "\n");
87
+ if (!source.trim() || source.includes("\0") || Buffer.byteLength(source, "utf8") > MAX_TEMPLATE_BYTES) return null;
88
+
89
+ return {
90
+ name,
91
+ path: realTemplatePath,
92
+ source,
93
+ sha256: crypto.createHash("sha256").update(source).digest("hex"),
94
+ };
95
+ } finally {
96
+ fs.closeSync(fd);
97
+ }
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ export function renderLocalTemplate(amqRoot, name, context, fallback) {
104
+ const safeFallback = boundedFallback(fallback);
105
+ const template = loadLocalTemplate(amqRoot, name);
106
+ if (!template) return { text: safeFallback, source: "fallback", path: null, sha256: null };
107
+
108
+ try {
109
+ return {
110
+ text: renderTemplate(template.source, context),
111
+ source: "template",
112
+ path: template.path,
113
+ sha256: template.sha256,
114
+ };
115
+ } catch {
116
+ return { text: safeFallback, source: "fallback", path: null, sha256: null };
117
+ }
118
+ }