@cohortapp/agent-sdk 2.11.14 → 2.11.15

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.
@@ -130,6 +130,35 @@ function voiceRules(a) {
130
130
  ];
131
131
  }
132
132
 
133
+ /**
134
+ * Message-craft doctrine — the "how you shape an outbound message" half of the
135
+ * voice, injected into BOTH prompt-assembly paths so the two planes write the
136
+ * same way: the full-session prompt (scripts/daemon/prompt-builder.mjs
137
+ * #buildPrompt) and the quick-reply system prompt (scripts/daemon/responder.mjs
138
+ * #realGenerateResponse).
139
+ *
140
+ * Deliberately a separately-exported CONSTANT rather than part of voiceRules():
141
+ * the responder does not render the persona block — it calls neither renderPersona
142
+ * nor voiceRules — so folding it in would reach only full sessions and leave every
143
+ * reactive quick reply unshaped. As framework code it also survives per-seat
144
+ * CLAUDE.md drift, where the same doctrine lives as human-readable scaffold data.
145
+ *
146
+ * Phrased "distil by default; structure only when the message must be large" so it
147
+ * does NOT contradict the scaffold's `No unsolicited structure` rule
148
+ * (scaffold/CLAUDE.md `## Communication Rules`): headings/bullets are for the
149
+ * genuinely large message, never decoration on a small one. The attach-don't-dump
150
+ * bullet leans on mechanisms already sanctioned elsewhere in the prompt (the PDF
151
+ * builder, Slack upload, email --attachment) and on the standing "never reference a
152
+ * local file path" rule.
153
+ */
154
+ export const MESSAGE_CRAFT = [
155
+ "How you shape a message:",
156
+ "- Distil to what matters, by default. Lead with the answer, the decision, or the ask; cut throat-clearing, restatement of the question, and background the reader already has. Most replies are a few sentences — send those as a few sentences, and keep them scannable.",
157
+ "- Offer depth, don't front-load it. Give the sharp version and add \"I can go deeper on X if useful\" rather than dumping every detail pre-emptively.",
158
+ "- When a message genuinely must be long, FORMAT it for legibility: a short intro line, then short paragraphs, a heading, or a few bullets for the parts that actually are a list. Structure serves a large message; it never decorates a small one, and it is never a wall of text.",
159
+ "- When the content is genuinely verbose — a full memo or report, a long analysis, a large table or dataset — do NOT dump it into the chat. Attach it as a document and put a two-to-three-line summary plus the attachment in the message: generate a branded PDF (scripts/pdf-generation/build-document.mjs, or `npm run pdf:memo -- --input <file.md>`) or upload the file (Slack: scripts/slack-upload-v2.py; email: send with --attachment). Never reference a local file path in an outbound message.",
160
+ ].join("\n");
161
+
133
162
  /**
134
163
  * Render the persona block from resolved config. Pure — no I/O, never throws.
135
164
  *
@@ -4,7 +4,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
 
7
- import { renderPersona, loadPersonaBlock } from "./persona.mjs";
7
+ import { renderPersona, loadPersonaBlock, MESSAGE_CRAFT } from "./persona.mjs";
8
8
 
9
9
  const AGENT = {
10
10
  firstName: "Jamie",
@@ -100,6 +100,31 @@ test("junk values in list fields are dropped, not rendered", () => {
100
100
  assert.doesNotMatch(out, /\[object Object\]/);
101
101
  });
102
102
 
103
+ // The message-craft doctrine is a separately-exported constant (NOT part of the
104
+ // persona block) precisely so the responder — which never renders the persona —
105
+ // can inject the identical string. These pin the substance both planes rely on.
106
+ test("MESSAGE_CRAFT carries the distil-by-default / format-when-large / attach-when-verbose doctrine", () => {
107
+ // Distil by default; most replies are short.
108
+ assert.match(MESSAGE_CRAFT, /Distil to what matters, by default/);
109
+ assert.match(MESSAGE_CRAFT, /Lead with the answer, the decision, or the ask/);
110
+ // Offer depth on request rather than front-loading.
111
+ assert.match(MESSAGE_CRAFT, /Offer depth, don't front-load it/);
112
+ // Format only when the message must be large — never a wall of text.
113
+ assert.match(MESSAGE_CRAFT, /when a message genuinely must be long, FORMAT it/i);
114
+ assert.match(MESSAGE_CRAFT, /never a wall of text/);
115
+ // Attach verbose content with a short summary rather than dumping it.
116
+ assert.match(MESSAGE_CRAFT, /do NOT dump it into the chat/);
117
+ assert.match(MESSAGE_CRAFT, /two-to-three-line summary/);
118
+ });
119
+
120
+ test("MESSAGE_CRAFT names the REAL attach mechanisms and forbids local paths", () => {
121
+ assert.match(MESSAGE_CRAFT, /scripts\/pdf-generation\/build-document\.mjs/);
122
+ assert.match(MESSAGE_CRAFT, /npm run pdf:memo/);
123
+ assert.match(MESSAGE_CRAFT, /scripts\/slack-upload-v2\.py/);
124
+ assert.match(MESSAGE_CRAFT, /--attachment/);
125
+ assert.match(MESSAGE_CRAFT, /Never reference a local file path/);
126
+ });
127
+
103
128
  test("loadPersonaBlock reads an agent repo, and fails open on a broken config", () => {
104
129
  const dir = mkdtempSync(join(tmpdir(), "persona-"));
105
130
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cohortapp/agent-sdk",
3
- "version": "2.11.14",
3
+ "version": "2.11.15",
4
4
  "description": "Cohort Agent SDK — autonomous AI colleague runtime. Deploy senior AI colleagues on dedicated Mac minis, wired to the Cohort operating surface.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,6 +103,16 @@ message (Slack, email, WhatsApp, SMS, voice, drafted artefacts):
103
103
  - **Disagree when warranted.** Don't soften real concerns into politeness.
104
104
  - **Brevity is a feature.** If the answer is one sentence, send one
105
105
  sentence. Do not pad to look thorough.
106
+ - **Distil by default; format only when large.** Lead with the answer,
107
+ the decision, or the ask; offer more detail on request rather than
108
+ front-loading it. When a message genuinely must be long, format it for
109
+ legibility (a short intro line, then short paragraphs or a few bullets)
110
+ — never a wall of text. When the content is genuinely verbose (a full
111
+ report, a long analysis, a large table), do NOT dump it in the chat:
112
+ attach it as a document and give a two-to-three-line summary. Generate a
113
+ branded PDF (`npm run pdf:memo -- --input <file.md>`) or upload the file
114
+ (Slack: `scripts/slack-upload-v2.py`; email: send with `--attachment`).
115
+ See Document Sharing below.
106
116
 
107
117
  System-style output (status indicators, checklists, formal headers) is
108
118
  opt-in — only when the user explicitly requests it or the medium clearly
@@ -7,7 +7,7 @@ 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 } from "../../lib/identity/persona.mjs";
10
+ import { renderPersona, MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
11
11
  import { wrapExternalContent } from "../../lib/security/external-content.mjs";
12
12
  import { isEnabled as orgEnabled } from "../../lib/org/client.mjs";
13
13
  import { recall as orgRecall } from "../../lib/org/knowledge.mjs";
@@ -876,6 +876,14 @@ export async function buildPrompt(item, classResult, options = {}) {
876
876
  parts.push(SKILLS_GUIDANCE);
877
877
  parts.push("");
878
878
 
879
+ // 1c. Message-craft doctrine — the shared "how you shape an outbound message"
880
+ // rule (distil by default; format only when the message must be large; attach
881
+ // rather than dump genuinely verbose content). Exported from persona.mjs and
882
+ // injected into the responder's quick-reply prompt too, so reactive replies and
883
+ // full sessions write outbound prose the same way.
884
+ parts.push(MESSAGE_CRAFT);
885
+ parts.push("");
886
+
879
887
  // 1a. Holding message warning — TOP OF PROMPT so Claude sees it before action instructions.
880
888
  // This is the most critical instruction in the prompt: prevents double-replies.
881
889
  // We repeat it at section 7a as well, immediately before the action block.
@@ -212,6 +212,28 @@ test("buildPrompt caps the number of injected org facts (bounded)", async () =>
212
212
  });
213
213
  });
214
214
 
215
+ // ---------------------------------------------------------------------------
216
+ // Message-craft doctrine — the shared "how you shape an outbound message" rule
217
+ // (distil by default; format only when the message must be large; attach
218
+ // genuinely verbose content). The SAME constant leads the responder's quick-reply
219
+ // system prompt, so reactive replies and full sessions write outbound prose the
220
+ // same way.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ test("buildPrompt injects the message-craft doctrine (distil / format / attach)", async () => {
224
+ writeOrgConfig(null);
225
+ const prompt = await buildPrompt(ITEM, CLASS, { type: "inbox" });
226
+ assert.match(prompt, /How you shape a message:/);
227
+ assert.match(prompt, /Distil to what matters, by default/);
228
+ assert.match(prompt, /when a message genuinely must be long, FORMAT it/i);
229
+ assert.match(prompt, /do NOT dump it into the chat/);
230
+ // The real attach mechanism, not a vague "share it somewhere".
231
+ assert.match(prompt, /scripts\/pdf-generation\/build-document\.mjs/);
232
+ assert.match(prompt, /scripts\/slack-upload-v2\.py/);
233
+ // The rest of the prompt is unchanged.
234
+ assert.ok(prompt.includes("--- INCOMING MESSAGE ---"));
235
+ });
236
+
215
237
  test("buildPrompt states the acknowledgement was sent when it actually was", async () => {
216
238
  const prompt = await buildPrompt(ITEM, CLASS, {
217
239
  type: "inbox",
@@ -60,6 +60,13 @@ import { startTyping, stopTyping } from "./typing-registry.mjs";
60
60
  // (in short: a 60s `claude --print` spawn to write "let me look into it" lost
61
61
  // its own race 2 times in 3, and lost it silently).
62
62
  import { composeAck } from "./assurance.mjs";
63
+ // Shared message-craft doctrine (distil by default; format only when the message
64
+ // must be large; attach genuinely verbose content rather than dumping it). The
65
+ // SAME constant leads the full-session prompt in prompt-builder.mjs, so a reactive
66
+ // quick reply and a full inbox/backlog session shape outbound prose identically.
67
+ // This path never renders the persona block, which is why the doctrine is a
68
+ // separately-exported constant rather than part of voiceRules().
69
+ import { MESSAGE_CRAFT } from "../../lib/identity/persona.mjs";
63
70
 
64
71
  const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
65
72
  const SONNET_MODEL = "claude-sonnet-4-6";
@@ -708,7 +715,9 @@ If it's informational, acknowledge appropriately.
708
715
 
709
716
  Keep responses focused — 1-4 sentences for simple items, up to a short paragraph for more nuanced ones.
710
717
  Match the sender's tone and urgency level.
711
- ${profile ? `\nSender profile:\n${profile}` : ""}`;
718
+ ${profile ? `\nSender profile:\n${profile}` : ""}
719
+
720
+ ${MESSAGE_CRAFT}`;
712
721
 
713
722
  const conversationHistory = await loadConversationHistory(item);
714
723