@commonlyai/cli 0.1.29 → 0.1.31

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.29",
3
+ "version": "0.1.31",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -223,6 +223,11 @@ const PROMPT_EVENT_TYPES = new Set([
223
223
  'first_contact',
224
224
  ]);
225
225
 
226
+ // Consultations have a per-request private response channel, rather than a
227
+ // pod-chat delivery. They stay on their existing single-event path; every
228
+ // event that represents the shared pod inbox is batched below.
229
+ const PRIVATE_RESPONSE_EVENT_TYPES = new Set(['agent.ask', 'agent.ask.response']);
230
+
226
231
  // ── default environment for adapters that benefit from auto-MCP wiring ─────
227
232
 
228
233
  // Adapters that can consume `mcp[]` from the resolved environment spec.
@@ -746,6 +751,10 @@ export const performRun = ({
746
751
  chatCharLimit = 400,
747
752
  maxChatChunks = 3,
748
753
  claimYieldDelayMs = 3000,
754
+ // ADR-024 D3: a poll is an inbox batch, not ten independent interrupts.
755
+ // Kept injectable for small-page regression cases; the runtime ships with
756
+ // the server's ordinary ten-event page.
757
+ inboxBatchLimit = 10,
749
758
  sleepImpl = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
750
759
  }) => {
751
760
  const client = createClient({ instance: instanceUrl, token });
@@ -793,6 +802,7 @@ export const performRun = ({
793
802
  // workspace path replaces the /tmp default.
794
803
  const agentCwd = workspacePath || join(tmpdir(), 'commonly-agents', agentName);
795
804
  if (!existsSync(agentCwd)) mkdirSync(agentCwd, { recursive: true });
805
+ const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
796
806
 
797
807
  const processEvent = async (event) => {
798
808
  const eventPodId = event.podId || podId;
@@ -1102,7 +1112,7 @@ export const performRun = ({
1102
1112
  }
1103
1113
  }
1104
1114
  }
1105
- const heartbeatControlReply = event.type === 'heartbeat'
1115
+ const heartbeatControlReply = (event.type === 'heartbeat' || event.payload?.hasHeartbeat === true)
1106
1116
  && /^(HEARTBEAT_OK|HEARTBEAT_NOOP)$/i.test(replyText);
1107
1117
  const silentReply = !replyText || replyText === 'NO_REPLY' || heartbeatControlReply;
1108
1118
  let delivered = agentPostedItself;
@@ -1253,38 +1263,322 @@ export const performRun = ({
1253
1263
  return { outcome: delivered ? 'posted' : 'no_action' };
1254
1264
  };
1255
1265
 
1266
+ // ADR-024 D3: the batch header states the real inbox count even when we cap
1267
+ // copied bodies. The cap is the fetched page itself: never acknowledge a
1268
+ // claimed event whose body the one turn did not receive. Additional pending
1269
+ // events stay in the kernel queue and are cued in the header for a later
1270
+ // pull, rather than being silently consumed here.
1271
+ const buildInboxPrompt = (entries, inboxCount = entries.length) => {
1272
+ const BODY_LIMIT = batchLimit;
1273
+ const bodies = entries.slice(0, BODY_LIMIT).map((entry, index) => {
1274
+ const messageId = entry.event.payload?.messageId;
1275
+ const prefix = [
1276
+ `Event ${index + 1}/${entries.length}: ${entry.event.type}`,
1277
+ messageId ? `message ${messageId}` : null,
1278
+ ].filter(Boolean).join(' — ');
1279
+ const claimContext = entry.readOnly
1280
+ ? `${peerHoldsFrame(entry.holder, messageId)}\n`
1281
+ + '[This item is read-only context: a peer won its binding claim. Do not act on it.]\n'
1282
+ : (entry.peerFrame ? `${entry.peerFrame}\n` : '');
1283
+ const body = entry.prompt || '[No usable prompt was delivered for this item.]';
1284
+ return `${prefix}\n${claimContext}${body}`;
1285
+ });
1286
+ const omitted = Math.max(0, inboxCount - bodies.length);
1287
+ return [
1288
+ `[Inbox batch: ${inboxCount} new event${inboxCount === 1 ? '' : 's'} in this pod. `
1289
+ + 'This is one turn: read the included items together, then decide what needs YOU.]',
1290
+ ...bodies,
1291
+ ...(omitted > 0
1292
+ ? [`[${omitted} more event${omitted === 1 ? '' : 's'} arrived. Their count is real; pull pod context if needed.]`]
1293
+ : []),
1294
+ 'Do not narrate the inbox. Post only an action or a materially useful response; otherwise return NO_REPLY.',
1295
+ ].join('\n\n');
1296
+ };
1297
+
1298
+ const batchAdmissionResult = (event, admission) => ({
1299
+ outcome: 'no_action',
1300
+ reason: 'cascade-cap',
1301
+ details: {
1302
+ messageId: event?.payload?.messageId || null,
1303
+ streak: admission.streak,
1304
+ cap: cascadeSettings.cap,
1305
+ addressedGrace: cascadeSettings.addressedGrace,
1306
+ resetMs: cascadeSettings.resetMs,
1307
+ addressed: admission.addressed,
1308
+ graceApplied: admission.graceApplied,
1309
+ },
1310
+ });
1311
+
1312
+ // Claim the binding portion before the ONE composite spawn. A lost binding
1313
+ // claim is not discarded: it remains in the model's context with the same
1314
+ // peer frame the single-event path already uses, but is explicitly read-only.
1315
+ // Advisory losses retain their existing peer-aware behaviour.
1316
+ const processEventBatch = async (events, inboxCount = events.length) => {
1317
+ const eventPodId = events[0]?.podId || podId;
1318
+ if (!eventPodId || events.some((event) => (event.podId || podId) !== eventPodId)) {
1319
+ throw new Error('Inbox batch crossed pod boundaries; refusing to mix private pod context');
1320
+ }
1321
+
1322
+ const entries = events.map((event) => ({
1323
+ event,
1324
+ prompt: extractPrompt(event),
1325
+ result: null,
1326
+ claimKeeper: null,
1327
+ peerFrame: null,
1328
+ readOnly: false,
1329
+ holder: null,
1330
+ }));
1331
+
1332
+ // Duplicate deliveries have already completed locally. Re-ack them but do
1333
+ // not spend the new batch turn displaying old work as if it were new.
1334
+ const seenEventIds = new Set();
1335
+ for (const entry of entries) {
1336
+ const eventId = String(entry.event._id);
1337
+ if (seenEventIds.has(eventId) || wasEventHandled(agentName, entry.event._id)) {
1338
+ entry.result = { outcome: 'no_action', reason: 'duplicate-delivery' };
1339
+ log(`[${entry.event.type}] duplicate delivery ${entry.event._id} — re-acking without batch spawn`);
1340
+ } else {
1341
+ seenEventIds.add(eventId);
1342
+ }
1343
+ if (entry.result?.reason === 'duplicate-delivery') {
1344
+ continue;
1345
+ }
1346
+ if (!entry.prompt) {
1347
+ entry.result = { outcome: 'no_action', reason: 'no-prompt' };
1348
+ log(`[${entry.event.type}] no prompt — no-op`);
1349
+ onError?.(Object.assign(
1350
+ new Error(
1351
+ `${entry.event.type} event ${entry.event._id} was acked WITHOUT a spawn: `
1352
+ + 'payload carried no content and no messageId. If this wake mattered, its producer is sending an unusable payload.',
1353
+ ),
1354
+ { code: 'agent_event_skipped_no_prompt', eventId: entry.event._id },
1355
+ ));
1356
+ }
1357
+ }
1358
+
1359
+ const snapshotMessages = async () => {
1360
+ try {
1361
+ const { messages = [] } = await client.get(
1362
+ `/api/agents/runtime/pods/${eventPodId}/messages`, { limit: 10 },
1363
+ );
1364
+ return messages;
1365
+ } catch {
1366
+ return null;
1367
+ }
1368
+ };
1369
+ const preSpawn = await snapshotMessages();
1370
+ const preSpawnIds = preSpawn
1371
+ ? new Set(preSpawn.map((message) => String(message._id || message.id)))
1372
+ : null;
1373
+ const activeEntries = entries.filter((entry) => !entry.result);
1374
+ const triggers = activeEntries.map((entry) => classifyTrigger(entry.event, preSpawn));
1375
+ const trigger = triggers.includes('human') ? 'human'
1376
+ : (triggers.includes('agent') ? 'agent' : 'unknown');
1377
+ if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
1378
+ const admissionEvent = activeEntries.find((entry) => ADDRESSED_EVENT_TYPES.has(entry.event.type))
1379
+ || activeEntries[0];
1380
+ if (admissionEvent) {
1381
+ const admission = cascadeGovernor.admit(
1382
+ eventPodId,
1383
+ trigger,
1384
+ admissionEvent.event.type,
1385
+ admissionEvent.event.payload,
1386
+ );
1387
+ if (!admission.allowed) {
1388
+ for (const entry of activeEntries) {
1389
+ entry.result = batchAdmissionResult(entry.event, admission);
1390
+ }
1391
+ return { entries };
1392
+ }
1393
+ }
1394
+
1395
+ const bindingEntries = activeEntries.filter((entry) => (
1396
+ entry.event.type === 'message.posted' && entry.event.payload?.messageId
1397
+ ));
1398
+ if (bindingEntries.length > 0) {
1399
+ const yieldMs = claimHandicap.yieldDelayMs(eventPodId);
1400
+ if (yieldMs > 0) {
1401
+ log(`[inbox.batch] yielding ${yieldMs}ms before claiming ${bindingEntries.length} binding event${bindingEntries.length === 1 ? '' : 's'}`);
1402
+ await sleepImpl(yieldMs);
1403
+ }
1404
+ }
1405
+
1406
+ for (const entry of activeEntries) {
1407
+ const messageId = entry.event.payload?.messageId;
1408
+ if (!messageId || !CLAIMABLE_EVENT_TYPES.has(entry.event.type)) continue;
1409
+ const claimKeeper = createClaimKeeper(client, {
1410
+ messageId,
1411
+ podId: eventPodId,
1412
+ leaseSeconds: claimLeaseSeconds,
1413
+ log: (line) => log(`[${entry.event.type}] ${line}`),
1414
+ setIntervalImpl,
1415
+ clearIntervalImpl,
1416
+ });
1417
+ const claim = await claimKeeper.acquire();
1418
+ if (claim.claimed) {
1419
+ entry.claimKeeper = claimKeeper;
1420
+ claimKeeper.startRenewal();
1421
+ if (entry.event.type === 'message.posted') claimHandicap.recordWin(eventPodId);
1422
+ } else if (!claim.failOpen) {
1423
+ entry.holder = claim.holder;
1424
+ if (entry.event.type === 'message.posted') {
1425
+ // A reply to this seat's own message is direct-address evidence,
1426
+ // even though its transport type is the broadcast-shaped
1427
+ // `message.posted`. Preserve the existing ADR-018/TASK-058
1428
+ // peer-aware exception; D3a otherwise partitions lost bindings as
1429
+ // read-only so a generic broadcast cannot widen itself after CAS.
1430
+ if (entry.event.payload?.repliesToYourMessage === true) {
1431
+ entry.peerFrame = peerHoldsFrame(claim.holder, messageId);
1432
+ continue;
1433
+ }
1434
+ // D3a's partition: no later model judgement can widen this item's
1435
+ // authority after a peer won the binding CAS.
1436
+ entry.readOnly = true;
1437
+ entry.result = { outcome: 'no_action', reason: 'claim-held' };
1438
+ claimHandicap.recordLoss(eventPodId);
1439
+ } else {
1440
+ entry.peerFrame = peerHoldsFrame(claim.holder, messageId);
1441
+ }
1442
+ } else {
1443
+ log(`[${entry.event.type}] claim unavailable (${claim.error?.message || 'unknown error'}) — proceeding unguarded`);
1444
+ }
1445
+ }
1446
+
1447
+ const actionEntries = activeEntries.filter((entry) => !entry.result);
1448
+ if (actionEntries.length === 0) return { entries };
1449
+
1450
+ const claimKeepers = actionEntries
1451
+ .map((entry) => entry.claimKeeper)
1452
+ .filter(Boolean);
1453
+ const compositeClaimKeeper = claimKeepers.length > 0 ? {
1454
+ isLost: () => claimKeepers.some((keeper) => keeper.isLost()),
1455
+ getHolder: () => claimKeepers.find((keeper) => keeper.isLost())?.getHolder(),
1456
+ } : null;
1457
+ const batchEvent = {
1458
+ ...actionEntries[0].event,
1459
+ _id: `batch-${actionEntries[0].event._id}`,
1460
+ type: 'inbox.batch',
1461
+ payload: {
1462
+ ...actionEntries[0].event.payload,
1463
+ batchEventIds: entries.map((entry) => String(entry.event._id)),
1464
+ hasHeartbeat: entries.some((entry) => entry.event.type === 'heartbeat'),
1465
+ },
1466
+ };
1467
+ let turnResult;
1468
+ try {
1469
+ turnResult = await runTurn({
1470
+ event: batchEvent,
1471
+ eventPodId,
1472
+ prompt: buildInboxPrompt(entries, inboxCount),
1473
+ preSpawnIds,
1474
+ snapshotMessages,
1475
+ claimKeeper: compositeClaimKeeper,
1476
+ trigger,
1477
+ });
1478
+ for (const entry of actionEntries) entry.result = turnResult;
1479
+ return { entries };
1480
+ } finally {
1481
+ await Promise.all(actionEntries
1482
+ .filter((entry) => entry.claimKeeper)
1483
+ .map(async (entry) => {
1484
+ // Preserve ADR-018 D6.1 per binding message: a silent human
1485
+ // broadcast may be handed to one remaining listener, while every
1486
+ // other completed batch item closes normally.
1487
+ const claimOutcome = entry.event.type === 'message.posted'
1488
+ && entry.event.payload?.senderIsHuman === true
1489
+ && turnResult?.outcome === 'no_action' && !turnResult?.reason
1490
+ ? 'declined'
1491
+ : (turnResult ? 'completed' : undefined);
1492
+ await entry.claimKeeper.release(claimOutcome);
1493
+ }));
1494
+ }
1495
+ };
1496
+
1256
1497
  const tick = async () => {
1257
1498
  if (!running) return;
1258
1499
  let nextPollDelayMs = intervalMs;
1259
1500
  try {
1260
- // ONE event per fetch, because the fetch IS the claim.
1261
- //
1262
- // `AgentEventService.list()` does not hand back a preview it marks
1263
- // every candidate `delivered` with `$inc: { attempts: 1 }` before
1264
- // returning. This loop then processes them SERIALLY, one full model
1265
- // turn each. So asking for 10 claims 10 and starts 1.
1266
- //
1267
- // The nine it cannot start are then reclaimed out from under it: the
1268
- // backend requeues `delivered` rows older than
1269
- // `requeueDeliveredMinutes` (default 10, swept on `*/10`), and turns
1270
- // routinely outlast that — measured on the pod-architect seat over
1271
- // 11.5h: median 128s, p90 669s, 13 turns over 600s, max 1153s. Each
1272
- // sweep returns the untouched siblings to `pending` at `attempts + 1`,
1273
- // and `attempts >= 3` retires an event to `failed`, which is terminal
1274
- // and invisible to `list()`. That cap exists to bound POISON events;
1275
- // over-claiming feeds it work no model ever saw, so a mention can be
1276
- // dropped without once being read.
1277
- //
1278
- // `limit: 1` costs nothing: capacity here is one turn at a time
1279
- // regardless, and the poll interval is 5s. It only stops the loop
1280
- // claiming work it has no way to begin.
1281
- const { events = [] } = await client.get('/api/agents/runtime/events', {
1282
- agentName, instanceId, limit: 1,
1501
+ // ADR-024 D3: this is an inbox fetch, not a stream of interrupts. The
1502
+ // backend returns one pod's oldest pending batch, so every delivered row
1503
+ // below reaches the SAME composite turn before it can age into the
1504
+ // requeue sweep. The service selects the pod before claiming any row;
1505
+ // do not reintroduce a mixed-pod `limit: 10` here.
1506
+ const { events = [], inboxCount } = await client.get('/api/agents/runtime/events', {
1507
+ agentName, instanceId, limit: batchLimit,
1283
1508
  });
1284
1509
  consecutiveAuthErrors = 0;
1285
1510
  consecutivePollFailures = 0;
1511
+ // A deployed CLI can briefly run against an older server that still
1512
+ // returns multiple pods in one page. Preserve pod privacy in that
1513
+ // migration window by partitioning locally. Current servers produce one
1514
+ // group, so the normal path remains one tick → one turn.
1515
+ const eventGroups = new Map();
1286
1516
  for (const event of events) {
1517
+ const eventPodId = event.podId || podId;
1518
+ const key = PRIVATE_RESPONSE_EVENT_TYPES.has(event.type)
1519
+ ? `private:${event._id}`
1520
+ : (eventPodId || `private:${event._id}`);
1521
+ const group = eventGroups.get(key) || [];
1522
+ group.push(event);
1523
+ eventGroups.set(key, group);
1524
+ }
1525
+ for (const group of eventGroups.values()) {
1287
1526
  if (!running) break;
1527
+ if ((group[0].podId || podId) && !PRIVATE_RESPONSE_EVENT_TYPES.has(group[0].type)) {
1528
+ let batch;
1529
+ try {
1530
+ batch = await processEventBatch(group, inboxCount);
1531
+ } catch (err) {
1532
+ consecutiveSpawnFailures += 1;
1533
+ const retry = spawnRetryPolicy({
1534
+ error: err,
1535
+ consecutiveFailures: consecutiveSpawnFailures,
1536
+ intervalMs,
1537
+ jitterRatio: spawnJitterRatio,
1538
+ });
1539
+ nextPollDelayMs = retry.delayMs;
1540
+ const event = group[0];
1541
+ const wrapped = new Error(
1542
+ `inbox batch processing failed (${retry.failureClass}; ${consecutiveSpawnFailures} consecutive) `
1543
+ + `— ${group.length} events remain unacked; ${retry.circuitOpen ? 'circuit open' : 'retry scheduled'}, `
1544
+ + `next probe in ${formatRetryDelay(retry.delayMs)}: ${err.message}`,
1545
+ { cause: err },
1546
+ );
1547
+ Object.assign(wrapped, {
1548
+ code: 'agent_spawn_retry_scheduled',
1549
+ failureClass: retry.failureClass,
1550
+ consecutiveFailures: consecutiveSpawnFailures,
1551
+ retryAfterMs: retry.delayMs,
1552
+ circuitOpen: retry.circuitOpen,
1553
+ eventId: event._id,
1554
+ });
1555
+ if (onError) onError(wrapped);
1556
+ else log(`[inbox.batch] ${wrapped.message}`);
1557
+ break;
1558
+ }
1559
+ const batchSpawned = batch.entries.some((entry) => (
1560
+ entry.result?.outcome === 'posted'
1561
+ || (entry.result?.outcome === 'no_action' && !entry.result?.reason)
1562
+ ));
1563
+ if (batchSpawned) consecutiveSpawnFailures = 0;
1564
+ for (const entry of batch.entries) {
1565
+ const event = entry.event;
1566
+ if (entry.result?.reason !== 'duplicate-delivery') {
1567
+ recordHandledEvent(agentName, event._id);
1568
+ }
1569
+ try {
1570
+ const deliveryId = event.payload?.deliveryId;
1571
+ await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
1572
+ result: entry.result,
1573
+ ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
1574
+ });
1575
+ } catch (ackErr) {
1576
+ onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
1577
+ }
1578
+ }
1579
+ continue;
1580
+ }
1581
+ const [event] = group;
1288
1582
  let result;
1289
1583
  if (wasEventHandled(agentName, event._id)) {
1290
1584
  result = { outcome: 'no_action', reason: 'duplicate-delivery' };
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Contract: ADR-005 §Adapter pattern.
5
5
  *
6
- * Memory preamble: if ctx.memoryLongTerm is non-empty, the adapter prepends
7
- * it to the prompt as a system-context preamble (§Memory bridge).
6
+ * Memory preamble: the adapter prepends the kernel's long-term memory context
7
+ * on every turn. A fresh underlying session additionally receives the
8
+ * read-first and durable-state-at-boundary cues (§Memory bridge).
8
9
  *
9
10
  * Environment (ADR-008 Phase 1): if ctx.environment is present, the adapter
10
11
  * symlinks declared Claude skills into `<cwd>/.claude/skills/`, writes an MCP
@@ -476,7 +477,7 @@ export default {
476
477
  // the only two paths that build a real prompt. buildPrompt handles
477
478
  // undefined and '' as absence itself; it does not need a guard, it needs
478
479
  // the value.
479
- const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
480
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: !isResume });
480
481
  const sessionFlag = isResume ? '--resume' : '--session-id';
481
482
  // Model pin from the ADR-008 environment spec. Absent it, claude picks its
482
483
  // own default — which is how a fleet of ten agents ended up running three
@@ -552,7 +553,11 @@ export default {
552
553
  // session id poisons every subsequent event re-delivery.
553
554
  if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
554
555
  const freshId = randomUUID();
555
- const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
556
+ // The retry creates a new underlying CLI session. Rebuild its prompt
557
+ // as fresh too: otherwise a session-recovery path is the one fresh
558
+ // session that misses the durable-state cue.
559
+ const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true });
560
+ const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
556
561
  const retry = await prepareArgv(retryBase, {
557
562
  ...ctx,
558
563
  mcpConfigPath: mcpConfig?.file || null,
@@ -20,10 +20,10 @@
20
20
  * `--output-last-message` short alias) — cleaner than parsing every
21
21
  * event-type variant the model can emit.
22
22
  *
23
- * Memory preamble: if ctx.memoryLongTerm is non-empty, the adapter prepends
24
- * it to the prompt as a system-context preamble (§Memory bridge), matching
25
- * the claude adapter's shape so the run loop's per-event memory plumbing
26
- * works identically across drivers.
23
+ * Memory preamble: the adapter prepends the kernel's long-term memory context
24
+ * on every turn. A fresh underlying session additionally receives the
25
+ * read-first and durable-state-at-boundary cues, matching the Claude adapter
26
+ * so the run loop's memory plumbing works identically across drivers.
27
27
  *
28
28
  * Purity (§Load-bearing invariants #1): input = argv + env + prompt;
29
29
  * output = text + session id. No direct network, no direct CAP calls.
@@ -421,7 +421,9 @@ export default {
421
421
  // the only two paths that build a real prompt. buildPrompt handles
422
422
  // undefined and '' as absence itself; it does not need a guard, it needs
423
423
  // the value.
424
- const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm);
424
+ const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm, {
425
+ freshSession: !ctx.sessionId,
426
+ });
425
427
 
426
428
  // Per-spawn temp dir for --output-last-message. Cleaned up in `finally`
427
429
  // so a crash in the middle of the spawn doesn't leak files in $TMPDIR.
@@ -39,29 +39,54 @@ export const SOURCE_RUNTIME = 'local-cli';
39
39
  * and is never seen again; the write and the silence are indistinguishable from
40
40
  * a correct round trip. Naming the section is the whole point of the cue.
41
41
  */
42
- export const buildMemoryPreamble = (prompt, memoryLongTerm) => {
42
+ export const buildMemoryPreamble = (prompt, memoryLongTerm, { freshSession = false } = {}) => {
43
43
  // `null` is the UNREADABLE signal, and it is deliberately not the same value
44
44
  // as `''`. Telling a seat whose token was revoked that "nothing has ever been
45
45
  // saved here" is a false claim about its own history, and it is the same
46
46
  // defect this cue exists to fix — one state over. When we could not read, say
47
47
  // that, and say nothing about what is stored.
48
+ let context;
48
49
  if (memoryLongTerm === null) {
49
- return `=== Context (your persistent memory) ===\n`
50
+ context = `=== Context (your persistent memory) ===\n`
50
51
  + `(unreadable this turn — the memory read failed, so this says NOTHING `
51
52
  + `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}`;
53
+ + `state you may already hold.)`;
54
+ } else if (memoryLongTerm) {
55
+ context = `=== Context (your persistent memory) ===\n${memoryLongTerm}`;
56
+ } else {
57
+ context = `=== Context (your persistent memory) ===\n`
58
+ + `(empty — nothing has ever been saved here)\n`
59
+ + `Only the \`long_term\` section is read back into this prompt. To make `
60
+ + `something survive your next session, call commonly_save_my_memory({ `
61
+ + `section: 'long_term', content: '...' }). A write to any other section `
62
+ + `succeeds and is never shown to you again.`;
54
63
  }
55
- if (memoryLongTerm) {
56
- return `=== Context (your persistent memory) ===\n${memoryLongTerm}\n=== Current turn ===\n${prompt}`;
64
+
65
+ if (!freshSession) return `${context}\n=== Current turn ===\n${prompt}`;
66
+
67
+ // This belongs on a fresh underlying CLI session, rather than every turn:
68
+ // a resumed session already carries the earlier instruction in its own
69
+ // transcript. Repeating it on each wake spends prompt budget while making
70
+ // the cue easier to ignore. The wrapper cannot know the final event of a
71
+ // session in advance, so it gives the end-of-session reminder while the
72
+ // agent can still act on it.
73
+ const freshReadCue = '=== Fresh session ===\n'
74
+ + 'This is a fresh session. Read the persistent memory context above before acting; '
75
+ + 'it carries durable state from prior sessions.\n';
76
+ let sessionEndCue = '=== Before this session ends ===\n';
77
+ if (memoryLongTerm === null) {
78
+ sessionEndCue += 'Memory was unreadable on this fresh session. Do not treat it as empty or '
79
+ + 'write a replacement based on this cue.';
80
+ } else if (memoryLongTerm) {
81
+ sessionEndCue += 'At a natural end to meaningful work, save durable working state — '
82
+ + 'gates held, decisions pending, and task context, not a transcript — with '
83
+ + "commonly_save_my_memory({ section: 'long_term', content: '...' }).";
84
+ } else {
85
+ sessionEndCue += 'At a natural end to meaningful work, save durable working state — '
86
+ + 'gates held, decisions pending, and task context, not a transcript — using '
87
+ + 'the long_term write above.';
57
88
  }
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}`;
89
+ return `${context}\n${freshReadCue}\n=== Current turn ===\n${prompt}\n${sessionEndCue}`;
65
90
  };
66
91
 
67
92
  export const readLongTerm = async (client, { onError } = {}) => {