@commonlyai/cli 0.1.18 → 0.1.24

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.24",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -28,7 +28,8 @@
28
28
  ],
29
29
  "scripts": {
30
30
  "start": "node src/index.js",
31
- "lint": "eslint src/",
31
+ "lint": "eslint src --ext .js",
32
+ "lint:fix": "eslint src --ext .js --fix",
32
33
  "test": "node --experimental-vm-modules node_modules/.bin/jest",
33
34
  "prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
34
35
  },
@@ -36,6 +37,7 @@
36
37
  "commander": "^12.0.0"
37
38
  },
38
39
  "devDependencies": {
40
+ "eslint": "^8.56.0",
39
41
  "jest": "^29.7.0"
40
42
  },
41
43
  "jest": {
@@ -351,6 +351,25 @@ export const assertSandboxDeclaredForPublicPod = async ({
351
351
  );
352
352
  };
353
353
 
354
+ // ── wake: toggle ADR-018 ambient wake on an attached agent ──────────────────
355
+
356
+ /**
357
+ * Set `config.wakeOnMessage.enabled` on an installation via the registry
358
+ * PATCH route (user JWT, pod member or creator). Pure core — takes the token
359
+ * record so the command doesn't need to ask for the pod again.
360
+ */
361
+ export const setWakeOnMessage = async ({ client, record, enabled }) => {
362
+ if (!record?.podId || !record?.agentName) {
363
+ throw new Error('token record is missing podId/agentName — re-attach the agent');
364
+ }
365
+ const instanceId = record.instanceId || 'default';
366
+ await client.patch(
367
+ `/api/registry/pods/${record.podId}/agents/${record.agentName}`,
368
+ { instanceId, config: { wakeOnMessage: { enabled: Boolean(enabled) } } },
369
+ );
370
+ return { agentName: record.agentName, podId: record.podId, instanceId, enabled: Boolean(enabled) };
371
+ };
372
+
354
373
  // ── attach: register a local-CLI-wrapped agent (ADR-005) ────────────────────
355
374
 
356
375
  /**
@@ -364,6 +383,7 @@ export const performAttach = async ({
364
383
  podId,
365
384
  displayName,
366
385
  envPath = null,
386
+ wakeOnMessage = false,
367
387
  log = () => {},
368
388
  }) => {
369
389
  const adapter = getAdapter(adapterName);
@@ -506,6 +526,10 @@ export const performAttach = async ({
506
526
  host: 'byo',
507
527
  },
508
528
  ...(environment ? { environment } : {}),
529
+ // ADR-018: ambient wake is a per-install opt-in read by
530
+ // agentMentionService (`config.wakeOnMessage.enabled === true`, default
531
+ // OFF). Until now the only way to set it for a BYO seat was a DB write.
532
+ ...(wakeOnMessage ? { wakeOnMessage: { enabled: true } } : {}),
509
533
  },
510
534
  scopes: ['context:read', 'messages:write', 'memory:read', 'memory:write'],
511
535
  });
@@ -1069,6 +1093,7 @@ export const performRun = ({
1069
1093
  && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
1070
1094
  const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
1071
1095
  let delivered = agentPostedItself;
1096
+ let deliveryRefusal = null;
1072
1097
 
1073
1098
  if (event.type === 'agent.ask') {
1074
1099
  if (silentReply) {
@@ -1154,11 +1179,32 @@ export const performRun = ({
1154
1179
  uploadName: `${agentName}-reply-${event._id}.md`,
1155
1180
  log: (line) => log(`[${event.type}] ${line}`),
1156
1181
  });
1157
- delivered = true;
1158
- log(
1159
- `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
1160
- + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
1161
- );
1182
+ if (delivery.refused) {
1183
+ // A run-cap refusal is a successful HTTP request but not a delivery.
1184
+ // Ack it so the kernel does not replay the same text (the server
1185
+ // guidance expressly says not to retry unchanged), while preserving
1186
+ // the refusal and its partial-post count for the seat and event ledger.
1187
+ deliveryRefusal = delivery;
1188
+ const guidance = delivery.guidance || 'The server refused this message; do not retry it unchanged.';
1189
+ const detail = `after ${delivery.messages}/${delivery.attemptedMessages} message${delivery.attemptedMessages === 1 ? '' : 's'}`;
1190
+ log(`[${event.type}] wrapper delivery refused ${detail} (${delivery.reason}): ${guidance}`);
1191
+ onError?.(Object.assign(
1192
+ new Error(`${event.type} wrapper delivery refused ${detail}: ${guidance}`),
1193
+ {
1194
+ code: 'agent_delivery_refused',
1195
+ reason: delivery.reason,
1196
+ eventId: event._id,
1197
+ postedMessages: delivery.messages,
1198
+ attemptedMessages: delivery.attemptedMessages,
1199
+ },
1200
+ ));
1201
+ } else {
1202
+ delivered = true;
1203
+ log(
1204
+ `[${event.type}] posted ${Buffer.byteLength(replyText)} bytes as `
1205
+ + `${delivery.messages} message${delivery.messages === 1 ? '' : 's'} (${delivery.mode})`,
1206
+ );
1207
+ }
1162
1208
  }
1163
1209
  if (result.memorySummary) {
1164
1210
  try {
@@ -1176,6 +1222,21 @@ export const performRun = ({
1176
1222
  // streak. Recording only on completion means a spawn failure that gets
1177
1223
  // redelivered never double-counts toward the cap.
1178
1224
  cascadeGovernor.record(eventPodId, trigger);
1225
+ if (deliveryRefusal) {
1226
+ return {
1227
+ outcome: 'no_action',
1228
+ reason: deliveryRefusal.reason,
1229
+ details: {
1230
+ mode: deliveryRefusal.mode,
1231
+ postedMessages: deliveryRefusal.messages,
1232
+ attemptedMessages: deliveryRefusal.attemptedMessages,
1233
+ ...(typeof deliveryRefusal.consecutive === 'number'
1234
+ ? { consecutive: deliveryRefusal.consecutive }
1235
+ : {}),
1236
+ ...(deliveryRefusal.guidance ? { guidance: deliveryRefusal.guidance } : {}),
1237
+ },
1238
+ };
1239
+ }
1179
1240
  return { outcome: delivered ? 'posted' : 'no_action' };
1180
1241
  };
1181
1242
 
@@ -1724,6 +1785,7 @@ Docs:
1724
1785
  .option('--env <path>', 'Path to environment.json (ADR-008 — sandbox/skills/MCP)')
1725
1786
  .option('--import-memory [path]', 'Import local memory (CLAUDE.md / MEMORY.md / ~/.claude project memory, or a given file/dir) into the agent after attach')
1726
1787
  .option('--yes', 'Skip the import confirmation prompt')
1788
+ .option('--wake-on-message', 'Wake this agent on every message in the pod, not only @mentions (ADR-018 ambient wake; default off). Change later with: commonly agent wake <name> on|off')
1727
1789
  .option('--instance <url>', 'Target Commonly instance')
1728
1790
  .action(async (adapterName, opts) => {
1729
1791
  const instanceUrl = resolveInstanceUrl(opts.instance);
@@ -1744,6 +1806,7 @@ Docs:
1744
1806
  podId: opts.pod,
1745
1807
  displayName: opts.display,
1746
1808
  envPath: envAbsPath,
1809
+ wakeOnMessage: Boolean(opts.wakeOnMessage),
1747
1810
  log: (line) => console.warn(`[attach] ${line}`),
1748
1811
  });
1749
1812
 
@@ -1766,6 +1829,9 @@ Docs:
1766
1829
  if (workspace) {
1767
1830
  console.log(`✓ Workspace: ${workspace.path}${workspace.created ? ' (created)' : ''}`);
1768
1831
  }
1832
+ if (opts.wakeOnMessage) {
1833
+ console.log('✓ Wake-on-message: ON (sees every pod message; answers only when addressed or genuinely useful)');
1834
+ }
1769
1835
 
1770
1836
  // Retention plan Phase C: the agent arrives whole. Import failure is
1771
1837
  // a warning, never an attach failure — the attach already succeeded
@@ -2174,6 +2240,38 @@ Use --local to find the name you'd pass to 'agent run' or 'agent detach'.
2174
2240
  }
2175
2241
  });
2176
2242
 
2243
+ // ── wake (ADR-018 ambient wake toggle) ───────────────────────────────────
2244
+ agent
2245
+ .command('wake <name> <on|off>')
2246
+ .description('Turn wake-on-message on/off for an attached agent (off = @mentions and heartbeats only)')
2247
+ .option('--instance <url>', 'Target Commonly instance')
2248
+ .action(async (name, state, opts) => {
2249
+ const enabled = state === 'on';
2250
+ if (!enabled && state !== 'off') {
2251
+ console.error(`Expected "on" or "off", got "${state}"`);
2252
+ process.exit(1);
2253
+ }
2254
+ const record = loadAgentToken(name);
2255
+ if (!record) {
2256
+ console.error(`No token file for '${name}' — is it attached on this machine? (commonly agent list --local)`);
2257
+ process.exit(1);
2258
+ }
2259
+ const instanceUrl = resolveInstanceUrl(opts.instance || record.instanceUrl);
2260
+ const token = getToken(opts.instance || record.instanceUrl);
2261
+ if (!token) { console.error('Not logged in. Run: commonly login'); process.exit(1); }
2262
+ const client = createClient({ instance: instanceUrl, token });
2263
+ try {
2264
+ const result = await setWakeOnMessage({ client, record, enabled });
2265
+ console.log(`✓ Wake-on-message ${result.enabled ? 'ON' : 'OFF'} for ${result.agentName} in pod ${result.podId}`);
2266
+ console.log(result.enabled
2267
+ ? ' The agent now sees every message in the pod; its runtime decides whether to answer (NO_REPLY otherwise). No restart needed.'
2268
+ : ' The agent now wakes only on @mentions, replies to it, and heartbeats. No restart needed.');
2269
+ } catch (err) {
2270
+ console.error(`Failed: ${err.message}`);
2271
+ process.exit(1);
2272
+ }
2273
+ });
2274
+
2177
2275
  // ── heartbeat ─────────────────────────────────────────────────────────────
2178
2276
  agent
2179
2277
  .command('heartbeat <name>')
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { createInterface } from 'readline';
9
9
  import { login as apiLogin } from '../lib/api.js';
10
- import { saveInstance, LOCAL_URL } from '../lib/config.js';
10
+ import { saveInstance } from '../lib/config.js';
11
11
 
12
12
  const prompt = (rl, question) => new Promise((resolve) => rl.question(question, resolve));
13
13
 
@@ -88,8 +88,8 @@ export const registerWhoami = (program) => {
88
88
  .command('whoami')
89
89
  .description('Show current auth state')
90
90
  .option('--instance <url>', 'Target instance')
91
- .action(async (opts) => {
92
- const { getActiveInstance, listInstances } = await import('../lib/config.js');
91
+ .action(async () => {
92
+ const { listInstances } = await import('../lib/config.js');
93
93
  const instances = listInstances();
94
94
 
95
95
  if (instances.length === 0) {
@@ -66,6 +66,7 @@ import {
66
66
  publicClaudeStateRoot,
67
67
  wrapArgvWithSeatbelt,
68
68
  } from '../sandbox/seatbelt.js';
69
+ import { buildMemoryPreamble } from '../memory-bridge.js';
69
70
 
70
71
  // See codex.js for the rationale on bumping the default + env override.
71
72
  // Keeping both adapters in lockstep so any wrapper agent runtime has the
@@ -78,10 +79,7 @@ const DEFAULT_TIMEOUT_MS = (() => {
78
79
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
79
80
  })();
80
81
 
81
- const buildPrompt = (prompt, memoryLongTerm) => {
82
- if (!memoryLongTerm) return prompt;
83
- return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
84
- };
82
+ const buildPrompt = buildMemoryPreamble;
85
83
 
86
84
  const PUBLIC_SANDBOX_MODES = new Set(['workspace', 'read-only']);
87
85
  const PUBLIC_DENIED_TOOLS = [
@@ -472,7 +470,13 @@ export default {
472
470
  async spawn(prompt, ctx = {}) {
473
471
  const isResume = !!ctx.sessionId;
474
472
  const sessionId = ctx.sessionId || randomUUID();
475
- const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
473
+ // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
474
+ // `null || ''` is `''` — which routed the unreadable case straight into
475
+ // the empty-memory branch and made the whole distinction unreachable from
476
+ // the only two paths that build a real prompt. buildPrompt handles
477
+ // undefined and '' as absence itself; it does not need a guard, it needs
478
+ // the value.
479
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
476
480
  const sessionFlag = isResume ? '--resume' : '--session-id';
477
481
  // Model pin from the ADR-008 environment spec. Absent it, claude picks its
478
482
  // own default — which is how a fleet of ten agents ended up running three
@@ -56,6 +56,7 @@ import {
56
56
  join,
57
57
  resolve as pathResolve,
58
58
  } from 'path';
59
+ import { buildMemoryPreamble } from '../memory-bridge.js';
59
60
 
60
61
  // Default timeout for a single codex spawn (exec mode).
61
62
  //
@@ -81,10 +82,7 @@ const DEFAULT_TIMEOUT_MS = (() => {
81
82
  return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
82
83
  })();
83
84
 
84
- const buildPrompt = (prompt, memoryLongTerm) => {
85
- if (!memoryLongTerm) return prompt;
86
- return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
87
- };
85
+ const buildPrompt = buildMemoryPreamble;
88
86
 
89
87
  // ── MCP wiring — codex consumes MCP servers via `-c mcp_servers.*` overrides ─
90
88
  //
@@ -417,7 +415,13 @@ export default {
417
415
  },
418
416
 
419
417
  async spawn(prompt, ctx = {}) {
420
- const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
418
+ // Passed through UNCOALESCED. `ctx.memoryLongTerm || ''` was here, and
419
+ // `null || ''` is `''` — which routed the unreadable case straight into
420
+ // the empty-memory branch and made the whole distinction unreachable from
421
+ // the only two paths that build a real prompt. buildPrompt handles
422
+ // undefined and '' as absence itself; it does not need a guard, it needs
423
+ // the value.
424
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
421
425
 
422
426
  // Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
423
427
  // so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
package/src/lib/api.js CHANGED
@@ -43,6 +43,12 @@ export const createClient = ({ instance = null, token = null } = {}) => {
43
43
  body: JSON.stringify(body),
44
44
  }).then(handleResponse);
45
45
 
46
+ const patch = (path, body = {}) => fetch(`${baseUrl}${path}`, {
47
+ method: 'PATCH',
48
+ headers: headers(authToken),
49
+ body: JSON.stringify(body),
50
+ }).then(handleResponse);
51
+
46
52
  const del = (path) => fetch(`${baseUrl}${path}`, {
47
53
  method: 'DELETE',
48
54
  headers: headers(authToken),
@@ -70,7 +76,7 @@ export const createClient = ({ instance = null, token = null } = {}) => {
70
76
  };
71
77
 
72
78
  return {
73
- get, post, del, upload, baseUrl,
79
+ get, post, patch, del, upload, baseUrl,
74
80
  };
75
81
  };
76
82
 
package/src/lib/config.js CHANGED
@@ -67,10 +67,19 @@ export const resolveInstance = (identifier = null) => {
67
67
  const looksLikeUrl = /^https?:\/\//i.test(identifier);
68
68
  if (looksLikeUrl) {
69
69
  const normalized = normalizeUrl(identifier);
70
- const urlEntry = Object.entries(config.instances)
71
- .find(([, v]) => normalizeUrl(v.url) === normalized);
72
- if (urlEntry) {
73
- const [key, value] = urlEntry;
70
+ const matches = Object.entries(config.instances)
71
+ .filter(([, v]) => normalizeUrl(v.url) === normalized);
72
+ if (matches.length > 0) {
73
+ // Several keys can share one URL (`dev` and `default` both pointing at
74
+ // api.commonly.me after a re-login under a new key). The first entry in
75
+ // file order is then whichever was saved FIRST — the stale one — and
76
+ // every command that resolves by the token file's instanceUrl
77
+ // (detach, wake) got a 401 from a token the user had already replaced.
78
+ // Prefer the active instance, then the most recently saved.
79
+ const active = matches.find(([key]) => key === config.active);
80
+ const [key, value] = active || matches
81
+ .slice()
82
+ .sort(([, a], [, b]) => String(b.savedAt || '').localeCompare(String(a.savedAt || '')))[0];
74
83
  return { key, ...value };
75
84
  }
76
85
  // Unknown URL — still usable for bootstrapping. Preserve the caller's
@@ -540,16 +540,35 @@ export const splitForChat = (text, { limit = 400 } = {}) => {
540
540
  * fits in one message → post as-is
541
541
  * splits into ≤ maxChunks → post the chunks in order ("two short
542
542
  * messages beat one wall")
543
- * longer than a split answer → it is a document, not a message: upload
544
- * the FULL text as a file and post one
545
- * message the reply's own opening plus
546
- * the file card. Nothing is cut; the file
547
- * holds everything.
543
+ * longer than a split answer → post the FIRST chunk to the channel and
544
+ * continue the rest in a thread under it.
545
+ * one indivisible oversize unit → it is a genuine document: upload the FULL
546
+ * text and post one message the reply's
547
+ * own opening plus the file card.
548
548
  *
549
- * If the upload fails (older server, network), fall back to posting every
550
- * chunk: a message flood is a tone violation, silence or truncation is a
551
- * correctness violation, and the contract itself ranks content above tone
552
- * ("NEVER hit that by cutting content").
549
+ * The thread rung is new (Sam 57691) and it replaces attachment as the answer
550
+ * for PROSE overflow. Before threads existed, a long analysis had nowhere to
551
+ * go but a file, and that was the right workaround. It is now the wrong one:
552
+ * an attachment is un-quotable, un-followable, and an all-or-nothing read,
553
+ * so overflowing into one buries the tail of a reply in a surface nobody can
554
+ * respond to. A thread keeps every word addressable and scopes the read.
555
+ *
556
+ * This is also the layer the rule has to live at. The pod-context cue already
557
+ * tells agents "prose overflow goes in a thread, not an attachment" (#1176),
558
+ * and the cue could not have been obeyed: the model does not choose the
559
+ * delivery mode, THIS FUNCTION does, and it only knew how to attach. A cue
560
+ * that promises what the wrapper contradicts teaches the agent it is failing
561
+ * at something it never controlled.
562
+ *
563
+ * Attachment is kept for the case it was always right for — a single atomic
564
+ * unit over `attachThreshold` (a long fence, an unbreakable run). That is a
565
+ * document by construction, not prose that outgrew a message.
566
+ *
567
+ * If threading fails (older server with no threadRootId support, no id in the
568
+ * response), fall back to attach, then to posting every chunk: a message
569
+ * flood is a tone violation, silence or truncation is a correctness
570
+ * violation, and the contract ranks content above tone ("NEVER hit that by
571
+ * cutting content").
553
572
  */
554
573
  export const deliverChatReply = async ({
555
574
  client,
@@ -563,6 +582,24 @@ export const deliverChatReply = async ({
563
582
  }) => {
564
583
  const messagesPath = `/api/agents/runtime/pods/${podId}/messages`;
565
584
  const chunks = splitForChat(text, { limit });
585
+ // The runtime route uses HTTP 200 for a policy refusal: it is a completed
586
+ // request, but no message was created. Keep that distinction at the client
587
+ // boundary so every delivery mode shares it rather than treating a resolved
588
+ // promise as proof of a post.
589
+ const postMessage = (body) => client.post(messagesPath, body);
590
+ const refused = (response, messages, attemptedMessages) => ({
591
+ mode: 'refused',
592
+ messages,
593
+ attemptedMessages,
594
+ refused: true,
595
+ reason: response.reason || 'message_refused',
596
+ ...(typeof response.guidance === 'string' && response.guidance
597
+ ? { guidance: response.guidance }
598
+ : {}),
599
+ ...(typeof response.consecutive === 'number'
600
+ ? { consecutive: response.consecutive }
601
+ : {}),
602
+ });
566
603
  // An atomic unit (a fenced block, an unbreakable word-run) can exceed the
567
604
  // limit by construction — splitForChat keeps it whole rather than breaking
568
605
  // its rendering. The tone contract's own rule covers it: over ~800 chars of
@@ -571,15 +608,71 @@ export const deliverChatReply = async ({
571
608
  // the gate (found by the fleet's implementation audit, Sharpen msg 53018).
572
609
  const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
573
610
  if (chunks.length <= 1 && !hasIndivisibleOversize) {
574
- await client.post(messagesPath, { content: chunks[0] ?? text });
611
+ const response = await postMessage({ content: chunks[0] ?? text });
612
+ if (response?.refused === true) return refused(response, 0, 1);
575
613
  return { mode: 'single', messages: 1 };
576
614
  }
577
615
  if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
616
+ let messages = 0;
578
617
  for (const chunk of chunks) {
579
618
  // eslint-disable-next-line no-await-in-loop
580
- await client.post(messagesPath, { content: chunk }); // in order, so the reply reads top-down
619
+ const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
620
+ if (response?.refused === true) return refused(response, messages, chunks.length);
621
+ messages += 1;
622
+ }
623
+ return { mode: 'split', messages };
624
+ }
625
+ // PROSE OVERFLOW → THREAD. Only when nothing is indivisibly oversize: a
626
+ // fence too big to split is a document and belongs in the attach rung below.
627
+ if (!hasIndivisibleOversize) {
628
+ // A COUNT, not a flag. The recovery below resumes from here, and a boolean
629
+ // can only distinguish "nothing posted" from "something posted" — it cannot
630
+ // say how much. Fail a continuation at chunk 3 with a boolean and chunks 1
631
+ // and 2 are already in the thread, then get posted again top-level; the
632
+ // reader sees them twice. Getting this wrong is silent, which is also why
633
+ // the attach rung below leads with `chunks[0]`: falling through after a
634
+ // successful headline duplicates the opening line. Two of this suite's
635
+ // existing tests caught that one, and none caught this one, because both
636
+ // fail at the root-id step before any continuation has posted.
637
+ let posted = 0;
638
+ try {
639
+ const rootRes = await postMessage({ content: chunks[0] });
640
+ if (rootRes?.refused === true) return refused(rootRes, 0, chunks.length);
641
+ posted = 1;
642
+ // The runtime route answers `res.json(result)` with the created row on
643
+ // `result.message`. Accept either id field; refuse to guess if neither
644
+ // is present, because a continuation posted with a missing root would
645
+ // silently become another top-level message — the exact flood this rung
646
+ // exists to prevent.
647
+ const rootId = rootRes?.message?.id ?? rootRes?.message?._id ?? rootRes?.id ?? rootRes?._id;
648
+ if (!rootId) throw new Error('no message id in post response — cannot root the thread');
649
+ for (const chunk of chunks.slice(1)) {
650
+ // eslint-disable-next-line no-await-in-loop
651
+ const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
652
+ if (response?.refused === true) return refused(response, posted, chunks.length);
653
+ posted += 1;
654
+ }
655
+ return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
656
+ } catch (err) {
657
+ if (posted > 0) {
658
+ // The opening is already in the room. Post the REMAINDER top-level —
659
+ // never the whole text again. This is the old flood, minus the
660
+ // duplicate, and it is still preferable to attaching: content ranks
661
+ // above tone, and the reader would otherwise see the same paragraph
662
+ // twice with the rest hidden in a file.
663
+ log(`thread continuation failed (${err.message}) — posting the remainder top-level`);
664
+ for (const chunk of chunks.slice(posted)) {
665
+ // eslint-disable-next-line no-await-in-loop
666
+ const response = await postMessage({ content: chunk });
667
+ if (response?.refused === true) return refused(response, posted, chunks.length);
668
+ posted += 1;
669
+ }
670
+ return { mode: 'thread-fallback', messages: chunks.length };
671
+ }
672
+ // Nothing reached the room, so the attach rung below is free to lead
673
+ // with the opening as it always did.
674
+ log(`thread headline failed (${err.message}) — falling back to attach`);
581
675
  }
582
- return { mode: 'split', messages: chunks.length };
583
676
  }
584
677
  try {
585
678
  const uploaded = await client.upload(`/api/agents/runtime/pods/${podId}/uploads`, {
@@ -596,14 +689,18 @@ export const deliverChatReply = async ({
596
689
  const lead = chunks[0] && chunks[0].length <= limit
597
690
  ? chunks[0]
598
691
  : '(reply too large for chat — attached in full)';
599
- await client.post(messagesPath, { content: `${lead}\n\n${directive}` });
692
+ const response = await postMessage({ content: `${lead}\n\n${directive}` });
693
+ if (response?.refused === true) return refused(response, 0, 1);
600
694
  return { mode: 'attach', messages: 1 };
601
695
  } catch (err) {
602
696
  log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
697
+ let messages = 0;
603
698
  for (const chunk of chunks) {
604
699
  // eslint-disable-next-line no-await-in-loop
605
- await client.post(messagesPath, { content: chunk });
700
+ const response = await postMessage({ content: chunk });
701
+ if (response?.refused === true) return refused(response, messages, chunks.length);
702
+ messages += 1;
606
703
  }
607
- return { mode: 'split-fallback', messages: chunks.length };
704
+ return { mode: 'split-fallback', messages };
608
705
  }
609
706
  };
@@ -18,7 +18,7 @@ import {
18
18
  readFile, mkdir, lstat, cp, rm, chmod, readdir, writeFile,
19
19
  } from 'fs/promises';
20
20
  import { existsSync } from 'fs';
21
- import { dirname, isAbsolute, join, resolve as pathResolve, basename } from 'path';
21
+ import { isAbsolute, join, resolve as pathResolve, basename } from 'path';
22
22
  import { homedir } from 'os';
23
23
 
24
24
  // ── Schema — keep the allow-list narrow; ADR-008 §invariants #1+#2 ──────────
@@ -21,20 +21,68 @@
21
21
 
22
22
  export const SOURCE_RUNTIME = 'local-cli';
23
23
 
24
+ /**
25
+ * The turn preamble, shared by every adapter so the two cannot drift.
26
+ *
27
+ * Non-empty memory is prepended verbatim — that path is load-bearing and is
28
+ * not touched here.
29
+ *
30
+ * The EMPTY case used to return the bare prompt, which made two very different
31
+ * states byte-identical from inside the session: an agent that has never saved
32
+ * anything, and an agent on a runtime with no memory bridge at all. A seat
33
+ * cannot adopt a habit whose surface it has no evidence exists, so the empty
34
+ * case now says so.
35
+ *
36
+ * It names `long_term` specifically because that is the ONLY section read back
37
+ * (`readLongTerm` returns `sections.long_term.content`). A write to `daily` —
38
+ * the section whose name most invites exactly this use — succeeds, returns 200,
39
+ * and is never seen again; the write and the silence are indistinguishable from
40
+ * a correct round trip. Naming the section is the whole point of the cue.
41
+ */
42
+ export const buildMemoryPreamble = (prompt, memoryLongTerm) => {
43
+ // `null` is the UNREADABLE signal, and it is deliberately not the same value
44
+ // as `''`. Telling a seat whose token was revoked that "nothing has ever been
45
+ // saved here" is a false claim about its own history, and it is the same
46
+ // defect this cue exists to fix — one state over. When we could not read, say
47
+ // that, and say nothing about what is stored.
48
+ if (memoryLongTerm === null) {
49
+ return `=== Context (your persistent memory) ===\n`
50
+ + `(unreadable this turn — the memory read failed, so this says NOTHING `
51
+ + `about what you have saved. Do not treat it as empty and do not re-save `
52
+ + `state you may already hold.)\n`
53
+ + `=== Current turn ===\n${prompt}`;
54
+ }
55
+ if (memoryLongTerm) {
56
+ return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
57
+ }
58
+ return `=== Context (your persistent memory) ===\n`
59
+ + `(empty — nothing has ever been saved here)\n`
60
+ + `Only the \`long_term\` section is read back into this prompt. To make `
61
+ + `something survive your next session, call commonly_save_my_memory({ `
62
+ + `section: 'long_term', content: '...' }). A write to any other section `
63
+ + `succeeds and is never shown to you again.\n`
64
+ + `=== Current turn ===\n${prompt}`;
65
+ };
66
+
24
67
  export const readLongTerm = async (client, { onError } = {}) => {
25
68
  try {
26
69
  const body = await client.get('/api/agents/runtime/memory');
27
70
  return body?.sections?.long_term?.content || '';
28
71
  } catch (err) {
29
- // A fresh agent has no memory row yet treat as empty rather than fail
30
- // the spawn. The kernel upserts on first write. For anything OTHER than
31
- // a 404 (auth revoked, backend down, network out), surface via onError
32
- // so the user sees "something's wrong with memory" instead of a silently
33
- // context-less agent.
34
- if (err?.status && err.status !== 404) {
35
- onError?.(err);
36
- }
37
- return '';
72
+ // A 404 is the ONLY error that means what '' means: a fresh agent with no
73
+ // memory row yet. The kernel upserts on first write, so this is genuine
74
+ // absence and the empty cue is true.
75
+ if (err?.status === 404) return '';
76
+ // Everything else — auth revoked, 500, connection refused — is a failure to
77
+ // READ, which tells us nothing about what is stored. Returning '' here made
78
+ // the caller assert emptiness on no evidence.
79
+ //
80
+ // `err.status` is undefined for a transport failure, so the old guard
81
+ // (`err?.status && err.status !== 404`) skipped onError precisely when the
82
+ // backend was unreachable: the loudest condition was the silent one, and
83
+ // nothing contradicted the false cue.
84
+ onError?.(err);
85
+ return null;
38
86
  }
39
87
  };
40
88