@cohortapp/agent-sdk 2.18.12 → 2.18.14
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/bin/maestro.mjs +38 -1
- package/docs/runbooks/fleet-rollout.md +14 -7
- package/docs/runbooks/recovery-and-failover.md +18 -0
- package/lib/cadence-failure-class.mjs +245 -0
- package/lib/claude-bin.mjs +26 -7
- package/lib/cli/doctor-checks.mjs +149 -1
- package/lib/comms/send-gate.mjs +6 -4
- package/lib/diagnostics/alerts.mjs +33 -0
- package/lib/engine/agents/usage.mjs +45 -0
- package/lib/engine/budget.mjs +293 -29
- package/lib/engine/cli.mjs +54 -5
- package/lib/engine/loop.mjs +30 -0
- package/lib/engine/output/json.mjs +26 -0
- package/lib/engine/wire/errors.mjs +179 -0
- package/lib/engine/wire/search.mjs +44 -8
- package/lib/identity/claude-md.mjs +107 -0
- package/lib/identity/disclosure-instructions.mjs +148 -0
- package/lib/identity/disclosure-scrub.mjs +207 -0
- package/lib/identity/persona.mjs +141 -6
- package/lib/org/inbound/conversation-frame.mjs +289 -0
- package/lib/org/inbound/directedness.mjs +27 -7
- package/lib/org/quota.mjs +27 -0
- package/lib/session/config.mjs +4 -0
- package/lib/session/identity.mjs +71 -7
- package/lib/session/launch-failure.mjs +251 -0
- package/lib/session/resume-target.mjs +86 -0
- package/lib/telemetry/collect.mjs +129 -0
- package/lib/upgrade/pinned-drift.mjs +467 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/skills/persona-discipline.md +24 -2
- package/scaffold/config/alerts.yaml +7 -0
- package/scripts/ci/check-cadence-prompts-exist.mjs +96 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/ci/run-tests.mjs +16 -2
- package/scripts/daemon/cadence-consumer.mjs +281 -34
- package/scripts/daemon/context-compiler.mjs +9 -1
- package/scripts/daemon/prompt-builder.mjs +219 -137
- package/scripts/daemon/responder.mjs +226 -26
- package/scripts/emergency-stop.sh +114 -13
- package/scripts/fleet/rollout.mjs +10 -3
- package/scripts/healthcheck.sh +131 -33
- package/scripts/resume-operations.sh +101 -6
- package/scripts/session/supervisor.mjs +198 -5
|
@@ -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(
|
|
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
|
|
328
|
-
*
|
|
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
|
|
332
|
-
const
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
816
|
-
|
|
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(
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|