@cohortapp/agent-sdk 2.18.11 → 2.18.13

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.
@@ -7,7 +7,32 @@ import { readFileSync, readdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { createRequire } from "node:module";
9
9
  import { compileContext } from "./context-compiler.mjs";
10
- import { renderPersona, MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
10
+ import { renderPersona, MESSAGE_CRAFT, SELF_PRESENTATION, configuredStr, renderSeatPersona } from "../../lib/identity/persona.mjs";
11
+ import { scrubSeatText } from "../../lib/identity/disclosure-scrub.mjs";
12
+ // Re-exported: the seat-persona renderer MOVED to lib/identity/persona.mjs so the
13
+ // quick-reply responder can render the same resolved identity block. One
14
+ // definition, two prompt planes.
15
+ export { renderSeatPersona };
16
+ // The CLAUDE.md preamble helpers — ONE definition, shared with the quick-reply
17
+ // responder. They lived in responder.mjs and were duplicated here, which is how
18
+ // this plane kept an unfixed copy of the scrape after the other was repaired.
19
+ import { preambleTargets, scrapeClaudeMdSections, stripScaffoldSentinels } from "../../lib/identity/claude-md.mjs";
20
+ // A seat's own CLAUDE.md can order a self-introduction and this repo cannot see
21
+ // it, so the check runs at runtime over the composed preamble.
22
+ import { stripDisclosureInstructionsAndWarn } from "../../lib/identity/disclosure-instructions.mjs";
23
+ // The conversation frame — the block that tells a prompt it is joining an
24
+ // exchange already in progress, with people who already know who the agent is.
25
+ // The SAME module the quick reply uses, so a message that escalates from rung 1
26
+ // to a full session does not change what the model is told about the room.
27
+ import {
28
+ conversationFrame,
29
+ dedupeThreadAgainstHistory,
30
+ frameRecipientClass,
31
+ markOwnTurns,
32
+ } from "../../lib/org/inbound/conversation-frame.mjs";
33
+ // Internal vs external by the send gate's own predicate, injected so
34
+ // conversation-frame.mjs stays pure.
35
+ import { classifyRecipient } from "../../lib/comms/send-gate.mjs";
11
36
  import { wrapExternalContent } from "../../lib/security/external-content.mjs";
12
37
  import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
13
38
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
@@ -41,6 +66,19 @@ function loadAgent() {
41
66
  }
42
67
  }
43
68
 
69
+ /**
70
+ * The seat's own name as it appears in a transcript, or "" when the seat is not
71
+ * configured. Scaffold sentinels ("UNCONFIGURED", "Unconfigured Agent") are
72
+ * absence, not a name — marking turns as belonging to "Unconfigured Agent"
73
+ * would be worse than marking none.
74
+ *
75
+ * @returns {string}
76
+ */
77
+ function seatDisplayName() {
78
+ const a = loadAgent();
79
+ return configuredStr(a.fullName) || configuredStr(a.firstName) || "";
80
+ }
81
+
44
82
  // Legacy: Maximum lines of conversation history (used when DAEMON_CONTEXT_COMPILER is off)
45
83
  const MAX_HISTORY_LINES = 30;
46
84
 
@@ -214,90 +252,6 @@ let cachedPreamble = null;
214
252
  // back to a generic-assistant register.
215
253
  let cachedPersona = null;
216
254
 
217
- /**
218
- * A config value the operator has not filled in yet.
219
- *
220
- * The scaffold does not ship empty strings for everything — it ships SENTINELS:
221
- * config/company.json has `"name": "UNCONFIGURED"`, config/agent.json has
222
- * `"firstName": "UNCONFIGURED"`, `"fullName": "Unconfigured Agent"`,
223
- * `"title": "Unconfigured Role"`. lib/identity/persona.mjs#renderPersona omits
224
- * EMPTY fields, which is right, but a sentinel is not empty — so an untouched
225
- * scaffold rendered "You are Unconfigured Agent, Unconfigured Role at
226
- * UNCONFIGURED." into a live prompt. Treat the sentinel as absence.
227
- *
228
- * @param {*} v
229
- * @returns {string} the trimmed value, or "" when absent/sentinel
230
- */
231
- function configuredStr(v) {
232
- const s = typeof v === "string" ? v.trim() : "";
233
- if (!s) return "";
234
- if (/^unconfigured\b/i.test(s)) return "";
235
- return s;
236
- }
237
-
238
- /**
239
- * Strip scaffold sentinels out of config/agent.json before it is rendered, so
240
- * `renderPersona`'s omit-when-unset rule actually fires on a fresh repo.
241
- * @param {object} a
242
- * @returns {object}
243
- */
244
- function scrubAgentConfig(a) {
245
- const src = a && typeof a === "object" ? a : {};
246
- const out = { ...src };
247
- for (const k of ["firstName", "lastName", "fullName", "title", "company", "companyDescription", "persona", "background", "bio"]) {
248
- if (k in out) out[k] = configuredStr(out[k]);
249
- }
250
- // A surname with no first name and no full name is not an identity — better
251
- // to render no name at all than "You are AGENT." (the scaffold ships
252
- // firstName "UNCONFIGURED" / lastName "AGENT").
253
- if (!out.firstName && !out.fullName) out.lastName = "";
254
- if (src.principal && typeof src.principal === "object") {
255
- const p = { ...src.principal };
256
- for (const k of ["firstName", "lastName", "fullName", "title"]) p[k] = configuredStr(p[k]);
257
- out.principal = p;
258
- }
259
- return out;
260
- }
261
-
262
- /** Same, for config/company.json. */
263
- function scrubCompanyConfig(c) {
264
- const src = c && typeof c === "object" ? c : {};
265
- const out = { ...src };
266
- for (const k of ["name", "legalName", "description", "tagline", "industry", "stage"]) {
267
- if (k in out) out[k] = configuredStr(out[k]);
268
- }
269
- return out;
270
- }
271
-
272
- /** Read + scrub both config files. Never throws. @returns {{agent:object, company:object}} */
273
- function readSeatConfig(root) {
274
- const read = (rel) => {
275
- try { return JSON.parse(readFileSync(join(root, rel), "utf-8")); } catch { return {}; }
276
- };
277
- return {
278
- agent: scrubAgentConfig(read("config/agent.json")),
279
- company: scrubCompanyConfig(read("config/company.json")),
280
- };
281
- }
282
-
283
- /**
284
- * Render the persona block for a seat — config/agent.json + config/company.json
285
- * through lib/identity/persona.mjs#renderPersona, with scaffold sentinels
286
- * scrubbed first so an unconfigured field is OMITTED rather than asserted.
287
- *
288
- * @param {string} root agent repo root
289
- * @param {object} [opts] forwarded to renderPersona
290
- * @returns {string} "" when nothing is configured
291
- */
292
- export function renderSeatPersona(root, opts = {}) {
293
- try {
294
- const { agent, company } = readSeatConfig(root);
295
- return renderPersona(agent, company, opts) || "";
296
- } catch {
297
- return "";
298
- }
299
- }
300
-
301
255
  /** Render (once per process) the persona block from config/agent.json. */
302
256
  function loadPersona() {
303
257
  if (cachedPersona !== null) return cachedPersona;
@@ -313,9 +267,10 @@ export function _resetPersonaCache() {
313
267
  function loadPreamble() {
314
268
  if (cachedPreamble) return cachedPreamble;
315
269
 
270
+ const claudeMd = join(AGENT_REPO_DIR, "CLAUDE.md");
316
271
  try {
317
- const raw = readFileSync(join(AGENT_REPO_DIR, "CLAUDE.md"), "utf-8");
318
- cachedPreamble = extractPreamble(raw);
272
+ const raw = readFileSync(claudeMd, "utf-8");
273
+ cachedPreamble = extractPreamble(raw, { hasPersona: Boolean(loadPersona()), source: claudeMd });
319
274
  } catch (err) {
320
275
  console.error(`[prompt-builder] Failed to read CLAUDE.md: ${err.message}`);
321
276
  cachedPreamble = fallbackPreamble();
@@ -324,45 +279,63 @@ function loadPreamble() {
324
279
  }
325
280
 
326
281
  /**
327
- * Extract the Identity, Operating Principles, Communication Rules,
328
- * and Document Sharing sections from CLAUDE.md — keep it concise.
282
+ * Extract the house-style sections from CLAUDE.md — keep it concise.
283
+ *
284
+ * THREE THINGS HAPPEN HERE, AND ALL THREE ARE THE SAME DEFECT.
285
+ *
286
+ * 1. `## Identity` IS DROPPED WHEN A PERSONA RENDERED. On every seat in the
287
+ * fleet that section is the scaffold template — "You are `{{agent.fullName}}`,
288
+ * `{{agent.title}}` at `{{agent.company}}`…" followed by "If those tokens are
289
+ * still unresolved, your identity has not been configured yet — run … maestro
290
+ * setup". Nothing substitutes those tokens on a `claude --print` call.
291
+ * buildPrompt() pushes the RESOLVED persona and then pushed this scrape
292
+ * immediately after, so the model got a correct identity followed by a notice
293
+ * that its identity was unconfigured. lib/identity/persona.mjs's own header
294
+ * names that exact condition as what makes an agent introduce and sign itself
295
+ * with a rendering of the policy's identity_line. See
296
+ * lib/identity/claude-md.mjs#preambleTargets for why the section is RESTORED
297
+ * on an unenrolled seat.
298
+ *
299
+ * 2. SCAFFOLD SENTINELS GO. "*Configured by `maestro setup`*" under `## Company
300
+ * Context` and `### Autonomy Model` is the same "you are not set up" signal,
301
+ * delivered once per turn.
302
+ *
303
+ * 3. A SELF-INTRODUCTION INSTRUCTION IN THE SEAT'S OWN FILE GOES, and is warned
304
+ * about. This is the one input the repo cannot see: a line like "On first
305
+ * message in any thread, introduce yourself as <the identity_line>." in a
306
+ * seat's `## Communication Rules` survives every
307
+ * upgrade and defeats everything above it. See
308
+ * lib/identity/disclosure-instructions.mjs — it is narrow on purpose, and it
309
+ * keeps a line that FORBIDS the behaviour.
310
+ *
311
+ * The <= 200 char floor is unchanged and still falls back: a scrape that yields
312
+ * almost nothing is a seat whose CLAUDE.md never got written, and
313
+ * `buildFallbackPreamble()` renders identity and autonomy from config instead.
314
+ *
315
+ * @param {string} raw CLAUDE.md contents
316
+ * @param {{hasPersona?: boolean, source?: string}} [opts]
317
+ * @returns {string}
329
318
  */
330
- function extractPreamble(raw) {
331
- const sections = [];
332
- const lines = raw.split("\n");
333
- const targetHeaders = [
334
- "## Identity",
319
+ function extractPreamble(raw, opts = {}) {
320
+ const { hasPersona = false, source = "CLAUDE.md" } = opts;
321
+ const targets = preambleTargets(hasPersona, [
335
322
  "## Company Context",
336
323
  "## Operating Principles",
337
324
  "## Communication Rules",
338
- ];
339
-
340
- let capturing = false;
341
- let depth = 0;
342
-
343
- for (const line of lines) {
344
- // Start capturing when we hit a target header
345
- if (targetHeaders.some((h) => line.startsWith(h))) {
346
- capturing = true;
347
- depth = 2; // ## level
348
- sections.push(line);
349
- continue;
350
- }
351
-
352
- // Stop capturing when we hit another ## header that is NOT a sub-section of what we want
353
- if (capturing && /^## [A-Z]/.test(line) && !targetHeaders.some((h) => line.startsWith(h))) {
354
- capturing = false;
355
- sections.push(""); // blank line separator
356
- continue;
357
- }
358
-
359
- // Also stop at ### Document Sharing end — capture it but stop at next ##
360
- if (capturing) {
361
- sections.push(line);
362
- }
363
- }
364
-
365
- const extracted = sections.join("\n").trim();
325
+ ]);
326
+ const scraped = stripScaffoldSentinels(scrapeClaudeMdSections(raw, targets));
327
+ // TWO scrubs, BOTH before the length floor, because they catch different
328
+ // things and this text is the SEAT's — `maestro upgrade` never touches it, so
329
+ // a seat hand-edited in the era when the send gate demanded an identity line
330
+ // from internal recipients still carries that instruction, first in the
331
+ // prompt. Shipping a counter-rule does not delete a contradiction; removing
332
+ // the contradiction does. `scrubSeatText` removes the rendered TEMPLATES
333
+ // (policies/ai-disclosure.yaml's identity_line and its kin);
334
+ // `stripDisclosureInstructionsAndWarn` removes the INSTRUCTION to open with
335
+ // one, and names the offending line on stderr so an operator can fix the
336
+ // file at source. The truthfulness invariant is exempt in both, by
337
+ // construction.
338
+ const extracted = stripDisclosureInstructionsAndWarn(scrubSeatText(scraped), source).trim();
366
339
 
367
340
  // If extraction got something reasonable, use it; otherwise fall back
368
341
  if (extracted.length > 200) return extracted;
@@ -795,9 +768,37 @@ function formatSender(item) {
795
768
  }
796
769
 
797
770
  /**
798
- * Build the item context block for inbox items
771
+ * Build the item context block for inbox items.
772
+ *
773
+ * THE FRAME. When there is a prior exchange, this block opens with
774
+ * `conversationFrame()` — the statement that the agent is ALREADY IN this
775
+ * conversation, who else has spoken, and that the other speakers are colleagues
776
+ * who know who it is. Without it the transcript arrives as a bare
777
+ * "Thread context:" dump with the agent's own turns unmarked, and a model handed
778
+ * a transcript it is not told it is part of behaves correctly when it introduces
779
+ * itself. That is what produced "Quick note before we get into it: I'm <Name>,
780
+ * <the policy's identity_line>" in the middle of a live thread.
781
+ *
782
+ * It is the SAME module the quick-reply responder renders, so an ask that starts
783
+ * on the 60-second path and escalates here (agent-daemon.mjs restricts the quick
784
+ * reply to ladder rung 0/1; anything needing real work comes to buildPrompt)
785
+ * does not change what the model is told about the room mid-flight.
786
+ *
787
+ * `markOwnTurns` rewrites nothing but the SPEAKER LABEL, and the transcript is
788
+ * still fenced as untrusted data below — the marking happens inside the fence,
789
+ * so an inbound line that impersonates the seat's name gains a "(you)" marker
790
+ * inside the fence and no more authority than any other fenced line.
791
+ *
792
+ * @param {object} item
793
+ * @param {{agentName?: string, recipientClass?: "internal"|"external", history?: string}} [opts]
794
+ * `history` is the channel transcript this prompt will ALSO carry (from
795
+ * the compiled context or the legacy block). It is used only to derive
796
+ * the speaker roster and to drop turns the thread duplicates; it is not
797
+ * rendered here.
798
+ * @returns {string}
799
799
  */
800
- export function buildInboxContext(item) {
800
+ export function buildInboxContext(item, opts = {}) {
801
+ const { agentName = "", recipientClass = "internal", history = "" } = opts;
801
802
  const lines = [];
802
803
  lines.push("--- INCOMING MESSAGE ---");
803
804
  lines.push(`From: ${formatSender(item)}`);
@@ -812,10 +813,30 @@ export function buildInboxContext(item) {
812
813
 
813
814
  const source = item.service || item.channel || "external";
814
815
 
815
- if (item.thread_context) {
816
- lines.push("Thread context:");
816
+ // The thread, with the agent's own turns marked and the turns the channel
817
+ // history already carries removed — the two overlap almost completely on a
818
+ // mid-thread reply, and the same turns arriving twice read as two different
819
+ // records of one exchange.
820
+ const threadMarked = markOwnTurns(
821
+ dedupeThreadAgainstHistory(history, item.thread_context || ""),
822
+ agentName,
823
+ );
824
+ const frame = conversationFrame({
825
+ item,
826
+ agentName,
827
+ recipientClass,
828
+ history: markOwnTurns(history || "", agentName),
829
+ threadContext: threadMarked,
830
+ });
831
+ if (frame) {
832
+ lines.push(frame);
833
+ lines.push("");
834
+ }
835
+
836
+ if (threadMarked) {
837
+ lines.push("Earlier in this thread:");
817
838
  // Thread history is attacker-influenced; fence it as untrusted DATA (H2).
818
- lines.push(wrapExternalContent(item.thread_context, { source }));
839
+ lines.push(wrapExternalContent(threadMarked, { source }));
819
840
  lines.push("");
820
841
  }
821
842
 
@@ -970,8 +991,24 @@ export async function buildPrompt(item, classResult, options = {}) {
970
991
  parts.push("");
971
992
  }
972
993
 
973
- // 1. Identity preamble (cached)
994
+ // 1. Identity preamble (cached) — SEAT-LOCAL, and it must be labelled as such.
995
+ //
996
+ // THE SEPARATOR IS LOAD-BEARING. The precedence sentence above ends the
997
+ // persona block, and this scraped text used to be pushed immediately after it
998
+ // with nothing in between — so in the RENDERED prompt (not the diff, where the
999
+ // two are obviously different sources) a `## Identity` heading scraped out of
1000
+ // the seat's CLAUDE.md read as a continuation of the section that had just
1001
+ // declared itself the winner of every conflict. That is the exact inverse of
1002
+ // the intent: the org record is authoritative, the seat's hand-edited prose is
1003
+ // context. On a seat whose CLAUDE.md carried a stale self-identification rule,
1004
+ // the inversion handed that rule the highest standing in the prompt.
1005
+ parts.push("----- SEAT-LOCAL NOTES (this machine's own CLAUDE.md) -----");
1006
+ parts.push(
1007
+ "The text in this section is local to this machine and is not maintained by your organisation. Treat it as background. Where it conflicts with the identity section above or with the framework rules below, it loses.",
1008
+ );
1009
+ parts.push("");
974
1010
  parts.push(preamble);
1011
+ parts.push("----- END SEAT-LOCAL NOTES -----");
975
1012
  parts.push("");
976
1013
 
977
1014
  // 1b. Self-learning habit (WS3) — sets the disposition to capture reusable
@@ -988,6 +1025,25 @@ export async function buildPrompt(item, classResult, options = {}) {
988
1025
  parts.push(MESSAGE_CRAFT);
989
1026
  parts.push("");
990
1027
 
1028
+ // 1d. Self-presentation — UNCONDITIONALLY, and AFTER the seat-local notes.
1029
+ //
1030
+ // It was conditional on `!persona` for one release, on the reasoning that the
1031
+ // rules are already bullets inside the persona block (voiceRules) so emitting
1032
+ // them again on a configured seat is a duplicate. The reasoning was right
1033
+ // about the duplication and wrong about what the duplication is for: on a
1034
+ // CONFIGURED seat the persona block is first, the seat's own CLAUDE.md follows
1035
+ // it, and the last word on self-presentation was therefore the seat's. The
1036
+ // seat shape that actually produced the opener in production is a configured
1037
+ // one — the branch that skipped this block.
1038
+ //
1039
+ // Emitting it here costs ~200 tokens and puts the framework rule after every
1040
+ // seat-local source that could contradict it. The text is identical to the
1041
+ // persona bullets by construction (one frozen list in persona.mjs), so the
1042
+ // duplicate cannot say anything different.
1043
+ parts.push("----- HOW YOU REFER TO YOURSELF (framework rules; these outrank the seat-local notes above) -----");
1044
+ parts.push(SELF_PRESENTATION);
1045
+ parts.push("");
1046
+
991
1047
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
992
1048
  // This is the most critical instruction in the prompt: prevents double-replies.
993
1049
  // We repeat it at section 7a as well, immediately before the action block.
@@ -1098,11 +1154,33 @@ export async function buildPrompt(item, classResult, options = {}) {
1098
1154
  parts.push(`Summary: ${classResult.summary || "No summary"}`);
1099
1155
  parts.push("");
1100
1156
 
1101
- // 3. Item context — inbox or backlog
1157
+ // 3. Item context — inbox or backlog.
1158
+ //
1159
+ // The inbox branch carries the CONVERSATION FRAME (see buildInboxContext), and
1160
+ // the frame needs three things this function is the only place that has:
1161
+ //
1162
+ // - the seat's own name, so its prior turns in the transcript can be marked
1163
+ // as ITS OWN. Without it the model reads lines it authored as a third
1164
+ // party's — a conversation it has not joined, and a model joining a
1165
+ // conversation cold introduces itself;
1166
+ // - whether the other speakers are colleagues or outside correspondents;
1167
+ // - the channel transcript, to derive the speaker roster and to drop the
1168
+ // turns the thread and the history BOTH carry.
1169
+ //
1170
+ // The history is read ONCE here and handed to both consumers. On the compiled
1171
+ // path it is not rendered from this variable (compileContext renders its own
1172
+ // block below) — it is read for the frame, which is two bounded local JSONL
1173
+ // reads, and reusing it keeps the legacy branch from reading the same files a
1174
+ // second time.
1175
+ const legacyHistory = type === "inbox" && item ? loadConversationHistory(item) : null;
1102
1176
  if (type === "backlog" && queueItem) {
1103
1177
  parts.push(buildBacklogContext(queueItem));
1104
1178
  } else {
1105
- parts.push(buildInboxContext(item));
1179
+ parts.push(buildInboxContext(item, {
1180
+ agentName: seatDisplayName(),
1181
+ recipientClass: frameRecipientClass(item || {}, classifyRecipient),
1182
+ history: legacyHistory || "",
1183
+ }));
1106
1184
  }
1107
1185
  parts.push("");
1108
1186
 
@@ -1132,20 +1210,24 @@ export async function buildPrompt(item, classResult, options = {}) {
1132
1210
  classResult,
1133
1211
  { type, queueItem }
1134
1212
  );
1135
- parts.push(contextBlock);
1213
+ // Mark the seat's own turns HERE too. The frame above tells the model that
1214
+ // lines marked "(you)" are its own earlier turns; on this path the
1215
+ // transcript arrives inside the compiled block, so without this the claim
1216
+ // would be true of the thread block and false of the history — and a
1217
+ // conversation whose only record of the agent's own voice is in the
1218
+ // history would read as one the agent has not joined. `markOwnTurns`
1219
+ // rewrites nothing but a speaker LABEL and is byte-identical when the
1220
+ // seat has not spoken, so a block with no turns of its own is untouched.
1221
+ parts.push(markOwnTurns(contextBlock, seatDisplayName()));
1136
1222
  parts.push("");
1137
1223
  } catch (err) {
1138
1224
  console.error(`[prompt-builder] Context compilation failed: ${err.message}`);
1139
1225
  // Fall through to legacy behaviour
1140
- if (type === "inbox" && item) {
1141
- const history = loadConversationHistory(item);
1142
- if (history) { parts.push(history); parts.push(""); }
1143
- }
1226
+ if (legacyHistory) { parts.push(legacyHistory); parts.push(""); }
1144
1227
  }
1145
1228
  } else if (type === "inbox" && item) {
1146
1229
  // Legacy path (feature flag off)
1147
- const history = loadConversationHistory(item);
1148
- if (history) { parts.push(history); parts.push(""); }
1230
+ if (legacyHistory) { parts.push(legacyHistory); parts.push(""); }
1149
1231
  }
1150
1232
 
1151
1233
  // 5. Action instructions