@agentshouse/kit 0.2.0 → 0.2.1-alpha.1

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,538 @@
1
+ import { client, } from '@agentclientprotocol/sdk';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { mkdir } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { killMarked, startAdapter, TURN_ENDED, TURN_STARTED } from './acp.js';
6
+ import { blocksOf, firstChange } from './blocks.js';
7
+ import { openBridge } from './bridge.js';
8
+ import { CLIS } from './clis.js';
9
+ import { placeFiles } from './files.js';
10
+ import { instructions } from './instructions.js';
11
+ import { holdSecretInput } from './secret-input.js';
12
+ const LAUNCH_BASE = '/agents/house';
13
+ const KILLED = 'the conversation was killed';
14
+ function causeOf(error) {
15
+ return error instanceof Error ? error.message : String(error);
16
+ }
17
+ function logged(error) {
18
+ process.stderr.write(`kit: ${causeOf(error)}\n`);
19
+ }
20
+ function unlessKilled(work, signal) {
21
+ return new Promise((resolve, reject) => {
22
+ const killed = () => reject(signal.reason);
23
+ signal.addEventListener('abort', killed, { once: true });
24
+ if (signal.aborted)
25
+ killed();
26
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', killed));
27
+ });
28
+ }
29
+ function stop(bridge) {
30
+ killMarked(`HOUSE_BRIDGE=${bridge.env.HOUSE_BRIDGE}`);
31
+ }
32
+ class Turn {
33
+ id = randomUUID();
34
+ text = '';
35
+ sent = [];
36
+ unsentFrom = null;
37
+ draftSequence = 0;
38
+ planSequence = 0;
39
+ questions = 0;
40
+ secrets = 0;
41
+ prompt = null;
42
+ ended = false;
43
+ ready = Promise.resolve();
44
+ }
45
+ class Conversation {
46
+ id;
47
+ running = null;
48
+ turn = null;
49
+ commands = null;
50
+ jobs = new Set();
51
+ queued = [];
52
+ late = [];
53
+ opening = null;
54
+ kills = 0;
55
+ killed = new AbortController();
56
+ open = 0;
57
+ queue = Promise.resolve();
58
+ reports = Promise.resolve();
59
+ constructor(id) {
60
+ this.id = id;
61
+ }
62
+ }
63
+ function formProperties(request) {
64
+ if (request.mode !== 'form')
65
+ return {};
66
+ return request.requestedSchema?.properties ?? {};
67
+ }
68
+ function asksSecret(request) {
69
+ return Object.values(formProperties(request)).some((property) => Object.values(property._meta ?? {}).some((meta) => typeof meta === 'object' && meta !== null && meta.isSecret === true));
70
+ }
71
+ export class Conversations {
72
+ kit;
73
+ conversations = new Map();
74
+ acks = new Map();
75
+ questions = new Map();
76
+ carrying = 0;
77
+ reporting = 0;
78
+ constructor(kit) {
79
+ this.kit = kit;
80
+ }
81
+ input(input) {
82
+ this.carrying++;
83
+ const carried = this.acks.get(input.input_id) ??
84
+ (() => {
85
+ const conversation = this.conversation(input.conversation_id);
86
+ if (input.kind === 'kill') {
87
+ conversation.kills++;
88
+ conversation.killed.abort(new Error(KILLED));
89
+ this.halt(conversation);
90
+ }
91
+ const work = conversation.queue.then(() => this.carry(conversation, input));
92
+ conversation.queue = work.catch(() => undefined);
93
+ this.acks.set(input.input_id, work);
94
+ return work;
95
+ })();
96
+ carried
97
+ .then((body) => this.kit.house.deliver(`/kit/inputs/${input.input_id}/ack`, body))
98
+ .catch(logged)
99
+ .finally(() => {
100
+ this.carrying--;
101
+ this.kit.changed();
102
+ });
103
+ }
104
+ idle() {
105
+ if (this.carrying > 0 || this.reporting > 0)
106
+ return false;
107
+ for (const conversation of this.conversations.values()) {
108
+ const turn = conversation.turn;
109
+ const asking = turn !== null && turn.questions > 0 && turn.secrets === 0;
110
+ if (conversation.jobs.size > 0 || conversation.open > (asking ? 1 : 0))
111
+ return false;
112
+ }
113
+ return true;
114
+ }
115
+ opened() {
116
+ for (const conversation of this.conversations.values()) {
117
+ if (conversation.turn !== null)
118
+ conversation.turn.unsentFrom = 0;
119
+ }
120
+ }
121
+ conversation(id) {
122
+ let conversation = this.conversations.get(id);
123
+ if (conversation === undefined) {
124
+ conversation = new Conversation(id);
125
+ this.conversations.set(id, conversation);
126
+ }
127
+ return conversation;
128
+ }
129
+ async carry(conversation, input) {
130
+ if (input.kind === 'open')
131
+ return this.open(conversation, input);
132
+ if (input.kind === 'message')
133
+ return this.message(conversation, input);
134
+ if (input.kind === 'interrupt')
135
+ await this.interrupt(conversation, String(input.turn_id));
136
+ if (input.kind === 'kill') {
137
+ await this.kill(conversation);
138
+ if (--conversation.kills === 0)
139
+ conversation.killed = new AbortController();
140
+ }
141
+ if (input.kind === 'option')
142
+ await this.option(conversation, String(input.option), input.value);
143
+ if (input.kind === 'answer')
144
+ this.questions.get(String(input.interaction_id))?.answer(input.response);
145
+ return {};
146
+ }
147
+ async open(conversation, input) {
148
+ if (conversation.running !== null)
149
+ return {};
150
+ try {
151
+ return { provider_session_id: await this.start(conversation, await this.route(conversation, input.agent_id), null) };
152
+ }
153
+ catch (error) {
154
+ return { refused: causeOf(error) };
155
+ }
156
+ }
157
+ async message(conversation, input) {
158
+ let ack = {};
159
+ const prompt = [];
160
+ try {
161
+ const route = await this.route(conversation, input.agent_id);
162
+ const paths = await placeFiles(this.kit.house, route.working_directory, input.files, conversation.killed.signal);
163
+ if (conversation.running === null) {
164
+ const opened = await this.start(conversation, route, input.provider_session_id);
165
+ if (input.provider_session_id === null)
166
+ ack = { provider_session_id: opened };
167
+ }
168
+ if (input.first === true) {
169
+ prompt.push({ type: 'text', text: await instructions(conversation.running.bridge, route.base_instructions) });
170
+ }
171
+ prompt.push({ type: 'text', text: String(input.text) });
172
+ if (paths.length > 0)
173
+ prompt.push({ type: 'text', text: paths.join('\n') });
174
+ if (conversation.kills > 0)
175
+ throw new Error(KILLED);
176
+ }
177
+ catch (error) {
178
+ if ('provider_session_id' in ack)
179
+ await this.kill(conversation);
180
+ return { refused: conversation.kills > 0 ? KILLED : causeOf(error) };
181
+ }
182
+ this.prompt(conversation, prompt);
183
+ return ack;
184
+ }
185
+ async interrupt(conversation, turnId) {
186
+ if (conversation.running === null || conversation.turn?.id !== turnId)
187
+ return;
188
+ await conversation.running.adapter.connection.agent.notify('session/cancel', {
189
+ sessionId: conversation.running.sessionId,
190
+ });
191
+ }
192
+ halt(conversation) {
193
+ if (conversation.opening !== null)
194
+ stop(conversation.opening);
195
+ if (conversation.running === null)
196
+ return;
197
+ conversation.running.killed = true;
198
+ stop(conversation.running.bridge);
199
+ }
200
+ async kill(conversation) {
201
+ const running = conversation.running;
202
+ this.halt(conversation);
203
+ await running?.adapter.exited;
204
+ }
205
+ async option(conversation, option, value) {
206
+ const running = conversation.running;
207
+ if (running === null)
208
+ return;
209
+ try {
210
+ const answered = await running.adapter.connection.agent.request('session/set_config_option', {
211
+ sessionId: running.sessionId,
212
+ configId: option,
213
+ ...(typeof value === 'boolean' ? { type: 'boolean', value } : { value: String(value) }),
214
+ });
215
+ this.options(conversation, answered.configOptions);
216
+ }
217
+ catch (error) {
218
+ logged(error);
219
+ }
220
+ }
221
+ async route(conversation, agentId) {
222
+ const signal = conversation.killed.signal;
223
+ await unlessKilled(this.kit.agents.read(), signal);
224
+ if (this.kit.agents.route(agentId) === undefined)
225
+ await unlessKilled(this.kit.agents.refresh(), signal);
226
+ const route = this.kit.agents.route(agentId);
227
+ if (route === undefined)
228
+ throw new Error(`this Environment hosts no Agent ${agentId}`);
229
+ if (route.working_directory === join(LAUNCH_BASE, route.agent_id)) {
230
+ await mkdir(route.working_directory, { recursive: true });
231
+ }
232
+ return route;
233
+ }
234
+ async start(conversation, route, sessionId) {
235
+ const app = client({ name: '@agentshouse/kit' })
236
+ .onNotification('session/update', ({ params }) => this.update(conversation, params))
237
+ .onNotification(TURN_STARTED, (params) => params, () => void this.started(conversation))
238
+ .onNotification(TURN_ENDED, (params) => params, () => this.ended(conversation))
239
+ .onRequest('session/request_permission', ({ params, signal }) => this.ask(conversation, 'session/request_permission', params, false, signal))
240
+ .onRequest('elicitation/create', ({ params, signal }) => this.ask(conversation, 'elicitation/create', params, asksSecret(params), signal));
241
+ await unlessKilled(this.kit.agents.installed(), conversation.killed.signal);
242
+ const bridge = await openBridge(this.kit.house, conversation.id, conversation.killed.signal);
243
+ if (conversation.kills > 0) {
244
+ bridge.close();
245
+ throw new Error(KILLED);
246
+ }
247
+ conversation.opening = bridge;
248
+ try {
249
+ const adapter = await startAdapter(route.kind, route.working_directory, app, (job) => this.job(conversation, job), { ...process.env, ...bridge.env });
250
+ void adapter.exited.then(() => bridge.close());
251
+ const agent = adapter.connection.agent;
252
+ const cwd = route.working_directory;
253
+ const opened = sessionId === null
254
+ ? await agent.request('session/new', { cwd, mcpServers: [] })
255
+ : { ...(await agent.request('session/resume', { sessionId, cwd, mcpServers: [] })), sessionId };
256
+ const running = {
257
+ adapter,
258
+ bridge,
259
+ sessionId: opened.sessionId,
260
+ killed: false,
261
+ queues: CLIS[route.kind].queues,
262
+ };
263
+ const options = await this.launchSettings(running, route, opened.configOptions ?? []);
264
+ conversation.running = running;
265
+ this.kit.send({ type: 'process', conversation_id: conversation.id, running: true });
266
+ if (conversation.commands !== null)
267
+ this.commands(conversation, conversation.commands);
268
+ this.options(conversation, options);
269
+ void adapter.exited.then((cause) => this.exited(conversation, adapter, cause));
270
+ return running.sessionId;
271
+ }
272
+ catch (error) {
273
+ stop(bridge);
274
+ bridge.close();
275
+ throw conversation.kills > 0 ? new Error(KILLED) : error;
276
+ }
277
+ finally {
278
+ conversation.opening = null;
279
+ }
280
+ }
281
+ async launchSettings(running, route, offered) {
282
+ let options = offered;
283
+ const settings = [
284
+ ['model', route.model],
285
+ ['thought_level', route.effort],
286
+ ];
287
+ for (const [category, value] of settings) {
288
+ if (value === null)
289
+ continue;
290
+ const option = options.find((candidate) => candidate.category === category);
291
+ const answered = await running.adapter.connection.agent.request('session/set_config_option', {
292
+ sessionId: running.sessionId,
293
+ configId: option?.id ?? category,
294
+ value,
295
+ });
296
+ options = answered.configOptions;
297
+ }
298
+ return options;
299
+ }
300
+ exited(conversation, adapter, cause) {
301
+ const running = conversation.running;
302
+ if (running?.adapter !== adapter)
303
+ return;
304
+ conversation.running = null;
305
+ conversation.commands = null;
306
+ conversation.jobs.clear();
307
+ conversation.queued.length = 0;
308
+ for (const [id, question] of this.questions) {
309
+ if (question.conversation === conversation)
310
+ this.questions.delete(id);
311
+ }
312
+ this.kit.send({ type: 'process', conversation_id: conversation.id, running: false });
313
+ if (conversation.turn !== null) {
314
+ this.end(conversation, conversation.turn, { failed: running.killed ? KILLED : cause });
315
+ }
316
+ this.kit.changed();
317
+ }
318
+ commands(conversation, commands) {
319
+ conversation.commands = commands;
320
+ if (conversation.running === null)
321
+ return;
322
+ this.kit.send({ type: 'commands', conversation_id: conversation.id, commands });
323
+ }
324
+ options(conversation, options) {
325
+ if (conversation.running === null)
326
+ return;
327
+ this.kit.send({ type: 'options', conversation_id: conversation.id, options });
328
+ }
329
+ deliver(conversation, turn, event, body) {
330
+ this.reporting++;
331
+ const delivered = conversation.reports.then(() => this.kit.house.deliver(`/kit/conversations/${conversation.id}/turns/${turn.id}/${event}`, body));
332
+ conversation.reports = delivered
333
+ .catch(() => undefined)
334
+ .finally(() => {
335
+ this.reporting--;
336
+ this.kit.changed();
337
+ });
338
+ return delivered;
339
+ }
340
+ report(conversation) {
341
+ const turn = new Turn();
342
+ conversation.open++;
343
+ turn.ready = this.deliver(conversation, turn, 'started', {}).then(() => undefined, logged);
344
+ return turn;
345
+ }
346
+ begin(conversation) {
347
+ const turn = this.report(conversation);
348
+ conversation.turn = turn;
349
+ this.kit.changed();
350
+ return turn;
351
+ }
352
+ end(conversation, turn, outcome) {
353
+ if (turn.ended)
354
+ return;
355
+ turn.ended = true;
356
+ conversation.open--;
357
+ if (conversation.turn === turn)
358
+ conversation.turn = null;
359
+ void this.deliver(conversation, turn, 'ended', outcome).catch(logged);
360
+ const late = conversation.open === 0 ? conversation.late.shift() : undefined;
361
+ if (late !== undefined)
362
+ this.failed(conversation, late);
363
+ this.kit.changed();
364
+ }
365
+ failed(conversation, cause) {
366
+ if (conversation.open > 0)
367
+ conversation.late.push(cause);
368
+ else
369
+ this.end(conversation, this.report(conversation), { failed: cause });
370
+ }
371
+ prompt(conversation, prompt) {
372
+ const running = conversation.running;
373
+ const sent = { turn: null };
374
+ if (running.queues && (conversation.turn !== null || conversation.queued.length > 0)) {
375
+ conversation.queued.push(sent);
376
+ }
377
+ else {
378
+ this.attach(conversation.turn ?? this.begin(conversation), sent);
379
+ }
380
+ running.adapter.connection.agent.request('session/prompt', { sessionId: running.sessionId, prompt }).then(() => this.answered(conversation, sent, null), async (error) => {
381
+ if (!running.adapter.connection.signal.aborted)
382
+ this.answered(conversation, sent, causeOf(error));
383
+ else if (sent.turn !== null) {
384
+ this.end(conversation, sent.turn, { failed: running.killed ? KILLED : await running.adapter.exited });
385
+ }
386
+ });
387
+ }
388
+ attach(turn, sent) {
389
+ turn.prompt = sent;
390
+ sent.turn = turn;
391
+ }
392
+ answered(conversation, sent, failed) {
393
+ const turn = sent.turn;
394
+ if (turn === null) {
395
+ const queued = conversation.queued.indexOf(sent);
396
+ if (queued < 0)
397
+ return;
398
+ conversation.queued.splice(queued, 1);
399
+ if (failed !== null)
400
+ this.failed(conversation, failed);
401
+ }
402
+ else if (failed !== null) {
403
+ if (turn.ended)
404
+ this.failed(conversation, failed);
405
+ else
406
+ this.end(conversation, turn, { failed });
407
+ }
408
+ else if (turn.prompt === sent) {
409
+ this.end(conversation, turn, { text: turn.text });
410
+ }
411
+ }
412
+ started(conversation) {
413
+ if (conversation.turn !== null)
414
+ return conversation.turn;
415
+ const turn = this.begin(conversation);
416
+ const queued = conversation.queued.shift();
417
+ if (queued !== undefined)
418
+ this.attach(turn, queued);
419
+ return turn;
420
+ }
421
+ ended(conversation) {
422
+ const turn = conversation.turn;
423
+ if (turn === null)
424
+ return;
425
+ if (turn.prompt === null) {
426
+ this.end(conversation, turn, { text: turn.text });
427
+ }
428
+ else {
429
+ conversation.turn = null;
430
+ this.kit.changed();
431
+ }
432
+ }
433
+ async ask(conversation, method, params, secret, signal) {
434
+ const turn = this.started(conversation);
435
+ const interactionId = randomUUID();
436
+ const answered = new Promise((answer, fail) => {
437
+ this.questions.set(interactionId, { conversation, answer: (response) => answer(response) });
438
+ signal.addEventListener('abort', () => answer(null), { once: true });
439
+ this.deliver(conversation, turn, 'interactions', {
440
+ interaction_id: interactionId,
441
+ request: { method, params },
442
+ secret,
443
+ }).then(() => {
444
+ if (secret)
445
+ void this.holdSecret(interactionId, params);
446
+ }, fail);
447
+ });
448
+ turn.questions++;
449
+ if (secret)
450
+ turn.secrets++;
451
+ this.kit.changed();
452
+ try {
453
+ const response = await answered;
454
+ if (response === null)
455
+ throw signal.reason;
456
+ return response;
457
+ }
458
+ finally {
459
+ this.questions.delete(interactionId);
460
+ turn.questions--;
461
+ if (secret)
462
+ turn.secrets--;
463
+ this.kit.changed();
464
+ }
465
+ }
466
+ async holdSecret(interactionId, request) {
467
+ const steps = Object.entries(formProperties(request)).map(([name, property]) => ({
468
+ kind: 'collect',
469
+ label: property.title ?? name,
470
+ name,
471
+ ...(property.description ? { description: property.description } : {}),
472
+ }));
473
+ const content = await holdSecretInput(this.kit.house, interactionId, steps, () => this.questions.has(interactionId));
474
+ if (content !== null)
475
+ this.questions.get(interactionId)?.answer({ action: 'accept', content });
476
+ }
477
+ update(conversation, notification) {
478
+ const update = notification.update;
479
+ if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
480
+ const turn = this.started(conversation);
481
+ turn.text += update.content.text;
482
+ const text = turn.text;
483
+ void turn.ready.then(() => this.draft(conversation, turn, text));
484
+ }
485
+ else if (update.sessionUpdate === 'plan') {
486
+ const turn = this.started(conversation);
487
+ void turn.ready.then(() => this.plan(conversation, turn, update.entries));
488
+ }
489
+ else if (update.sessionUpdate === 'agent_thought_chunk' || update.sessionUpdate === 'tool_call') {
490
+ this.started(conversation);
491
+ }
492
+ else if (update.sessionUpdate === 'available_commands_update') {
493
+ this.commands(conversation, update.availableCommands);
494
+ }
495
+ else if (update.sessionUpdate === 'config_option_update') {
496
+ this.options(conversation, update.configOptions);
497
+ }
498
+ }
499
+ job(conversation, job) {
500
+ if (job.running)
501
+ conversation.jobs.add(job.id);
502
+ else
503
+ conversation.jobs.delete(job.id);
504
+ this.kit.changed();
505
+ }
506
+ draft(conversation, turn, text) {
507
+ const blocks = blocksOf(text);
508
+ const from = Math.min(firstChange(turn.sent, blocks), turn.unsentFrom ?? Infinity);
509
+ const sent = this.kit.send({
510
+ type: 'draft',
511
+ conversation_id: conversation.id,
512
+ turn_id: turn.id,
513
+ sequence: turn.draftSequence + 1,
514
+ from,
515
+ blocks: blocks.slice(from),
516
+ });
517
+ if (sent) {
518
+ turn.draftSequence++;
519
+ turn.sent = blocks;
520
+ turn.unsentFrom = null;
521
+ }
522
+ else {
523
+ turn.unsentFrom = from;
524
+ }
525
+ }
526
+ plan(conversation, turn, entries) {
527
+ const sent = this.kit.send({
528
+ type: 'plan',
529
+ conversation_id: conversation.id,
530
+ turn_id: turn.id,
531
+ sequence: turn.planSequence + 1,
532
+ from: 0,
533
+ steps: entries.map((entry) => ({ label: entry.content, priority: entry.priority, status: entry.status })),
534
+ });
535
+ if (sent)
536
+ turn.planSequence++;
537
+ }
538
+ }
package/dist/files.js ADDED
@@ -0,0 +1,21 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ export async function placeFiles(house, directory, files, signal) {
4
+ const paths = [];
5
+ for (const file of files) {
6
+ const original = await house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal);
7
+ const answer = await fetch(original.download.url, { signal });
8
+ if (!answer.ok)
9
+ throw new Error(`${file.name} did not download: ${answer.status}`);
10
+ const folder = join(directory, '.house', 'files', original.message, file.version);
11
+ await mkdir(join(directory, '.house')).catch((error) => {
12
+ if (error.code !== 'EEXIST')
13
+ throw error;
14
+ });
15
+ await mkdir(folder, { recursive: true });
16
+ const path = join(folder, file.name);
17
+ await writeFile(path, Buffer.from(await answer.arrayBuffer()));
18
+ paths.push(path);
19
+ }
20
+ return paths;
21
+ }
@@ -1,4 +1,92 @@
1
1
  #!/usr/bin/env node
2
- process.stderr.write('house: house runs inside an Agent conversation\n');
3
- process.exitCode = 1;
4
- export {};
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { request } from 'node:http';
5
+ import { basename } from 'node:path';
6
+ const USAGE = "usage: house <tool> ['<arguments as JSON>']";
7
+ const socketPath = process.env.HOUSE_BRIDGE;
8
+ function bridged(path, body) {
9
+ return new Promise((resolve, reject) => {
10
+ const sent = request({ socketPath, path, method: 'POST', headers: { 'content-type': 'application/json' } }, (answer) => {
11
+ let text = '';
12
+ answer.setEncoding('utf8');
13
+ answer.on('data', (chunk) => {
14
+ text += chunk;
15
+ });
16
+ answer.on('end', () => {
17
+ if (answer.statusCode === 200)
18
+ resolve(text);
19
+ else
20
+ reject(new Error(`${path} answered ${answer.statusCode}: ${text}`));
21
+ });
22
+ });
23
+ sent.on('error', reject);
24
+ sent.end(JSON.stringify(body));
25
+ });
26
+ }
27
+ async function mcp(method, params) {
28
+ const answer = JSON.parse(await bridged('/', { jsonrpc: '2.0', id: 1, method, params }));
29
+ if (answer.error !== undefined)
30
+ throw new Error(answer.error.message);
31
+ return answer.result;
32
+ }
33
+ async function tools() {
34
+ return (await mcp('tools/list', {})).tools;
35
+ }
36
+ async function upload(path, roomRef) {
37
+ const bytes = await readFile(path);
38
+ const declared = JSON.parse(await bridged('/kit/attachments/upload', {
39
+ ...(roomRef === undefined ? {} : { room_ref: roomRef }),
40
+ version: randomUUID(),
41
+ name: basename(path),
42
+ bytes: bytes.length,
43
+ sha256: createHash('sha256').update(bytes).digest('hex'),
44
+ }));
45
+ const answer = await fetch(declared.upload.url, {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/octet-stream', 'x-house-byte-operation': declared.upload.operation },
48
+ body: bytes,
49
+ });
50
+ const text = await answer.text();
51
+ if (!answer.ok)
52
+ throw new Error(`House refused the bytes of ${path} with ${answer.status}: ${text}`);
53
+ return text;
54
+ }
55
+ async function house([verb, argument]) {
56
+ if (verb === undefined || verb === '--help') {
57
+ return [USAGE, ...(await tools()).map((tool) => `${tool.name}: ${tool.description}`)].join('\n');
58
+ }
59
+ if (argument === '--help') {
60
+ const tool = (await tools()).find((listed) => listed.name === verb);
61
+ if (tool === undefined)
62
+ throw new Error(`House has no tool ${verb}`);
63
+ return `${tool.description}\n${JSON.stringify(tool.inputSchema, null, 2)}`;
64
+ }
65
+ const args = (argument === undefined ? {} : JSON.parse(argument));
66
+ if (verb === 'upload_attachment')
67
+ return upload(String(args.path), args.room_ref);
68
+ if (verb === 'append_record' && Array.isArray(args.attachments)) {
69
+ const attachments = [];
70
+ for (const path of args.attachments) {
71
+ attachments.push(JSON.parse(await upload(String(path), args.room_ref)).attachment);
72
+ }
73
+ args.attachments = attachments;
74
+ }
75
+ const result = await mcp('tools/call', { name: verb, arguments: args });
76
+ const text = result.content.map((content) => content.text).join('\n');
77
+ if (result.isError === true)
78
+ throw new Error(text);
79
+ return text;
80
+ }
81
+ if (socketPath === undefined) {
82
+ process.stderr.write('house: house runs inside an Agent conversation\n');
83
+ process.exitCode = 1;
84
+ }
85
+ else {
86
+ house(process.argv.slice(2)).then((text) => {
87
+ process.stdout.write(`${text}\n`);
88
+ }, (error) => {
89
+ process.stderr.write(`house: ${error instanceof Error ? error.message : String(error)}\n`);
90
+ process.exitCode = 1;
91
+ });
92
+ }
@@ -0,0 +1,28 @@
1
+ const HOUSE_LINE = 'Work with House through the `house` CLI: run `house --help`.';
2
+ const FILE_LINE = 'To give the User a file, upload it with `house upload_attachment` and link it in your answer.';
3
+ const HOW_WE_WORK = '/private/library/how-we-work.md';
4
+ const UNREADABLE = /^(?:\S+: )?(?:path_not_found|room_not_found|operation_denied)\b/;
5
+ const CUT = /^stderr: shell: output_cut (\d+) of \d+ bytes; continue with: (.+)$/;
6
+ async function howWeWork(bridge) {
7
+ let document = '';
8
+ for (let command = `cat ${HOW_WE_WORK}`; command !== undefined;) {
9
+ const read = await bridge.tool('shell', { command });
10
+ const text = read.content.map((content) => content.text).join('\n');
11
+ const lines = text.split('\n');
12
+ let at = lines[5]?.startsWith('authority: ') ? 6 : 5;
13
+ if (read.isError === true || lines[2] !== 'exit: 0') {
14
+ const refusal = read.isError === true ? text : lines.slice(at).join('\n').replace(/^stderr: /gm, '');
15
+ if (UNREADABLE.test(refusal))
16
+ return '';
17
+ throw new Error(refusal);
18
+ }
19
+ const cut = lines[3] === 'truncation: egress' ? CUT.exec(lines[at++] ?? '') : null;
20
+ const shown = Buffer.from(`${lines.slice(at).join('\n')}\n`);
21
+ document += (cut === null ? shown : shown.subarray(0, Number(cut[1]))).toString();
22
+ command = cut?.[2];
23
+ }
24
+ return document.replace(/\n$/, '');
25
+ }
26
+ export async function instructions(bridge, base) {
27
+ return [`${HOUSE_LINE}\n${FILE_LINE}`, base, await howWeWork(bridge)].filter((part) => part !== '').join('\n\n');
28
+ }