@commonlyai/cli 0.1.18 → 0.1.23
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 +4 -2
- package/src/commands/agent.js +103 -5
- package/src/commands/login.js +3 -3
- package/src/lib/adapters/claude.js +9 -5
- package/src/lib/adapters/codex.js +9 -5
- package/src/lib/api.js +7 -1
- package/src/lib/enforcement.js +112 -15
- package/src/lib/environment.js +1 -1
- package/src/lib/memory-bridge.js +57 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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": {
|
package/src/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
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>')
|
package/src/commands/login.js
CHANGED
|
@@ -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
|
|
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 (
|
|
92
|
-
const {
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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/enforcement.js
CHANGED
|
@@ -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 →
|
|
544
|
-
* the
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
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
|
-
*
|
|
550
|
-
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
704
|
+
return { mode: 'split-fallback', messages };
|
|
608
705
|
}
|
|
609
706
|
};
|
package/src/lib/environment.js
CHANGED
|
@@ -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 {
|
|
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 ──────────
|
package/src/lib/memory-bridge.js
CHANGED
|
@@ -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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|