@twinsearth/w2m-dsh-plugin 1.2.1 → 1.2.3

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,221 @@
1
+ /**
2
+ * A council whose members are machines, not callers in one process.
3
+ *
4
+ * ## What is shared with the single-machine council, and what is not
5
+ *
6
+ * The RULE is shared: a vote's weight is a pure function of the numbers on its ballot (authority and reputation), so any
7
+ * machine that holds the same ballots computes the same outcome; and a tie is a REFUSAL, not a coin toss, because a split
8
+ * council is exactly the case a person has to look at.
9
+ *
10
+ * The TRANSPORT is different. A single-machine council holds its ballots in memory; a fleet council's ballots arrive as
11
+ * chat messages on a thread (\`council-<id>\`), which is what the 1.2.1 relay already supports. So this module is the state
12
+ * and the arithmetic, and {@link fleetCouncilRequest} / {@link fleetCouncilBallot} are the two messages it exchanges --
13
+ * plain JSON in text, because the fleet chat contract is "plain text" and inventing a binary frame would need a relay
14
+ * change for no benefit.
15
+ *
16
+ * ## Why late ballots are refused rather than ignored
17
+ *
18
+ * Silently dropping a ballot makes a voter believe their vote counted. Refusing it by name can be told to them.
19
+ */
20
+ export const FLEET_COUNCIL_REFUSAL = Object.freeze({
21
+ UNKNOWN: 'FLEET_COUNCIL_UNKNOWN',
22
+ ALREADY_OPEN: 'FLEET_COUNCIL_ALREADY_OPEN',
23
+ ALREADY_DECIDED: 'FLEET_COUNCIL_ALREADY_DECIDED',
24
+ NO_BALLOTS: 'FLEET_COUNCIL_NO_BALLOTS',
25
+ BAD_BALLOT: 'FLEET_COUNCIL_BAD_BALLOT',
26
+ DUPLICATE_VOTER: 'FLEET_COUNCIL_DUPLICATE_VOTER',
27
+ LATE_BALLOT: 'FLEET_COUNCIL_LATE_BALLOT',
28
+ TIED: 'FLEET_COUNCIL_TIED',
29
+ BAD_MESSAGE: 'FLEET_COUNCIL_BAD_MESSAGE',
30
+ });
31
+
32
+ /** The weight of one ballot. Deliberately the same formula as \`agent/council.mjs#weightOf\`, restated here because importing
33
+ * the single-machine module would drag its phase machine into a fleet decision that does not have phases. */
34
+ export function fleetWeightOf({ authority, reputation } = {}) {
35
+ const level = Number.isFinite(authority) ? authority : null;
36
+ if (level === null || level < 0 || level > 100) return null;
37
+ const rep = Number.isFinite(reputation) ? reputation : 0;
38
+ return level * (1 + rep / 100);
39
+ }
40
+
41
+ /**
42
+ * Count the stored ballots. Pure: same ballots, same outcome, on any machine.
43
+ *
44
+ * @param {Array<{choice: string, authority: number, reputation: number, machine_id?: string}>} ballots
45
+ * @returns {{ok: true, choice: string, weight: number, dissent: object[]} | {ok: false, code: string, detail?: object}}
46
+ */
47
+ export function tallyFleetBallots(ballots) {
48
+ const list = Array.isArray(ballots) ? ballots : [];
49
+ if (list.length === 0) return { ok: false, code: FLEET_COUNCIL_REFUSAL.NO_BALLOTS };
50
+ const totals = new Map();
51
+ for (const ballot of list) {
52
+ const choice = typeof ballot?.choice === 'string' ? ballot.choice.trim() : '';
53
+ if (choice === '') return { ok: false, code: FLEET_COUNCIL_REFUSAL.BAD_BALLOT, detail: { ballot } };
54
+ const weight = fleetWeightOf(ballot);
55
+ if (weight === null) return { ok: false, code: FLEET_COUNCIL_REFUSAL.BAD_BALLOT, detail: { authority: ballot?.authority } };
56
+ totals.set(choice, (totals.get(choice) ?? 0) + weight);
57
+ }
58
+ const ranked = [...totals.entries()].sort((a, b) => b[1] - a[1]);
59
+ const [topChoice, topWeight] = ranked[0];
60
+ const tied = ranked.slice(1).filter(([, weight]) => weight === topWeight);
61
+ if (tied.length > 0) {
62
+ return {
63
+ ok: false,
64
+ code: FLEET_COUNCIL_REFUSAL.TIED,
65
+ detail: { tied: [topChoice, ...tied.map(([choice]) => choice)], weight: topWeight },
66
+ };
67
+ }
68
+ return {
69
+ ok: true,
70
+ choice: topChoice,
71
+ weight: topWeight,
72
+ dissent: list.filter((ballot) => ballot.choice !== topChoice).map((ballot) => ({ ...ballot })),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * @param {{now?: () => number}} [options]
78
+ */
79
+ export function createFleetCouncil(options = {}) {
80
+ const now = typeof options.now === 'function' ? options.now : () => Date.now();
81
+ /** councilId -> { id, question, openedBy, atMs, decidedAtMs, ballots, decision } */
82
+ const councils = new Map();
83
+
84
+ function open({ councilId, question, openedByMachineId } = {}) {
85
+ const id = typeof councilId === 'string' && councilId.trim() !== '' ? councilId.trim() : null;
86
+ if (id === null) return { ok: false, code: FLEET_COUNCIL_REFUSAL.BAD_BALLOT, detail: { field: 'councilId' } };
87
+ const questionText = typeof question === 'string' ? question.trim() : '';
88
+ if (questionText === '') return { ok: false, code: FLEET_COUNCIL_REFUSAL.BAD_BALLOT, detail: { field: 'question' } };
89
+ const existing = councils.get(id);
90
+ if (existing !== undefined && existing.decision === null) return { ok: false, code: FLEET_COUNCIL_REFUSAL.ALREADY_OPEN };
91
+ const council = {
92
+ id,
93
+ question: questionText,
94
+ openedBy: typeof openedByMachineId === 'string' ? openedByMachineId : null,
95
+ atMs: now(),
96
+ decidedAtMs: null,
97
+ ballots: [],
98
+ decision: null,
99
+ };
100
+ councils.set(id, council);
101
+ return { ok: true, council: snapshot(council) };
102
+ }
103
+
104
+ function vote({ councilId, machineId, choice, reason = null, authority = null, reputation = null } = {}) {
105
+ const council = councils.get(typeof councilId === 'string' ? councilId.trim() : '');
106
+ if (council === undefined) return { ok: false, code: FLEET_COUNCIL_REFUSAL.UNKNOWN, detail: { councilId } };
107
+ if (council.decision !== null) return { ok: false, code: FLEET_COUNCIL_REFUSAL.LATE_BALLOT, detail: { decidedAtMs: council.decidedAtMs } };
108
+ const voter = typeof machineId === 'string' && machineId !== '' ? machineId : null;
109
+ if (voter === null) return { ok: false, code: FLEET_COUNCIL_REFUSAL.BAD_BALLOT, detail: { field: 'machineId' } };
110
+ if (council.ballots.some((ballot) => ballot.machine_id === voter)) {
111
+ return { ok: false, code: FLEET_COUNCIL_REFUSAL.DUPLICATE_VOTER, detail: { machineId: voter } };
112
+ }
113
+ const ballot = {
114
+ machine_id: voter,
115
+ choice: typeof choice === 'string' ? choice.trim() : '',
116
+ reason: typeof reason === 'string' ? reason : null,
117
+ authority: Number.isInteger(authority) && authority >= 0 && authority <= 100 ? authority : null,
118
+ reputation: Number.isFinite(reputation) ? reputation : null,
119
+ at_ms: now(),
120
+ };
121
+ if (ballot.choice === '' || ballot.authority === null) {
122
+ return { ok: false, code: FLEET_COUNCIL_REFUSAL.BAD_BALLOT, detail: { ballot } };
123
+ }
124
+ council.ballots.push(ballot);
125
+ return { ok: true, council: snapshot(council) };
126
+ }
127
+
128
+ function decide({ councilId } = {}) {
129
+ const council = councils.get(typeof councilId === 'string' ? councilId.trim() : '');
130
+ if (council === undefined) return { ok: false, code: FLEET_COUNCIL_REFUSAL.UNKNOWN, detail: { councilId } };
131
+ if (council.decision !== null) return { ok: true, council: snapshot(council), alreadyDecided: true };
132
+ const counted = tallyFleetBallots(council.ballots);
133
+ if (counted.ok !== true) return counted;
134
+ council.decision = {
135
+ choice: counted.choice,
136
+ weight: counted.weight,
137
+ dissent: counted.dissent,
138
+ ballots: council.ballots.length,
139
+ };
140
+ council.decidedAtMs = now();
141
+ return { ok: true, council: snapshot(council) };
142
+ }
143
+
144
+ function state({ councilId } = {}) {
145
+ const council = councils.get(typeof councilId === 'string' ? councilId.trim() : '');
146
+ if (council === undefined) return { ok: false, code: FLEET_COUNCIL_REFUSAL.UNKNOWN, detail: { councilId } };
147
+ return { ok: true, council: snapshot(council) };
148
+ }
149
+
150
+ function list() {
151
+ return { ok: true, councils: [...councils.values()].map(snapshot) };
152
+ }
153
+
154
+ function snapshot(council) {
155
+ return {
156
+ id: council.id,
157
+ question: council.question,
158
+ opened_by: council.openedBy,
159
+ open: council.decision === null,
160
+ at_ms: council.atMs,
161
+ decided_at_ms: council.decidedAtMs,
162
+ ballots: council.ballots.map((ballot) => ({ ...ballot })),
163
+ decision: council.decision === null ? null : { ...council.decision, dissent: council.decision.dissent.map((b) => ({ ...b })) },
164
+ };
165
+ }
166
+
167
+ return { open, vote, decide, state, list, _councils: councils };
168
+ }
169
+
170
+ /** The thread every message about one council travels on. */
171
+ export function fleetCouncilThread(councilId) {
172
+ return 'council-' + String(councilId ?? '').trim();
173
+ }
174
+
175
+ /** The message that opens a vote on the fleet chat. */
176
+ export function fleetCouncilRequest({ councilId, question, openedByMachineId = null } = {}) {
177
+ return JSON.stringify({
178
+ v: 1,
179
+ kind: 'council-request',
180
+ council_id: String(councilId ?? ''),
181
+ question: String(question ?? ''),
182
+ opened_by: openedByMachineId,
183
+ });
184
+ }
185
+
186
+ /** The message that carries one ballot. */
187
+ export function fleetCouncilBallot({ councilId, machineId, choice, reason = null, authority = null, reputation = null } = {}) {
188
+ return JSON.stringify({
189
+ v: 1,
190
+ kind: 'council-ballot',
191
+ council_id: String(councilId ?? ''),
192
+ machine_id: String(machineId ?? ''),
193
+ choice: String(choice ?? ''),
194
+ reason,
195
+ authority,
196
+ reputation,
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Read one fleet-chat message: is it a council request, a ballot, or neither?
202
+ *
203
+ * @param {string} text
204
+ * @returns {{kind: 'request'|'ballot', payload: object} | {kind: 'none'} | {kind: 'bad', reason: string}}
205
+ */
206
+ export function parseFleetCouncilMessage(text) {
207
+ if (typeof text !== 'string' || text.trim() === '') return { kind: 'none' };
208
+ let parsed;
209
+ try { parsed = JSON.parse(text); } catch { return { kind: 'none' }; }
210
+ if (parsed === null || typeof parsed !== 'object' || parsed.v !== 1) return { kind: 'none' };
211
+ if (parsed.kind === 'council-request') {
212
+ if (typeof parsed.council_id !== 'string' || parsed.council_id.trim() === '') return { kind: 'bad', reason: 'council_id' };
213
+ return { kind: 'request', payload: parsed };
214
+ }
215
+ if (parsed.kind === 'council-ballot') {
216
+ if (typeof parsed.council_id !== 'string' || parsed.council_id.trim() === '') return { kind: 'bad', reason: 'council_id' };
217
+ if (typeof parsed.machine_id !== 'string' || parsed.machine_id.trim() === '') return { kind: 'bad', reason: 'machine_id' };
218
+ return { kind: 'ballot', payload: parsed };
219
+ }
220
+ return { kind: 'none' };
221
+ }
@@ -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
+ }
@@ -42,7 +42,10 @@ function newestTranscript(cwd) {
42
42
  return best === null ? null : best.file;
43
43
  }
44
44
 
45
- export const CHAT_ROUTE_PATH = '/api/w2m/chat';
45
+ export const CHAT_ROUTE_PATH = '/api/w2m/chat';
46
+
47
+ /** The light task board the panel shows beside the chat. The relay already answers GET /v1/tasks. */
48
+ export const TASKS_ROUTE_PATH = '/api/w2m/tasks';
46
49
  export const MAX_SESSION_ID_LENGTH = 200;
47
50
 
48
51
  const NO_STORE = { 'cache-control': 'no-store' };
@@ -102,6 +105,36 @@ export function registerChatRoutes(ctx, deps) {
102
105
  };
103
106
  return resolved;
104
107
  };
108
+
109
+ /**
110
+ * The machine ids this fleet knows, cached for five seconds.
111
+ *
112
+ * Only the HOST can check a single recipient: the relay validates the shape of a recipient, not whether a machine
113
+ * with that id exists. Null means the roster could not be read at all, which is deliberately a different answer from
114
+ * "no such machine" -- a relay outage must not be reported as a typo in the recipient.
115
+ */
116
+ let rosterAtMs = 0;
117
+ let rosterIds = null;
118
+ const knownMachineIds = async (signal) => {
119
+ if (rosterIds !== null && Date.now() - rosterAtMs < 5000) return rosterIds;
120
+ try {
121
+ const cfg = deps.cfg();
122
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
123
+ if (rabbitUrl === null) return null;
124
+ const token = await deps.resolveToken();
125
+ if (typeof token !== 'string' || token === '') return null;
126
+ const listed = await deps.request({ rabbitUrl, token, method: 'GET', pathname: '/v1/devices', signal });
127
+ if (listed?.ok !== true) return null;
128
+ const devices = Array.isArray(listed.json?.devices) ? listed.json.devices : null;
129
+ if (devices === null) return null;
130
+ const ids = devices.map((device) => device?.machine_id).filter((id) => typeof id === 'string');
131
+ rosterIds = ids;
132
+ rosterAtMs = Date.now();
133
+ return ids;
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
105
138
 
106
139
  /** Post one batch into the relay using the machine's device token, exactly as the browser route does. */
107
140
  const postToRelay = async (identity, payload) => {
@@ -232,6 +265,16 @@ export function registerChatRoutes(ctx, deps) {
232
265
  }
233
266
  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
267
  }
268
+ // Addressing (v1.2.2): a single recipient must exist in THIS fleet.
269
+ if (typeof addressing.to === 'string' && addressing.to !== 'all') {
270
+ const roster = await knownMachineIds(request.signal);
271
+ if (roster === null) {
272
+ return refuse(503, 'CHAT_ROSTER_UNAVAILABLE', 'the machine roster could not be read', 'a recipient cannot be checked without the relay roster');
273
+ }
274
+ if (!roster.includes(addressing.to)) {
275
+ 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');
276
+ }
277
+ }
235
278
  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
279
  if (posted?.ok !== true) {
237
280
  const code = posted?.json?.error?.code ?? posted?.error?.code ?? 'CHAT_SEND_REFUSED';
@@ -244,6 +287,48 @@ export function registerChatRoutes(ctx, deps) {
244
287
  }
245
288
  },
246
289
  });
290
+ // A light task board. The relay answers GET /v1/tasks; this proxy exists so the panel can read it with the device
291
+ // token that never leaves the host.
292
+ registry.register({
293
+ path: TASKS_ROUTE_PATH,
294
+ methods: ['GET'],
295
+ requestBody: 'buffered',
296
+ fetch: async (request) => {
297
+ const refuse = (httpStatus, reasonCode, error, detail) =>
298
+ Response.json({ ok: false, error, reason_code: reasonCode, detail }, { status: httpStatus, headers: NO_STORE });
299
+ if (request.method !== 'GET') {
300
+ return refuse(405, 'TASKS_METHOD_NOT_ALLOWED', `${request.method} is not answered here`, 'GET only');
301
+ }
302
+ const url = new URL(request.url);
303
+ const limitRaw = url.searchParams.get('limit');
304
+ const limit = limitRaw !== null && /^[0-9]+$/.test(limitRaw) ? Math.min(Number(limitRaw), 50) : 20;
305
+ const cfg = deps.cfg();
306
+ const rabbitUrl = cfg?.rabbitUrl ?? null;
307
+ if (rabbitUrl === null) return refuse(503, 'TASKS_NO_RELAY', 'no relay is configured', 'set rabbitUrl in the profile patch');
308
+ let token;
309
+ try {
310
+ token = await deps.resolveToken();
311
+ } catch (error) {
312
+ return refuse(503, 'TASKS_UNPAIRED', `this machine has no device token: ${error instanceof Error ? error.message : String(error)}`, 'pair the machine first');
313
+ }
314
+ if (typeof token !== 'string' || token === '') {
315
+ return refuse(503, 'TASKS_UNPAIRED', 'this machine has no device token', 'pair the machine first');
316
+ }
317
+ try {
318
+ const listed = await deps.request({
319
+ rabbitUrl, token, method: 'GET', pathname: '/v1/tasks', query: { limit: String(limit) }, signal: request.signal,
320
+ });
321
+ if (listed?.ok !== true) {
322
+ const code = listed?.json?.error?.code ?? 'TASKS_LIST_REFUSED';
323
+ return refuse(502, code, `the relay answered HTTP ${listed?.status ?? 0}`, 'the relay must run a version with /v1/tasks');
324
+ }
325
+ return Response.json({ ...listed.json, ok: true }, { headers: NO_STORE });
326
+ } catch (error) {
327
+ return refuse(502, 'TASKS_RELAY_ERROR', `the relay call failed: ${error instanceof Error ? error.message : String(error)}`, null);
328
+ }
329
+ },
330
+ });
331
+
247
332
  status.mounted = true;
248
333
  status.reason = null;
249
334
  const timer = setInterval(() => { mirrorOnce().catch(() => { /* one bad tick must not stop the next */ }); }, 2000);
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Authoring rules DSH enforces on tool schemas, checked here so the failure happens at build time in this repository
3
+ * rather than at mount time in someone else's profile.
4
+ *
5
+ * The rule this exists for: DSH's `parameterSchemaSpecToJsonSchema` requires an EXPLICIT `additionalProperties` on every
6
+ * `type: 'object'` node and throws otherwise. Local tests passed while the packed artifact failed in a real profile,
7
+ * which is exactly the gap a pure checker closes: the same walk runs in the suite and at registration.
8
+ */
9
+
10
+ /**
11
+ * Every `type: 'object'` node in a JSON-schema-shaped value, with its path.
12
+ *
13
+ * @param {unknown} schema
14
+ * @param {string} [path]
15
+ * @returns {Array<{path: string, node: object}>}
16
+ */
17
+ export function objectNodes(schema, path = 'schema') {
18
+ const found = [];
19
+ if (schema === null || typeof schema !== 'object') return found;
20
+ const node = schema;
21
+ if (node.type === 'object') found.push({ path, node });
22
+ if (node.properties !== null && typeof node.properties === 'object') {
23
+ for (const [name, child] of Object.entries(node.properties)) {
24
+ found.push(...objectNodes(child, path + '.properties.' + name));
25
+ }
26
+ }
27
+ if (node.items !== undefined) found.push(...objectNodes(node.items, path + '[]'));
28
+ for (const key of ['oneOf', 'anyOf', 'allOf']) {
29
+ if (Array.isArray(node[key])) {
30
+ node[key].forEach((child, index) => found.push(...objectNodes(child, path + '.' + key + '[' + index + ']')));
31
+ }
32
+ }
33
+ return found;
34
+ }
35
+
36
+ /**
37
+ * The problems a schema would be refused for, as `{path, why}` values rather than a thrown error, so a caller can report
38
+ * all of them at once instead of fixing one per run.
39
+ *
40
+ * @param {unknown} schema
41
+ * @param {string} [name] tool name, used in the path
42
+ * @returns {Array<{path: string, why: string}>}
43
+ */
44
+ export function schemaProblems(schema, name = 'tool') {
45
+ return objectNodes(schema, 'parameters')
46
+ .filter(({ node }) => typeof node.additionalProperties !== 'boolean')
47
+ .map(({ path }) => ({ path: path + '.additionalProperties', why: 'an object parameter must state additionalProperties: true or false' }))
48
+ .map((problem) => ({ ...problem, path: name + '.' + problem.path }));
49
+ }