@twinsearth/w2m-dsh-plugin 1.2.0 → 1.2.2

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Data flow across the fleet: map results that are too big to return inline travel as blobs, and the reduce step names
3
+ * exactly which blob it read.
4
+ *
5
+ * ## Why a plan instead of a pipe
6
+ *
7
+ * Machines do not send data to each other directly: the relay is the only channel every machine can reach, and it already
8
+ * stores content-addressed blobs. So the shape is map -> PUT blob -> reduce by sha256. The decisions worth testing are the
9
+ * boundaries: when an output is inline, when it becomes a blob, and what happens when a blob a reduce depends on is not
10
+ * there. A pipeline that silently drops a shard is worse than one that fails.
11
+ */
12
+
13
+ /** Stage stdout at or below this travels inline in the result envelope. */
14
+ export const MAX_INLINE_BYTES = 64 * 1024;
15
+
16
+ /** Past this a stage output is not a result any more: it is a data problem the operator has to see. */
17
+ export const HARD_CAP_BYTES = 16 * 1024 * 1024;
18
+
19
+ /**
20
+ * Decide how one machine's stage output travels.
21
+ *
22
+ * @param {{machineId?: string, stageIndex?: number, stdoutBytes?: number}} input
23
+ * @returns {{ok: true, mode: 'inline'|'blob', stdoutBytes: number} | {ok: false, code: string, detail: object}}
24
+ */
25
+ export function planStageOutput(input = {}) {
26
+ const machineId = typeof input.machineId === 'string' && input.machineId !== '' ? input.machineId : null;
27
+ if (machineId === null) return { ok: false, code: 'STAGE_MACHINE_MISSING', detail: {} };
28
+ const stdoutBytes = Number.isFinite(input.stdoutBytes) ? Math.max(0, Math.floor(input.stdoutBytes)) : 0;
29
+ if (stdoutBytes > HARD_CAP_BYTES) {
30
+ return { ok: false, code: 'STAGE_OUTPUT_TOO_LARGE', detail: { machineId, stdoutBytes, hardCapBytes: HARD_CAP_BYTES } };
31
+ }
32
+ return { ok: true, mode: stdoutBytes <= MAX_INLINE_BYTES ? 'inline' : 'blob', stdoutBytes };
33
+ }
34
+
35
+ const SHA256 = /^[0-9a-f]{64}$/;
36
+
37
+ /**
38
+ * Build the reduce input list from the map results.
39
+ *
40
+ * Every map machine must contribute exactly one entry: inline text is carried as-is, a blob result must name a sha256.
41
+ * A missing or malformed hash is refused by name -- the reduce step must never run on a partial fleet and call the answer
42
+ * complete.
43
+ *
44
+ * @param {{results?: Array<{machine_id?: string, mode?: string, stdout?: string, blob_sha256?: string}>,
45
+ * reducerMachineId?: string}} input
46
+ * @returns {{ok: true, reducerMachineId: string, inputs: Array<{machine_id: string, mode: 'inline'|'blob', stdout?: string, sha256?: string}>} |
47
+ * {ok: false, code: string, detail: object}}
48
+ */
49
+ export function buildReducePlan(input = {}) {
50
+ const reducer = typeof input.reducerMachineId === 'string' && input.reducerMachineId !== '' ? input.reducerMachineId : null;
51
+ if (reducer === null) return { ok: false, code: 'REDUCER_MISSING', detail: {} };
52
+ const results = Array.isArray(input.results) ? input.results : null;
53
+ if (results === null || results.length === 0) return { ok: false, code: 'NO_MAP_RESULTS', detail: {} };
54
+
55
+ const seen = new Set();
56
+ const inputs = [];
57
+ for (const result of results) {
58
+ const machineId = typeof result?.machine_id === 'string' && result.machine_id !== '' ? result.machine_id : null;
59
+ if (machineId === null) return { ok: false, code: 'MAP_MACHINE_MISSING', detail: {} };
60
+ if (seen.has(machineId)) return { ok: false, code: 'MAP_MACHINE_DUPLICATE', detail: { machineId } };
61
+ seen.add(machineId);
62
+ const mode = result?.mode === 'blob' ? 'blob' : 'inline';
63
+ if (mode === 'blob') {
64
+ const sha = typeof result?.blob_sha256 === 'string' ? result.blob_sha256.toLowerCase() : '';
65
+ if (!SHA256.test(sha)) return { ok: false, code: 'BLOB_MISSING', detail: { machineId, sha256: result?.blob_sha256 ?? null } };
66
+ inputs.push({ machine_id: machineId, mode, sha256: sha });
67
+ } else {
68
+ inputs.push({ machine_id: machineId, mode, stdout: typeof result?.stdout === 'string' ? result.stdout : '' });
69
+ }
70
+ }
71
+ return { ok: true, reducerMachineId: reducer, inputs };
72
+ }
@@ -102,6 +102,36 @@ export function registerChatRoutes(ctx, deps) {
102
102
  };
103
103
  return resolved;
104
104
  };
105
+
106
+ /**
107
+ * The machine ids this fleet knows, cached for five seconds.
108
+ *
109
+ * Only the HOST can check a single recipient: the relay validates the shape of a recipient, not whether a machine
110
+ * with that id exists. Null means the roster could not be read at all, which is deliberately a different answer from
111
+ * "no such machine" -- a relay outage must not be reported as a typo in the recipient.
112
+ */
113
+ let rosterAtMs = 0;
114
+ let rosterIds = null;
115
+ const knownMachineIds = async (signal) => {
116
+ if (rosterIds !== null && Date.now() - rosterAtMs < 5000) return rosterIds;
117
+ try {
118
+ const cfg = deps.cfg();
119
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
120
+ if (rabbitUrl === null) return null;
121
+ const token = await deps.resolveToken();
122
+ if (typeof token !== 'string' || token === '') return null;
123
+ const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/devices', signal });
124
+ if (listed?.ok !== true) return null;
125
+ const devices = Array.isArray(listed.json?.devices) ? listed.json.devices : null;
126
+ if (devices === null) return null;
127
+ const ids = devices.map((device) => device?.machine_id).filter((id) => typeof id === 'string');
128
+ rosterIds = ids;
129
+ rosterAtMs = Date.now();
130
+ return ids;
131
+ } catch {
132
+ return null;
133
+ }
134
+ };
105
135
 
106
136
  /** Post one batch into the relay using the machine's device token, exactly as the browser route does. */
107
137
  const postToRelay = async (identity, payload) => {
@@ -165,6 +195,7 @@ export function registerChatRoutes(ctx, deps) {
165
195
  // Its key is the relay alone -- deliberately NOT the project name -- so every machine in the fleet
166
196
  // lands in one stream. It is not a history fetch: the caller sends a floor timestamp (see the panel).
167
197
  let sessionId = '';
198
+ const addressing = {};
168
199
  let text = null;
169
200
  if (request.method === 'GET') {
170
201
  sessionId = sessionIdOf(url.searchParams.get('session_id'));
@@ -177,6 +208,17 @@ export function registerChatRoutes(ctx, deps) {
177
208
  }
178
209
  sessionId = sessionIdOf(body?.session_id);
179
210
  text = typeof body?.text === 'string' ? body.text : null;
211
+ // Addressing + correlation travel as optional fields; each is validated here so a bad one is refused with a
212
+ // name instead of being stored and never understood.
213
+ if (typeof body?.to === 'string' && body.to.trim() !== '') {
214
+ const to = body.to.trim();
215
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) {
216
+ return refuse(400, 'CHAT_BAD_ADDRESS', 'to must be "all" or a machine id', 'a recipient is either the whole fleet or one machine');
217
+ }
218
+ addressing.to = to;
219
+ }
220
+ if (typeof body?.thread_id === 'string' && body.thread_id.trim() !== '') addressing.thread_id = body.thread_id.trim().slice(0, 64);
221
+ if (typeof body?.reply_to === 'string' && body.reply_to.trim() !== '') addressing.reply_to = body.reply_to.trim();
180
222
  }
181
223
  const identity = await identityOf();
182
224
  if (identity.error !== null) {
@@ -203,7 +245,11 @@ export function registerChatRoutes(ctx, deps) {
203
245
 
204
246
  try {
205
247
  if (request.method === 'GET') {
206
- const query = { session_id: sessionId };
248
+ const query = { session_id: sessionId, for_me: '1' };
249
+ const threadOf = url.searchParams.get('thread_id');
250
+ const toOf = url.searchParams.get('to');
251
+ if (threadOf !== null && threadOf !== '') query.thread_id = threadOf;
252
+ if (toOf !== null && toOf !== '') query.to = toOf;
207
253
  const sinceMs = url.searchParams.get('since_ms');
208
254
  const limit = url.searchParams.get('limit');
209
255
  if (sinceMs !== null && /^\d+$/.test(sinceMs)) query.since_ms = sinceMs;
@@ -216,7 +262,17 @@ export function registerChatRoutes(ctx, deps) {
216
262
  }
217
263
  return Response.json({ ...listed.json, ok: true, local_machine_id: identity.machineId, local_machine_name: identity.machineName, conversation_key: sessionId, conversation_source: identity.source }, { headers: NO_STORE });
218
264
  }
219
- const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '', machine_id: identity.machineId, machine_name: identity.machineName, origin: 'chat' }, signal: request.signal });
265
+ // Addressing (v1.2.2): a single recipient must exist in THIS fleet.
266
+ if (typeof addressing.to === 'string' && addressing.to !== 'all') {
267
+ const roster = await knownMachineIds(request.signal);
268
+ if (roster === null) {
269
+ return refuse(503, 'CHAT_ROSTER_UNAVAILABLE', 'the machine roster could not be read', 'a recipient cannot be checked without the relay roster');
270
+ }
271
+ if (!roster.includes(addressing.to)) {
272
+ return refuse(400, 'CHAT_UNKNOWN_RECIPIENT', 'no machine with that id is in this fleet', 'send all, or a machine_id listed by w2m_devices');
273
+ }
274
+ }
275
+ const posted = await deps.request({ rabbitUrl, token, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: text ?? '', machine_id: identity.machineId, machine_name: identity.machineName, origin: 'chat', ...addressing }, signal: request.signal });
220
276
  if (posted?.ok !== true) {
221
277
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
222
278
  const message = posted?.json?.error?.message ?? posted?.error?.message ?? `the relay answered HTTP ${posted?.status ?? 0}`;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Structured intents: the deterministic half of "natural language becomes an action".
3
+ *
4
+ * ## What this is not
5
+ *
6
+ * It does not understand free text. A machine in this fleet runs an allow-list of commands; asking an LLM on the remote
7
+ * side to invent a command would defeat the allow-list and put an unbounded decision on a machine nobody is sitting at.
8
+ * So the caller (the DSH agent, which IS the model) turns language into one of these intents, and this module decides
9
+ * whether that intent maps to an allowed command. Anything unrecognised is REFUSED by name, never guessed.
10
+ *
11
+ * ## Why a catalog instead of string templates from the caller
12
+ *
13
+ * A caller that supplies its own argv is `w2m_run` with extra steps. The value here is the closed set: each action has a
14
+ * fixed argv shape, typed parameters, and a risk level, so an intent cannot smuggle an argument that the allow-list was
15
+ * meant to stop (a path, a flag that loads code, a shell metacharacter).
16
+ */
17
+
18
+ /** The closed set. `argv(params)` receives validated parameters only. */
19
+ export const INTENTS = {
20
+ 'run-tests': {
21
+ risk: 'read',
22
+ description: 'Run the project test suite: node --test',
23
+ params: {},
24
+ argv: () => ['node', '--test'],
25
+ },
26
+ 'node-version': {
27
+ risk: 'read',
28
+ description: 'Report the Node version on the machine: node --version',
29
+ params: {},
30
+ argv: () => ['node', '--version'],
31
+ },
32
+ 'git-status': {
33
+ risk: 'read',
34
+ description: 'Show the working tree status: git status --porcelain',
35
+ params: {},
36
+ argv: () => ['git', 'status', '--porcelain'],
37
+ },
38
+ 'git-head': {
39
+ risk: 'read',
40
+ description: 'Report the commit HEAD points at: git rev-parse HEAD',
41
+ params: {},
42
+ argv: () => ['git', 'rev-parse', 'HEAD'],
43
+ },
44
+ 'fleet-facts': {
45
+ risk: 'read',
46
+ description: 'Run the demo fact collector: node src/facts.mjs',
47
+ params: {},
48
+ argv: () => ['node', 'src/facts.mjs'],
49
+ },
50
+ 'shard-probe': {
51
+ risk: 'read',
52
+ description: 'Run the demo shard probe with one non-negative integer: node src/shard.mjs <n>',
53
+ params: { n: 'non-negative integer' },
54
+ argv: (params) => ['node', 'src/shard.mjs', String(params.n)],
55
+ },
56
+ };
57
+
58
+ /** The catalog as data, for a tool/README to list without duplicating it. */
59
+ export function listIntents() {
60
+ return Object.entries(INTENTS).map(([action, spec]) => ({
61
+ action,
62
+ risk: spec.risk,
63
+ description: spec.description,
64
+ params: spec.params,
65
+ }));
66
+ }
67
+
68
+ /** A single argument must be a plain token: no spaces, no shell metacharacters, no path separators. */
69
+ function tokenIsSafe(value) {
70
+ return typeof value === 'string' && value !== '' && value.length <= 128 && !/[\s;&|$`<>\\/"']/.test(value);
71
+ }
72
+
73
+ /** Render an argv the way the allow-list matcher compares it (space separated), for the refusal message. */
74
+ function renderArgv(argv) {
75
+ return argv.join(' ');
76
+ }
77
+
78
+ /**
79
+ * Map one structured intent onto an allow-listed argv.
80
+ *
81
+ * @param {{action?: string, params?: object}} intent
82
+ * @param {{allowedCommands?: string[]}} [options] the machine's allow-list, as the agent sees it
83
+ * @returns {{ok: true, action: string, argv: string[], risk: string, command: string} |
84
+ * {ok: false, code: string, detail?: object, hint: string}}
85
+ */
86
+ export function resolveIntent(intent, options = {}) {
87
+ const action = typeof intent?.action === 'string' ? intent.action.trim() : '';
88
+ if (action === '') {
89
+ return { ok: false, code: 'INTENT_MISSING', hint: 'pass {"action": "<one of w2m_intent list>", "params": {...}}' };
90
+ }
91
+ const spec = INTENTS[action];
92
+ if (spec === undefined) {
93
+ return {
94
+ ok: false,
95
+ code: 'INTENT_UNKNOWN',
96
+ detail: { action, known: Object.keys(INTENTS) },
97
+ hint: 'this fleet does not invent commands: pick one of the catalog actions',
98
+ };
99
+ }
100
+
101
+ const params = intent?.params ?? {};
102
+ if (params === null || typeof params !== 'object' || Array.isArray(params)) {
103
+ return { ok: false, code: 'INTENT_PARAM_INVALID', detail: { action }, hint: 'params must be an object' };
104
+ }
105
+ const expected = Object.keys(spec.params);
106
+ for (const key of Object.keys(params)) {
107
+ if (!expected.includes(key)) {
108
+ return { ok: false, code: 'INTENT_PARAM_UNKNOWN', detail: { action, key, expected }, hint: 'unknown parameter for this action' };
109
+ }
110
+ }
111
+ if (action === 'shard-probe') {
112
+ const n = params.n;
113
+ if (!Number.isInteger(n) || n < 0) {
114
+ return { ok: false, code: 'INTENT_PARAM_INVALID', detail: { action, param: 'n', expected: 'non-negative integer' }, hint: 'n must be a non-negative integer' };
115
+ }
116
+ }
117
+ // Only CALLER-SUPPLIED values are shape-checked: the fixed argv comes from this catalog, where a path separator is
118
+ // normal. A metacharacter in a caller value is how a command becomes something else.
119
+ for (const key of Object.keys(params)) {
120
+ const value = params[key];
121
+ if (typeof value === 'string' && !tokenIsSafe(value)) {
122
+ return { ok: false, code: 'INTENT_ARG_UNSAFE', detail: { action, parameter: key }, hint: 'a parameter may not contain spaces, quotes or shell metacharacters' };
123
+ }
124
+ }
125
+ const argv = spec.argv(params);
126
+ const command = renderArgv(argv);
127
+ const allowed = Array.isArray(options.allowedCommands) ? options.allowedCommands : null;
128
+ if (allowed !== null && !allowed.includes(command)) {
129
+ return {
130
+ ok: false,
131
+ code: 'INTENT_NOT_ALLOWED',
132
+ detail: { action, command, allowedCommands: allowed },
133
+ hint: 'the machine allow-list does not contain this command; ask the operator to add it',
134
+ };
135
+ }
136
+ return { ok: true, action, argv, risk: spec.risk, command };
137
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * A readable view over memory records.
3
+ *
4
+ * The memory log is JSONL rows of mixed kinds (finished task, conclusion, accepted block). Reading it as a model means
5
+ * reading what it will answer with; reading it as a person means reading what happened, in order, with the counts that
6
+ * answer "have we done this before". Both are here, and the view never invents a field a record does not carry: an unknown
7
+ * row shape is shown as itself rather than dropped, because a memory that silently hides rows is worse than a noisy one.
8
+ */
9
+
10
+ /** The fields that make one row readable, in the order a person scans them. */
11
+ const SUMMARY_FIELDS = ['status', 'exit_code', 'command', 'conclusion', 'kind'];
12
+
13
+ function textOf(value) {
14
+ if (typeof value === 'string') return value;
15
+ if (value === null || value === undefined) return '';
16
+ try { return JSON.stringify(value); } catch { return String(value); }
17
+ }
18
+
19
+ /** One line per record: time, machine, then the first informative field. */
20
+ function lineOf(record, index) {
21
+ const at = typeof record?.at === 'string' ? record.at : (Number.isFinite(record?.at_ms) ? new Date(record.at_ms).toISOString() : null);
22
+ const machine = typeof record?.machine_name === 'string' && record.machine_name !== ''
23
+ ? record.machine_name
24
+ : (typeof record?.machine_id === 'string' ? record.machine_id.slice(0, 8) : 'unknown');
25
+ const field = SUMMARY_FIELDS.find((name) => record?.[name] !== undefined && record[name] !== null) ?? null;
26
+ const detail = field === null ? '' : ' ' + field + '=' + textOf(record[field]);
27
+ const command = typeof record?.command === 'string' ? ' ' + record.command : '';
28
+ const head = at === null ? '(no time)' : at.replace('T', ' ').slice(0, 19);
29
+ const raw = head + ' ' + machine + detail + command;
30
+ return { index, text: raw.length > 160 ? raw.slice(0, 157) + '...' : raw, machine, at };
31
+ }
32
+
33
+ /**
34
+ * @param {Array<object>} records newest first, as the memory reader returns them
35
+ * @param {{limit?: number}} [options]
36
+ * @returns {{count: number, shown: number, truncated: boolean, by_machine: object, by_status: object, lines: object[]}}
37
+ */
38
+ export function renderMemoryView(records, options = {}) {
39
+ const list = Array.isArray(records) ? records : [];
40
+ const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 50;
41
+ const byMachine = {};
42
+ const byStatus = {};
43
+ for (const record of list) {
44
+ const machine = typeof record?.machine_name === 'string' && record.machine_name !== ''
45
+ ? record.machine_name
46
+ : (typeof record?.machine_id === 'string' ? record.machine_id.slice(0, 8) : 'unknown');
47
+ byMachine[machine] = (byMachine[machine] ?? 0) + 1;
48
+ const status = typeof record?.status === 'string' ? record.status : (typeof record?.kind === 'string' ? record.kind : 'unknown');
49
+ byStatus[status] = (byStatus[status] ?? 0) + 1;
50
+ }
51
+ const shown = list.slice(0, limit);
52
+ return {
53
+ count: list.length,
54
+ shown: shown.length,
55
+ truncated: list.length > shown.length,
56
+ by_machine: byMachine,
57
+ by_status: byStatus,
58
+ lines: shown.map(lineOf),
59
+ };
60
+ }
@@ -183,6 +183,9 @@ import {
183
183
  // v1.1.17 chat: the host route behind the composer's fleet-chat panel. The relay side and the
184
184
  // `w2m_chat` tool shipped first; this is the browser-facing half that was still missing.
185
185
  import { registerChatRoutes } from './fleet-chat-route.mjs';
186
+ import { createFleetCouncil, fleetCouncilThread } from './council-fleet.mjs';
187
+ import { renderMemoryView } from './memory-view.mjs';
188
+ import { listIntents, resolveIntent } from './intents.mjs';
186
189
  // v0.7.0: the LAN hint layer. Imported here and used in exactly one place (`w2m_devices`); see the
187
190
  // module header for why a discovered device is a candidate to *ask* and never an authorisation.
188
191
  import { createDiscoverySource } from './mdns-discover.mjs';
@@ -220,7 +223,7 @@ const MAX_STAGES = 16;
220
223
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
221
224
  * overrides it at runtime for tests and packagers.
222
225
  */
223
- const PLUGIN_VERSION = '1.2.0';
226
+ const PLUGIN_VERSION = '1.2.2';
224
227
 
225
228
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
226
229
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
@@ -3390,6 +3393,10 @@ export async function apply(ctx, config = {}) {
3390
3393
  */
3391
3394
  const councilSessions = createCouncilSessions();
3392
3395
 
3396
+ /** v1.2.2: the machine-wide council. Ballots arrive as chat messages, so this is state + arithmetic; the transport is
3397
+ * the fleet chat itself (thread `council-<id>`), which already carries `to` / `thread_id` / `reply_to` since 1.2.1. */
3398
+ const fleetCouncil = createFleetCouncil({});
3399
+
3393
3400
  // v0.6.1: the two browser routes behind the composer's target control. Additive and optional: a
3394
3401
  // host without `connection.fetch` still gets every tool, and `w2m_status` reports the absence - // which is the honest place for it, since a host that has no Connection at all is simply not a web
3395
3402
  // composition and does not need a warning on every headless run.
@@ -5234,7 +5241,7 @@ export async function apply(ctx, config = {}) {
5234
5241
  */
5235
5242
  action: {
5236
5243
  type: 'string',
5237
- enum: ['read', 'publish', 'pull', 'latest', 'diff', 'merge'],
5244
+ enum: ['read', 'publish', 'pull', 'latest', 'diff', 'merge', 'append'],
5238
5245
  description:
5239
5246
  'read (default) = answer from memory. publish = upload the curated collective memory '
5240
5247
  + '(`fleet-memory.md`) to the relay as a content-addressed blob and return its SHA-256. latest = ask the '
@@ -5252,6 +5259,19 @@ export async function apply(ctx, config = {}) {
5252
5259
  + 'relay but belongs to another fleet is reported as not found, exactly like a hash that was never '
5253
5260
  + 'stored -- a distinguishable refusal would confirm to a prober which hashes this relay holds.',
5254
5261
  },
5262
+ records: {
5263
+ type: 'array',
5264
+ items: { type: 'string' },
5265
+ description:
5266
+ 'For action="append": one append-only record per string. Appending is the unit of change, so two machines ' +
5267
+ 'can add records without one overwriting the other document.',
5268
+ },
5269
+ if_revision: {
5270
+ type: 'number',
5271
+ description:
5272
+ 'For action="append": the revision you read. A stale value is refused with 409 and the records you have not ' +
5273
+ 'read, because applying it on top of something you never saw is a silent overwrite.',
5274
+ },
5255
5275
  scope: {
5256
5276
  type: 'string',
5257
5277
  enum: ['local', 'fleet'],
@@ -5340,6 +5360,64 @@ export async function apply(ctx, config = {}) {
5340
5360
  async execute(args, exec) {
5341
5361
  throwIfAborted(exec?.signal);
5342
5362
 
5363
+ /**
5364
+ * v1.2.2: append records to the shared memory under compare-and-swap.
5365
+ *
5366
+ * A 409 is returned AS-IS, with the revision and what the caller has not read. Retrying here would turn a visible
5367
+ * conflict into a silent overwrite, which is the whole reason the relay keeps a revision instead of merging.
5368
+ */
5369
+ if (typeof args?.action === 'string' && args.action === 'append') {
5370
+ const doc = typeof args?.scope === 'string' && args.scope.trim() !== '' ? args.scope.trim() : 'fleet';
5371
+ const incoming = Array.isArray(args?.records)
5372
+ ? args.records
5373
+ : (typeof args?.records === 'string' ? [args.records] : []);
5374
+ const records = incoming.filter((entry) => typeof entry === 'string' && entry.trim() !== '');
5375
+ if (records.length === 0) {
5376
+ throw new W2MError('W2M_MEMORY_APPEND', 'W2M_MEMORY_APPEND: records is required for append', {
5377
+ hint: 'pass records: ["a fact the fleet should keep", ...]',
5378
+ });
5379
+ }
5380
+ const appendUrl = cfg.rabbitUrl ?? null;
5381
+ if (appendUrl === null) {
5382
+ throw new W2MError('W2M_NO_RELAY', 'W2M_NO_RELAY: no relay is configured, so shared memory cannot be written', {
5383
+ hint: 'set rabbitUrl in the profile patch',
5384
+ });
5385
+ }
5386
+ const appendToken = await resolveToken(cfg);
5387
+ if (appendToken === null) {
5388
+ throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so shared memory cannot be written', {
5389
+ hint: 'pair this machine first, then retry',
5390
+ });
5391
+ }
5392
+ const appendBody = { records };
5393
+ if (Number.isInteger(args?.if_revision)) appendBody.if_revision = args.if_revision;
5394
+ const appended = await request({
5395
+ rabbitUrl: appendUrl,
5396
+ token: appendToken,
5397
+ method: 'PUT',
5398
+ pathname: '/v1/fleet/memory/' + encodeURIComponent(doc),
5399
+ body: appendBody,
5400
+ signal: exec?.signal,
5401
+ });
5402
+ if (appended?.ok !== true) {
5403
+ return JSON.stringify({
5404
+ ok: false,
5405
+ action: 'append',
5406
+ doc,
5407
+ status: appended?.status ?? 0,
5408
+ code: appended?.json?.error?.code ?? appended?.error?.code ?? 'W2M_MEMORY_APPEND',
5409
+ detail: appended?.json?.detail ?? null,
5410
+ });
5411
+ }
5412
+ return JSON.stringify({
5413
+ ok: true,
5414
+ action: 'append',
5415
+ doc,
5416
+ revision: appended.json?.revision ?? null,
5417
+ appended: appended.json?.appended ?? 0,
5418
+ });
5419
+ }
5420
+
5343
5421
  /**
5344
5422
  * v1.0.7 (#1c): publish the collective memory as a content-addressed blob.
5345
5423
  *
@@ -6095,7 +6173,8 @@ export async function apply(ctx, config = {}) {
6095
6173
  skipped_lines: current.skipped,
6096
6174
  truncated: current.truncated,
6097
6175
  status_tally: tally(current.records),
6098
- records: current.records,
6176
+ records: current.records,
6177
+ view: renderMemoryView(current.records),
6099
6178
  ...(previous === null
6100
6179
  ? {}
6101
6180
  : {
@@ -6335,11 +6414,20 @@ export async function apply(ctx, config = {}) {
6335
6414
  'Fleet chat for one session: send plain text to the machines in this conversation, or read what was said. ' +
6336
6415
  'A refused write comes back with its named reason (quota, size, or non-text) rather than as a bare failure.',
6337
6416
  parameters: {
6338
- action: { type: 'string', enum: ['send', 'list'], description: 'send posts text; list reads the flow (default).', default: 'list' },
6417
+ action: {
6418
+ type: 'string',
6419
+ enum: ['send', 'list', 'ask', 'await'],
6420
+ description: 'send posts text; ask posts a question and returns its message id; await waits for a reply to it; list reads the flow (default).',
6421
+ default: 'list',
6422
+ },
6339
6423
  session_id: { type: 'string', description: 'Which conversation. Chat is scoped to a session, not a machine.' },
6340
6424
  text: { type: 'string', description: 'The message for send. Plain text, UTF-8, at most 4 KiB.' },
6341
6425
  since_ms: { type: 'number', description: 'For list: only messages newer than this relay timestamp.' },
6342
6426
  limit: { type: 'number', description: 'For list: how many of the newest messages (default 50).' },
6427
+ to: { type: 'string', description: 'For send/ask: "all" (the fleet group, the default) or one machine_id.' },
6428
+ thread_id: { type: 'string', description: 'For send/ask: group this message into a named thread (at most 64 characters).' },
6429
+ reply_to: { type: 'string', description: 'For send/ask: the message_id this message answers. For await: the message_id to wait for a reply to.' },
6430
+ timeout_ms: { type: 'number', description: 'For await: how long to wait before reporting a timeout (default 60000, capped at 300000). A timeout is a value, not an error.' },
6343
6431
  },
6344
6432
  output: jsonOutput,
6345
6433
  presentCall: (args) => ({ card: 'generic', title: 'w2m chat ' + (typeof args?.action === 'string' ? args.action : 'list'), kind: args?.action === 'send' ? 'write' : 'read', rawInput: {} }),
@@ -6356,14 +6444,46 @@ export async function apply(ctx, config = {}) {
6356
6444
  // one reported W2M_NO_TOKEN on a machine that was paired and answering every other call.
6357
6445
  const token = await resolveToken(cfg);
6358
6446
  if (token === null) throw new W2MError('W2M_NO_TOKEN', 'W2M_NO_TOKEN: this machine has no device token, so chat cannot be authenticated', { hint: 'pair this machine first (autoStartAgent or a pairing code), then retry' });
6359
- if (action === 'send') {
6360
- const posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: typeof args?.text === 'string' ? args.text : '' } });
6447
+ // Addressing + correlation travel only when the caller supplied them, so a plain broadcast stays byte-identical.
6448
+ const addressing = {};
6449
+ if (typeof args?.to === 'string' && args.to.trim() !== '') {
6450
+ const to = args.to.trim();
6451
+ if (to !== 'all' && (to.length > 64 || /\s/.test(to))) {
6452
+ throw new W2MError('W2M_CHAT_ADDRESS', 'W2M_CHAT_ADDRESS: to must be "all" or a machine id', { hint: 'a recipient is either the whole fleet or one machine listed by w2m_devices' });
6453
+ }
6454
+ addressing.to = to;
6455
+ }
6456
+ if (typeof args?.thread_id === 'string' && args.thread_id.trim() !== '') addressing.thread_id = args.thread_id.trim().slice(0, 64);
6457
+ if (typeof args?.reply_to === 'string' && args.reply_to.trim() !== '') addressing.reply_to = args.reply_to.trim();
6458
+
6459
+ if (action === 'await') {
6460
+ // Waiting for an ANSWER is a state machine, and a timeout is a value the caller reads rather than an exception
6461
+ // that loses the question. The question stays in the flow, so it can still be answered later.
6462
+ const target = typeof args?.reply_to === 'string' ? args.reply_to.trim() : '';
6463
+ if (target === '') throw new W2MError('W2M_CHAT_AWAIT', 'W2M_CHAT_AWAIT: reply_to is required for await', { hint: 'pass the message_id returned by ask' });
6464
+ const timeoutMs = Number.isFinite(args?.timeout_ms) ? Math.max(1000, Math.min(args.timeout_ms, 300000)) : 60000;
6465
+ const startedAt = Date.now();
6466
+ for (;;) {
6467
+ throwIfAborted(exec?.signal);
6468
+ const reply = await request({ rabbitUrl, token, signal: exec?.signal, method: 'GET', pathname: '/v1/fleet/messages', query: { session_id: sessionId, reply_to: target, limit: '10' } });
6469
+ const found = Array.isArray(reply?.json?.messages) ? reply.json.messages : [];
6470
+ if (found.length > 0) return JSON.stringify({ ok: true, action, state: 'answered', reply_to: target, replies: found });
6471
+ if (Date.now() - startedAt >= timeoutMs) {
6472
+ return JSON.stringify({ ok: true, action, state: 'timeout', reply_to: target, waited_ms: Date.now() - startedAt, hint: 'the question is still in the flow and can be answered later' });
6473
+ }
6474
+ await new Promise((resolve) => { setTimeout(resolve, 1500); });
6475
+ }
6476
+ }
6477
+ if (action === 'send' || action === 'ask') {
6478
+ const posted = await request({ rabbitUrl, token, signal: exec?.signal, method: 'POST', pathname: '/v1/fleet/messages', body: { session_id: sessionId, text: typeof args?.text === 'string' ? args.text : '', ...addressing } });
6361
6479
  if (posted?.ok !== true) {
6362
6480
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'W2M_CHAT_SEND';
6363
6481
  throw new W2MError('W2M_CHAT_SEND', code + ': the message was refused', { reason_code: code, hint: 'quota, size and non-text each need a different answer' });
6364
6482
  }
6365
- return JSON.stringify({ ok: true, action, message: posted.json?.message ?? null });
6483
+ return JSON.stringify({ ok: true, action, message: posted.json?.message ?? null, message_id: posted.json?.message?.message_id ?? null });
6366
6484
  }
6485
+ if (typeof args?.thread_id === 'string' && args.thread_id.trim() !== '') query.thread_id = args.thread_id.trim();
6486
+ if (typeof args?.to === 'string' && args.to.trim() !== '') query.to = args.to.trim();
6367
6487
  const query = { session_id: sessionId };
6368
6488
  if (Number.isFinite(args?.since_ms)) query.since_ms = String(args.since_ms);
6369
6489
  if (Number.isInteger(args?.limit)) query.limit = String(args.limit);
@@ -6373,6 +6493,40 @@ export async function apply(ctx, config = {}) {
6373
6493
  },
6374
6494
  }));
6375
6495
 
6496
+ /**
6497
+ * v1.2.2: structured intents -- the deterministic half of "say it in words, run it on the machine".
6498
+ *
6499
+ * The caller is the model, and it turns language into one of a CLOSED set of intents; this tool maps that intent to an
6500
+ * allow-listed argv or refuses it by name. Free text is deliberately not interpreted here: a remote machine runs an
6501
+ * allow-list, and a model inventing a command on its behalf is exactly what the list exists to stop.
6502
+ */
6503
+ ctx.tools.register(defineTool({
6504
+ name: 'w2m_intent',
6505
+ description:
6506
+ 'Map a structured intent to an allow-listed command. Actions: run-tests, node-version, git-status, git-head, ' +
6507
+ 'fleet-facts, shard-probe. Unknown actions and unknown parameters are refused by name rather than guessed, and a ' +
6508
+ 'command the machine allow-list does not carry is refused with the list it checked.',
6509
+ parameters: {
6510
+ action: { type: 'string', description: 'The catalog action; call with action:"list" to read the catalog.' },
6511
+ params: { type: 'object', description: 'Action parameters, for example { n: 3 } for shard-probe.' },
6512
+ },
6513
+ output: jsonOutput,
6514
+ presentCall: (args) => ({ card: 'generic', title: 'w2m intent ' + (typeof args?.action === 'string' ? args.action : '?'), kind: 'read', rawInput: {} }),
6515
+ async execute(args, exec) {
6516
+ throwIfAborted(exec?.signal);
6517
+ const action = typeof args?.action === 'string' ? args.action.trim() : '';
6518
+ if (action === '' || action === 'list') {
6519
+ return JSON.stringify({ ok: true, action: 'list', intents: listIntents() });
6520
+ }
6521
+ const resolved = resolveIntent({ action, params: args?.params }, { allowedCommands: cfg.allowedCommands ?? null });
6522
+ if (resolved.ok !== true) {
6523
+ return JSON.stringify({ ok: false, action, code: resolved.code, detail: resolved.detail ?? null, hint: resolved.hint });
6524
+ }
6525
+ return JSON.stringify({ ok: true, action, argv: resolved.argv, command: resolved.command, risk: resolved.risk });
6526
+ },
6527
+ }));
6528
+
6529
+
6376
6530
  /**
6377
6531
  * v0.37.0: the eleventh tool -- a question put to a council, and the outcome read back with its dissent.
6378
6532
  *
@@ -6402,7 +6556,7 @@ export async function apply(ctx, config = {}) {
6402
6556
  parameters: {
6403
6557
  action: {
6404
6558
  type: 'string',
6405
- enum: ['open', 'advance', 'assign', 'argue', 'vote', 'decide', 'record', 'state', 'decision', 'list'],
6559
+ enum: ['open', 'advance', 'assign', 'argue', 'vote', 'decide', 'record', 'state', 'decision', 'list', 'fleet-open', 'fleet-vote', 'fleet-decide', 'fleet-state', 'fleet-list'],
6406
6560
  description: 'What to do. "open" starts a council, "state" reports one mid-flight, "decision" reads a sealed one back with its dissent.',
6407
6561
  default: 'state',
6408
6562
  },
@@ -6433,6 +6587,37 @@ export async function apply(ctx, config = {}) {
6433
6587
  const action = typeof args?.action === 'string' ? args.action : 'state';
6434
6588
  const id = typeof args?.council_id === 'string' ? args.council_id : null;
6435
6589
 
6590
+ // v1.2.2: the FLEET council. Same decision rule as the single-machine one (reproducible weights, a tie is a
6591
+ // refusal); the difference is transport -- ballots are chat messages on `council-<id>`, so every machine can read
6592
+ // the same ballots and recount to the same outcome.
6593
+ if (typeof action === 'string' && action.startsWith('fleet-')) {
6594
+ const threadId = fleetCouncilThread(id);
6595
+ if (action === 'fleet-open') {
6596
+ const opened = fleetCouncil.open({ councilId: id, question: typeof args?.question === 'string' ? args.question : '', openedByMachineId: 'local' });
6597
+ return JSON.stringify({ ok: opened.ok, action, council_id: id, thread_id: threadId, reason_code: opened.code ?? null, council: opened.council ?? null });
6598
+ }
6599
+ if (action === 'fleet-vote') {
6600
+ const voted = fleetCouncil.vote({
6601
+ councilId: id,
6602
+ machineId: typeof args?.participant === 'string' && args.participant !== '' ? args.participant : 'local',
6603
+ choice: args?.choice,
6604
+ reason: args?.reason ?? null,
6605
+ authority: args?.authority ?? null,
6606
+ reputation: args?.reputation ?? null,
6607
+ });
6608
+ return JSON.stringify({ ok: voted.ok, action, council_id: id, thread_id: threadId, reason_code: voted.code ?? null, council: voted.council ?? null });
6609
+ }
6610
+ if (action === 'fleet-decide') {
6611
+ const decided = fleetCouncil.decide({ councilId: id });
6612
+ return JSON.stringify({ ok: decided.ok, action, council_id: id, thread_id: threadId, reason_code: decided.code ?? null, detail: decided.detail ?? null, council: decided.council ?? null });
6613
+ }
6614
+ if (action === 'fleet-list') {
6615
+ return JSON.stringify({ ok: true, action, ...fleetCouncil.list() });
6616
+ }
6617
+ const read = fleetCouncil.state({ councilId: id });
6618
+ return JSON.stringify({ ok: read.ok, action, council_id: id, thread_id: threadId, reason_code: read.code ?? null, council: read.council ?? null });
6619
+ }
6620
+
6436
6621
  if (action === 'list') {
6437
6622
  return JSON.stringify({ ok: true, action, ...councilSessions.ids(), phases: PHASES, roles: [...ROLES] });
6438
6623
  }