@twinsearth/w2m-dsh-plugin 1.2.1 → 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) => {
@@ -232,6 +262,16 @@ export function registerChatRoutes(ctx, deps) {
232
262
  }
233
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 });
234
264
  }
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
+ }
235
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 });
236
276
  if (posted?.ok !== true) {
237
277
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
@@ -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.1';
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
  : {
@@ -6414,6 +6493,40 @@ export async function apply(ctx, config = {}) {
6414
6493
  },
6415
6494
  }));
6416
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
+
6417
6530
  /**
6418
6531
  * v0.37.0: the eleventh tool -- a question put to a council, and the outcome read back with its dissent.
6419
6532
  *
@@ -6443,7 +6556,7 @@ export async function apply(ctx, config = {}) {
6443
6556
  parameters: {
6444
6557
  action: {
6445
6558
  type: 'string',
6446
- 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'],
6447
6560
  description: 'What to do. "open" starts a council, "state" reports one mid-flight, "decision" reads a sealed one back with its dissent.',
6448
6561
  default: 'state',
6449
6562
  },
@@ -6474,6 +6587,37 @@ export async function apply(ctx, config = {}) {
6474
6587
  const action = typeof args?.action === 'string' ? args.action : 'state';
6475
6588
  const id = typeof args?.council_id === 'string' ? args.council_id : null;
6476
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
+
6477
6621
  if (action === 'list') {
6478
6622
  return JSON.stringify({ ok: true, action, ...councilSessions.ids(), phases: PHASES, roles: [...ROLES] });
6479
6623
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared fleet memory: an append-only record log with compare-and-swap.
3
+ *
4
+ * ## What this is for
5
+ *
6
+ * The fleet memory was read/publish only. Two machines writing meant last-writer-wins over a whole document, so a record
7
+ * added by one could vanish because the other had an older copy. Here the unit of change is a RECORD and the guard is a
8
+ * revision number: a writer says which revision it saw, and a stale one is refused with the current revision and a diff
9
+ * summary instead of being applied on top of something it never read.
10
+ *
11
+ * ## Refusals are values
12
+ *
13
+ * `REVISION_CONFLICT` is a normal answer, not an error: the caller re-reads, decides, and retries. Automatic retry is
14
+ * deliberately NOT implemented here -- retrying in the transport would turn a visible conflict into a silent overwrite.
15
+ */
16
+ export class FleetMemoryConflict extends Error {}
17
+
18
+ /**
19
+ * @param {{now?: () => number, maxRecords?: number}} [options]
20
+ */
21
+ export function createFleetMemory(options = {}) {
22
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
23
+ const maxRecords = Number.isInteger(options.maxRecords) && options.maxRecords > 0 ? options.maxRecords : 10_000;
24
+ /** doc -> { revision, records: [{ at_ms, author_machine_id, text }] } */
25
+ const docs = new Map();
26
+
27
+ const stateOf = (doc) => docs.get(doc) ?? { revision: 0, records: [] };
28
+
29
+ /**
30
+ * @param {string} doc
31
+ * @returns {{ok: true, revision: number, records: object[], count: number}}
32
+ */
33
+ function get(doc) {
34
+ const state = stateOf(doc);
35
+ return { ok: true, revision: state.revision, records: state.records.map((r) => ({ ...r })), count: state.records.length };
36
+ }
37
+
38
+ /**
39
+ * Append records, guarded by the revision the caller saw.
40
+ *
41
+ * @param {{doc: string, if_revision?: number|null, author_machine_id: string, records: string[]}} input
42
+ * @returns {{ok: true, revision: number, appended: number} |
43
+ * {ok: false, code: string, detail?: object}}
44
+ */
45
+ function append(input) {
46
+ const doc = typeof input?.doc === 'string' && input.doc.trim() !== '' ? input.doc.trim() : null;
47
+ if (doc === null) return { ok: false, code: 'BAD_DOC' };
48
+ const author = typeof input?.author_machine_id === 'string' && input.author_machine_id !== '' ? input.author_machine_id : null;
49
+ if (author === null) return { ok: false, code: 'BAD_AUTHOR' };
50
+ const incoming = Array.isArray(input?.records) ? input.records : null;
51
+ if (incoming === null) return { ok: false, code: 'NO_RECORDS' };
52
+ const texts = incoming.filter((r) => typeof r === 'string' && r.trim() !== '');
53
+ if (texts.length === 0) return { ok: false, code: 'NO_RECORDS' };
54
+ if (texts.length !== incoming.length) return { ok: false, code: 'BAD_RECORD' };
55
+
56
+ const state = stateOf(doc);
57
+ const seen = Number.isInteger(input?.if_revision) ? input.if_revision : null;
58
+ if (seen !== null && seen !== state.revision) {
59
+ // The diff is what the caller needs to decide: how far ahead the document is, and what it has not read yet.
60
+ const missing = state.records.slice(Math.max(0, seen < 0 ? 0 : seen));
61
+ return {
62
+ ok: false,
63
+ code: 'REVISION_CONFLICT',
64
+ detail: {
65
+ if_revision: seen,
66
+ revision: state.revision,
67
+ behind_by: state.revision - seen,
68
+ unread_records: missing.length,
69
+ sample: missing.slice(0, 3).map((r) => ({ author: r.author_machine_id, text: r.text.slice(0, 80) })),
70
+ },
71
+ };
72
+ }
73
+ if (state.records.length + texts.length > maxRecords) {
74
+ return { ok: false, code: 'MEMORY_FULL', detail: { count: state.records.length, maxRecords } };
75
+ }
76
+
77
+ const at = now();
78
+ const added = texts.map((text) => ({ at_ms: at, author_machine_id: author, text }));
79
+ const next = { revision: state.revision + 1, records: [...state.records, ...added] };
80
+ docs.set(doc, next);
81
+ return { ok: true, revision: next.revision, appended: added.length };
82
+ }
83
+
84
+ return { get, append, _docs: docs };
85
+ }