@agentshouse/kit 0.2.1-cf6326dfa5c3e → 0.2.1-cf6a92e13f031

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.
Files changed (130) hide show
  1. package/README.md +22 -22
  2. package/dist/acp.js +2 -2
  3. package/dist/agents.js +2 -2
  4. package/dist/api.js +5 -5
  5. package/dist/bootstrap.json +4 -4
  6. package/dist/bridge.js +1 -1
  7. package/dist/{inputs.js → calls.js} +20 -20
  8. package/dist/clis.js +6 -3
  9. package/dist/{stream.js → connection.js} +30 -27
  10. package/dist/conversations.js +220 -105
  11. package/dist/copies.js +15 -29
  12. package/dist/end.js +6 -2
  13. package/dist/events.js +2 -2
  14. package/dist/files.js +123 -37
  15. package/dist/git.js +1 -1
  16. package/dist/house-cli.js +81 -86
  17. package/dist/instructions.js +28 -8
  18. package/dist/kit-cli.js +2 -2
  19. package/dist/login.js +1 -3
  20. package/dist/mcp.js +4 -2
  21. package/dist/placement.js +11 -2
  22. package/dist/resident.js +38 -32
  23. package/dist/scope.js +3 -0
  24. package/dist/service.js +1 -1
  25. package/dist/sign-in.js +19 -19
  26. package/dist/skills.js +12 -93
  27. package/dist/transcription.js +51 -20
  28. package/dist/update.js +32 -32
  29. package/dist/web-wizard.js +100 -0
  30. package/dist/wire.js +19 -3
  31. package/package.json +6 -6
  32. package/how-we-work.md +0 -33
  33. package/skills/ask-matt/LICENSE +0 -21
  34. package/skills/ask-matt/PHASE-BOUNDARIES.md +0 -55
  35. package/skills/ask-matt/SKILL.md +0 -97
  36. package/skills/ask-matt/agents/openai.yaml +0 -5
  37. package/skills/code-review/LICENSE +0 -21
  38. package/skills/code-review/SKILL.md +0 -89
  39. package/skills/code-review/agents/openai.yaml +0 -3
  40. package/skills/codebase-design/DEEPENING.md +0 -37
  41. package/skills/codebase-design/DESIGN-IT-TWICE.md +0 -44
  42. package/skills/codebase-design/LICENSE +0 -21
  43. package/skills/codebase-design/SKILL.md +0 -114
  44. package/skills/codebase-design/agents/openai.yaml +0 -3
  45. package/skills/diagnosing-bugs/LICENSE +0 -21
  46. package/skills/diagnosing-bugs/SKILL.md +0 -138
  47. package/skills/diagnosing-bugs/agents/openai.yaml +0 -3
  48. package/skills/diagnosing-bugs/scripts/hitl-loop.template.sh +0 -44
  49. package/skills/domain-modeling/ADR-FORMAT.md +0 -47
  50. package/skills/domain-modeling/GLOSSARY-FORMAT.md +0 -60
  51. package/skills/domain-modeling/LICENSE +0 -21
  52. package/skills/domain-modeling/SKILL.md +0 -74
  53. package/skills/domain-modeling/agents/openai.yaml +0 -3
  54. package/skills/grill-me/LICENSE +0 -21
  55. package/skills/grill-me/SKILL.md +0 -7
  56. package/skills/grill-me/agents/openai.yaml +0 -5
  57. package/skills/grill-with-docs/LICENSE +0 -21
  58. package/skills/grill-with-docs/SKILL.md +0 -7
  59. package/skills/grill-with-docs/agents/openai.yaml +0 -5
  60. package/skills/grilling/LICENSE +0 -21
  61. package/skills/grilling/SKILL.md +0 -30
  62. package/skills/grilling/agents/openai.yaml +0 -3
  63. package/skills/handoff/LICENSE +0 -21
  64. package/skills/handoff/SKILL.md +0 -16
  65. package/skills/handoff/agents/openai.yaml +0 -5
  66. package/skills/implement/LICENSE +0 -21
  67. package/skills/implement/SKILL.md +0 -17
  68. package/skills/implement/agents/openai.yaml +0 -5
  69. package/skills/implement-spec/LICENSE +0 -21
  70. package/skills/implement-spec/SKILL.md +0 -40
  71. package/skills/implement-spec/agents/openai.yaml +0 -5
  72. package/skills/improve-codebase-architecture/HTML-REPORT.md +0 -123
  73. package/skills/improve-codebase-architecture/LICENSE +0 -21
  74. package/skills/improve-codebase-architecture/SKILL.md +0 -71
  75. package/skills/improve-codebase-architecture/agents/openai.yaml +0 -5
  76. package/skills/pr/CREDITS.md +0 -3
  77. package/skills/pr/LICENSE +0 -21
  78. package/skills/pr/SKILL.md +0 -170
  79. package/skills/pr/agents/openai.yaml +0 -3
  80. package/skills/prototype/LICENSE +0 -21
  81. package/skills/prototype/LOGIC.md +0 -67
  82. package/skills/prototype/SKILL.md +0 -26
  83. package/skills/prototype/UI.md +0 -112
  84. package/skills/prototype/agents/openai.yaml +0 -3
  85. package/skills/research/LICENSE +0 -21
  86. package/skills/research/SKILL.md +0 -12
  87. package/skills/research/agents/openai.yaml +0 -3
  88. package/skills/retro/LICENSE +0 -21
  89. package/skills/retro/SKILL.md +0 -44
  90. package/skills/retro/agents/openai.yaml +0 -5
  91. package/skills/tdd/LICENSE +0 -21
  92. package/skills/tdd/SKILL.md +0 -38
  93. package/skills/tdd/agents/openai.yaml +0 -3
  94. package/skills/tdd/mocking.md +0 -59
  95. package/skills/tdd/tests.md +0 -77
  96. package/skills/teach/GLOSSARY-FORMAT.md +0 -35
  97. package/skills/teach/LEARNING-RECORD-FORMAT.md +0 -46
  98. package/skills/teach/LICENSE +0 -21
  99. package/skills/teach/MISSION-FORMAT.md +0 -31
  100. package/skills/teach/RESOURCES-FORMAT.md +0 -32
  101. package/skills/teach/SKILL.md +0 -140
  102. package/skills/teach/agents/openai.yaml +0 -5
  103. package/skills/to-questionnaire/LICENSE +0 -21
  104. package/skills/to-questionnaire/SKILL.md +0 -54
  105. package/skills/to-questionnaire/agents/openai.yaml +0 -5
  106. package/skills/to-spec/LICENSE +0 -21
  107. package/skills/to-spec/SKILL.md +0 -75
  108. package/skills/to-spec/agents/openai.yaml +0 -5
  109. package/skills/to-tickets/LICENSE +0 -21
  110. package/skills/to-tickets/SKILL.md +0 -105
  111. package/skills/to-tickets/agents/openai.yaml +0 -5
  112. package/skills/triage/AGENT-BRIEF.md +0 -207
  113. package/skills/triage/LICENSE +0 -21
  114. package/skills/triage/OUT-OF-SCOPE.md +0 -105
  115. package/skills/triage/SKILL.md +0 -112
  116. package/skills/triage/agents/openai.yaml +0 -5
  117. package/skills/wait-what/LICENSE +0 -21
  118. package/skills/wait-what/SKILL.md +0 -7
  119. package/skills/wait-what/agents/openai.yaml +0 -5
  120. package/skills/wayfinder/LICENSE +0 -21
  121. package/skills/wayfinder/SKILL.md +0 -128
  122. package/skills/wayfinder/agents/openai.yaml +0 -5
  123. package/skills/wizard/LICENSE +0 -21
  124. package/skills/wizard/SKILL.md +0 -44
  125. package/skills/wizard/agents/openai.yaml +0 -3
  126. package/skills/wizard/template.sh +0 -223
  127. package/skills/writing-for-agents/LICENSE +0 -21
  128. package/skills/writing-for-agents/SKILL-MECHANICS.md +0 -22
  129. package/skills/writing-for-agents/SKILL.md +0 -81
  130. package/skills/writing-for-agents/agents/openai.yaml +0 -3
package/dist/copies.js CHANGED
@@ -11,7 +11,7 @@ import { changed } from './translate.js';
11
11
  import { relayed } from './relay.js';
12
12
  const BRANCH = 'main';
13
13
  const PROVENANCE = '_provenance';
14
- const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
14
+ const IDENTITY = { GIT_COMMITTER_NAME: 'Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
15
15
  function causeOf(error) {
16
16
  return error instanceof Error ? error.message : String(error);
17
17
  }
@@ -133,7 +133,7 @@ async function ignoreRules(repository, writable, copy) {
133
133
  ].map(pattern);
134
134
  const granted = readOnly.filter((path) => !protectedPath(path)).map(pattern);
135
135
  await ruled(join(repository, '.gitignore'), [
136
- '# House Kit writes this file and .ignore. The paths below are read-only House',
136
+ '# Kit writes this file and .ignore. The paths below are read-only House',
137
137
  '# content: Git does not track them, and House refuses a change to them even',
138
138
  '# when this file is edited or a path is force-added.',
139
139
  '/.gitignore',
@@ -148,7 +148,7 @@ async function ignoreRules(repository, writable, copy) {
148
148
  '',
149
149
  ].join('\n'));
150
150
  await ruled(join(repository, '.ignore'), [
151
- '# House Kit writes this file so native search still reads the read-only House',
151
+ '# Kit writes this file so native search still reads the read-only House',
152
152
  '# content that .gitignore keeps out of Git.',
153
153
  `!${PROVENANCE}`,
154
154
  ...[...house, ...granted].map((rule) => `!${rule}`),
@@ -218,7 +218,7 @@ export class LocalCopies {
218
218
  async mapping() {
219
219
  await this.answering;
220
220
  return [...this.copies.values()]
221
- .filter((copy) => copy.erasing === undefined)
221
+ .filter((copy) => copy.unfollowed === undefined)
222
222
  .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
223
223
  .sort();
224
224
  }
@@ -250,30 +250,24 @@ export class LocalCopies {
250
250
  return this.holding([...new Set([...held().map((copy) => copy.key), ...synced.keys()])].sort(), async () => {
251
251
  const said = [];
252
252
  let refused = false;
253
+ const mark = refreshing ? 'said' : 'unsaid';
253
254
  for (const copy of held()) {
254
- const returned = synced.has(copy.key);
255
- if (returned && copy.erasing === undefined)
255
+ if (synced.has(copy.key) || copy.unfollowed === 'said' || copy.unfollowed === mark)
256
256
  continue;
257
- try {
258
- await this.erase(copy);
259
- said.push(`${rootOf(copy.room_handle)}: ${returned ? '' : 'no longer synced to this Environment; '}erased from the Local copy`);
260
- }
261
- catch (error) {
262
- refused = true;
263
- said.push(`${rootOf(copy.room_handle)}: ${causeOf(error)}`);
264
- }
257
+ await this.save({ ...copy, unfollowed: mark });
258
+ if (refreshing)
259
+ said.push(`${rootOf(copy.room_handle)}: no longer followed; kept as it is`);
265
260
  }
266
261
  const current = [];
267
262
  for (const [key, handle] of synced) {
268
263
  try {
269
264
  const held = this.copies.get(key);
270
- if (held?.erasing)
271
- continue;
272
265
  if (held === undefined || !(await exists(held.repository))) {
273
266
  said.push(await this.materialize(key, handle));
274
267
  continue;
275
268
  }
276
- current.push(held.room_handle === handle ? held : await this.save({ ...held, room_handle: handle }));
269
+ const { unfollowed, ...followed } = held;
270
+ current.push(unfollowed === undefined && held.room_handle === handle ? held : await this.save({ ...followed, room_handle: handle }));
277
271
  }
278
272
  catch (error) {
279
273
  refused = true;
@@ -298,8 +292,6 @@ export class LocalCopies {
298
292
  return;
299
293
  await this.exclusive(key, async () => {
300
294
  const copy = this.copies.get(key);
301
- if (copy === undefined || copy.erasing)
302
- return;
303
295
  let moves = copy.moves;
304
296
  for (const { from, to } of report.moves) {
305
297
  const held = moves.find((move) => move.worktree === report.worktree && move.to === from);
@@ -355,12 +347,6 @@ export class LocalCopies {
355
347
  this.copies.set(copy.key, copy);
356
348
  return copy;
357
349
  }
358
- async erase(copy) {
359
- await this.save({ ...copy, erasing: true });
360
- await rm(copy.repository, { recursive: true, force: true });
361
- await rm(recordOf(copy.key), { force: true });
362
- this.copies.delete(copy.key);
363
- }
364
350
  async read(handle) {
365
351
  const prefixes = [rootOf(handle)];
366
352
  const discovery = await this.house.post('/kit/door/discover', { prefixes });
@@ -402,7 +388,7 @@ export class LocalCopies {
402
388
  throw new Error('House serves nothing of it to copy');
403
389
  const repository = join(localCopies(), 'rooms', read.anchor);
404
390
  if (await exists(repository))
405
- throw new Error(`${repository} already exists and House Kit leaves it as it is`);
391
+ throw new Error(`${repository} already exists and Kit leaves it as it is`);
406
392
  const staging = join(dirname(repository), `.${randomUUID()}`);
407
393
  await mkdir(staging, { recursive: true });
408
394
  let house;
@@ -560,8 +546,6 @@ export class LocalCopies {
560
546
  }
561
547
  async pushed(key, worktree, selector, caller) {
562
548
  let copy = this.copies.get(key);
563
- if (copy === undefined || copy.erasing)
564
- return outside(worktree);
565
549
  if (copy.push?.state === 'submitted')
566
550
  return this.settle(copy, caller);
567
551
  const selected = await run(worktree, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${selector}^{commit}`]);
@@ -676,8 +660,10 @@ export class LocalCopies {
676
660
  }
677
661
  if (outcome.refused.code === 'expired')
678
662
  return this.recovered(copy);
679
- const rebased = rebaseFixes(outcome.refused);
680
663
  let settled = await this.save(without(copy));
664
+ if (settled.unfollowed !== undefined)
665
+ return { refused: true, text: outcome.refused.text };
666
+ const rebased = rebaseFixes(outcome.refused);
681
667
  try {
682
668
  settled = (await this.caughtUp([settled], true))[0];
683
669
  }
package/dist/end.js CHANGED
@@ -1,14 +1,15 @@
1
+ import { KIT_RELEASE } from './clis.js';
1
2
  import { forgetRejected } from './home.js';
2
3
  import { installCommand } from './placement.js';
3
4
  import { killDescendants } from './scope.js';
4
- export const REPLACED = 'another Kit started for this Environment';
5
+ export const REPLACED = 'another Kit process started for this Environment';
5
6
  function exit(reason, status) {
6
7
  process.stderr.write(`kit: ${reason}\n`);
7
8
  killDescendants();
8
9
  process.exit(status);
9
10
  }
10
11
  export function end(reason) {
11
- exit(reason, 0);
12
+ exit(reason, process.env.HOUSE_KIT_RESIDENT === String(process.pid) ? 0 : 1);
12
13
  }
13
14
  export function restart(reason) {
14
15
  exit(reason, 1);
@@ -17,6 +18,9 @@ export function updateRequired() {
17
18
  const command = installCommand();
18
19
  end(command === null ? 'This Kit is too old for House; install it again.' : `This Kit is too old for House. Update it: ${command}`);
19
20
  }
21
+ export function newerThanHouse(release, command) {
22
+ end(`Kit ${KIT_RELEASE} is newer than this Building supports (Kit ${release}). Install Kit ${release}: ${command}`);
23
+ }
20
24
  export async function signInAgain(rejected) {
21
25
  await forgetRejected(rejected);
22
26
  restart('sign in again');
package/dist/events.js CHANGED
@@ -2,7 +2,7 @@ const COMMAND = { shown: 'command' };
2
2
  const HIDDEN = { shown: 'hidden' };
3
3
  const FINISHED = new Set(['completed', 'failed']);
4
4
  export const RUNNING_JOB = new Set(['running', 'paused']);
5
- const ROUTINE_TARGETS = { send_routine_request: 'access', cancel_routine_request: 'request' };
5
+ const MESSAGE_TARGETS = { send_routine_request: 'access', cancel_routine_request: 'request', send_mail: 'request' };
6
6
  function words(line) {
7
7
  const found = [];
8
8
  let word = null;
@@ -48,7 +48,7 @@ export function houseMessage(line) {
48
48
  catch {
49
49
  return null;
50
50
  }
51
- const argument = ROUTINE_TARGETS[call.command];
51
+ const argument = MESSAGE_TARGETS[call.command];
52
52
  if (argument === undefined)
53
53
  return null;
54
54
  const target = call.arguments?.[argument];
package/dist/files.js CHANGED
@@ -1,42 +1,128 @@
1
- import { createHash } from 'node:crypto';
2
- import { mkdir, writeFile } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { HouseRefusal, retrying } from './api.js';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { createWriteStream } from 'node:fs';
3
+ import { mkdir, rename, rm } from 'node:fs/promises';
4
+ import { basename, dirname, join } from 'node:path';
5
+ import { Readable, Transform } from 'node:stream';
6
+ import { pipeline } from 'node:stream/promises';
7
+ import { parseDocument } from 'yaml';
8
+ import { retryDelay } from './api.js';
9
+ import { Unsettled } from './mcp.js';
5
10
  import { relayed } from './relay.js';
6
- export function verified(bytes, sha256, name) {
7
- if (createHash('sha256').update(bytes).digest('hex') !== sha256)
8
- throw new Error(`${name} did not arrive whole; download it again`);
9
- return bytes;
10
- }
11
- export async function placeFiles(house, directory, files, signal) {
12
- const paths = [];
13
- for (const file of files) {
14
- const original = await retrying(() => house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal), signal);
15
- const answer = await fetch(relayed(original.download.url), { signal });
16
- if (!answer.ok)
17
- throw new Error(`${file.name} did not download: ${answer.status}`);
18
- const folder = join(directory, '.house', 'files', original.message, file.version);
19
- await mkdir(join(directory, '.house')).catch((error) => {
20
- if (error.code !== 'EEXIST')
11
+ const STALLED_ROUNDS = 8;
12
+ const LINK = /\]\((\/rooms\/[^/)\s]+\/[^)\s]+)\)/gu;
13
+ export function linkedPaths(text) {
14
+ return [...new Set(Array.from(text.matchAll(LINK), (link) => link[1]))];
15
+ }
16
+ export function inspectedFile(answer) {
17
+ const document = parseDocument(answer);
18
+ if (document.errors.length > 0)
19
+ return null;
20
+ const inspected = document.toJS();
21
+ if (typeof inspected?.download !== 'string')
22
+ return null;
23
+ return {
24
+ download: inspected.download,
25
+ sha256: String(inspected.sha256),
26
+ media_type: typeof inspected.media_type === 'string' ? inspected.media_type : null,
27
+ };
28
+ }
29
+ function paused(milliseconds) {
30
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
31
+ }
32
+ export function storeRefusal(act, status) {
33
+ return new Error(`the ${act} answered ${status}; ${act} the file again`);
34
+ }
35
+ export async function uploaded(declared, uploading) {
36
+ let upload = declared;
37
+ let idle = 0;
38
+ let stalled = 0;
39
+ for (;;) {
40
+ let sent = 0;
41
+ let failure = null;
42
+ for (const { part, url } of upload.parts) {
43
+ const start = (part - 1) * upload.part_bytes;
44
+ try {
45
+ await uploading.send(url, start, Math.min(start + upload.part_bytes, uploading.bytes));
46
+ sent++;
47
+ }
48
+ catch (error) {
49
+ failure = error;
50
+ break;
51
+ }
52
+ }
53
+ if (sent > 0) {
54
+ idle = 0;
55
+ stalled = 0;
56
+ }
57
+ else {
58
+ if (failure !== null && ++stalled >= STALLED_ROUNDS)
59
+ throw failure;
60
+ // A round that sent no part, its store unreachable or not yet started, is followed by the next finishing call on the jittered curve.
61
+ await paused(retryDelay(idle++));
62
+ }
63
+ let answer = await finishing(uploading, upload.id);
64
+ while ('checking' in answer) {
65
+ // A finishing call that finds House still checking the stored bytes is sent again after five seconds, the cadence at which House takes up a check it handed over.
66
+ await paused(5_000);
67
+ answer = await finishing(uploading, upload.id);
68
+ }
69
+ if (!('upload' in answer))
70
+ return answer;
71
+ upload = answer.upload;
72
+ }
73
+ }
74
+ async function finishing(uploading, upload) {
75
+ for (let unsettled = 1;; unsettled++) {
76
+ try {
77
+ return await uploading.finish(upload);
78
+ }
79
+ catch (error) {
80
+ if (!(error instanceof Unsettled) || unsettled === STALLED_ROUNDS)
21
81
  throw error;
22
- });
23
- await mkdir(folder, { recursive: true });
24
- const path = join(folder, file.name);
25
- await writeFile(path, verified(Buffer.from(await answer.arrayBuffer()), file.sha256, file.name));
26
- paths.push(path);
82
+ }
83
+ // A finishing call House did not settle, unreached or refused short of final, is sent again on the jittered curve as a conversation resends it, so a dropped connection or a House restart resumes the upload instead of ending it.
84
+ await paused(retryDelay(unsettled - 1));
27
85
  }
28
- return paths;
29
- }
30
- export function uploadBytes(upload, bytes) {
31
- return retrying(async () => {
32
- const answer = await fetch(relayed(upload.url), {
33
- method: 'POST',
34
- headers: { 'content-type': 'application/octet-stream', 'x-house-byte-operation': upload.operation },
35
- body: bytes,
86
+ }
87
+ async function drained(body) {
88
+ for await (const _chunk of body)
89
+ ;
90
+ }
91
+ export async function downloaded(address, fetching, destination, name) {
92
+ for (let asked = 1;; asked++) {
93
+ const { download, sha256 } = await address();
94
+ const answer = await fetching(download);
95
+ if (answer.status === 403 && asked === 1) {
96
+ await drained(answer.body);
97
+ continue;
98
+ }
99
+ if (answer.status !== 200) {
100
+ await drained(answer.body);
101
+ throw storeRefusal('download', answer.status);
102
+ }
103
+ await mkdir(dirname(destination), { recursive: true });
104
+ const partial = join(dirname(destination), `.${basename(destination)}.${randomUUID()}`);
105
+ const digest = createHash('sha256');
106
+ const hashed = new Transform({
107
+ transform(chunk, _encoding, done) {
108
+ digest.update(chunk);
109
+ done(null, chunk);
110
+ },
36
111
  });
37
- const text = await answer.text();
38
- if (!answer.ok)
39
- throw new HouseRefusal(upload.url, answer.status, text);
40
- return JSON.parse(text);
41
- });
112
+ try {
113
+ await pipeline(answer.body, hashed, createWriteStream(partial, { flags: 'wx' }));
114
+ if (digest.digest('hex') !== sha256)
115
+ throw new Error(`${name} did not arrive whole; download it again`);
116
+ await rename(partial, destination);
117
+ }
118
+ catch (error) {
119
+ await rm(partial, { force: true });
120
+ throw error;
121
+ }
122
+ return;
123
+ }
124
+ }
125
+ export async function fetched(url, signal) {
126
+ const answer = await fetch(relayed(url), { signal });
127
+ return { status: answer.status, body: answer.body ?? Readable.from([]) };
42
128
  }
package/dist/git.js CHANGED
@@ -8,7 +8,7 @@ export function localCopies() {
8
8
  return join(workspaceRoot(), 'local-copies');
9
9
  }
10
10
  export const HOUSE_REF = 'refs/house/received';
11
- const COMMITTER = 'House Kit <house-kit@localhost>';
11
+ const COMMITTER = 'Kit <house-kit@localhost>';
12
12
  export function run(cwd, args, input, env = {}) {
13
13
  return new Promise((resolve, reject) => {
14
14
  const child = spawn('git', args, { cwd, env: { ...withoutProxy(), ...env } });
package/dist/house-cli.js CHANGED
@@ -1,14 +1,17 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
3
  import { request, setGlobalProxyFromEnv } from 'node:http';
4
- import { basename, dirname, extname, join, resolve } from 'node:path';
5
- import { verified } from './files.js';
4
+ import { request as httpsRequest } from 'node:https';
5
+ import { extname, join, resolve } from 'node:path';
6
+ import { Readable } from 'node:stream';
7
+ import { pipeline } from 'node:stream/promises';
8
+ import { downloaded, inspectedFile, storeRefusal, uploaded } from './files.js';
6
9
  import { HOUSE_REF } from './git.js';
7
10
  import { ownerSocket, readOwnAgent } from './home.js';
8
- import { callHouse, rpc, UNREACHABLE } from './mcp.js';
9
- import { refusalOf } from './refusals.js';
11
+ import { callHouse, rpc, UNREACHABLE, Unsettled } from './mcp.js';
12
+ import { refusalOf, retryable } from './refusals.js';
10
13
  import { relayed, selectRelay } from './relay.js';
11
- import { parse, stringify } from 'yaml';
14
+ import { parse } from 'yaml';
12
15
  const USAGE = "usage: house <tool> ['<arguments as JSON>']";
13
16
  const PUSH_USAGE = 'usage: house push [commit] [--owner]';
14
17
  const PUSH = "push [commit]: sends the Local copy's committed changes to House";
@@ -51,15 +54,12 @@ const MEDIA_TYPES = new Map([
51
54
  ['xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
52
55
  ['zip', 'application/zip'],
53
56
  ]);
54
- function refused(path, method, status, text, local) {
57
+ function refused(path, status, text, local) {
55
58
  const refusal = refusalOf(text);
56
59
  if (refusal !== null)
57
60
  return refusal.text;
58
61
  if (status === 401)
59
62
  return REVOKED;
60
- const act = method === 'GET' ? 'download' : 'upload';
61
- if (URL.canParse(path))
62
- return `the ${act} answered ${status}; ${act} the file again`;
63
63
  if (status === 502 && local)
64
64
  return text;
65
65
  return path === '/' ? unconfirmed(status) : `${path} answered ${status}`;
@@ -67,26 +67,23 @@ function refused(path, method, status, text, local) {
67
67
  function unconfirmed(status) {
68
68
  return `House answered ${status} ${UNCONFIRMED}`;
69
69
  }
70
- function exchanged(path, method, body, headers = { 'content-type': 'application/json' }, to = socketPath) {
70
+ function bridged(path, body, to = socketPath) {
71
71
  return new Promise((resolve, reject) => {
72
- const sent = request({ socketPath: to, path, method, headers }, (answer) => {
72
+ const sent = request({ socketPath: to, path, method: 'POST', headers: { 'content-type': 'application/json' } }, (answer) => {
73
73
  const chunks = [];
74
74
  answer.on('data', (chunk) => chunks.push(chunk));
75
75
  answer.on('end', () => {
76
- const bytes = Buffer.concat(chunks);
76
+ const text = Buffer.concat(chunks).toString('utf8');
77
77
  if (answer.statusCode === 200)
78
- resolve(bytes);
78
+ resolve(text);
79
79
  else
80
- reject(new Error(refused(path, method, answer.statusCode, bytes.toString('utf8'), answer.headers['content-type'] === undefined)));
80
+ reject(new Error(refused(path, answer.statusCode, text, answer.headers['content-type'] === undefined)));
81
81
  });
82
82
  });
83
83
  sent.on('error', (error) => reject(new Error(error.code === 'EPERM' || error.code === 'EACCES' ? SANDBOXED : CLOSED)));
84
- sent.end(body === undefined ? undefined : body instanceof Buffer ? body : JSON.stringify(body));
84
+ sent.end(JSON.stringify(body));
85
85
  });
86
86
  }
87
- async function bridged(path, body, headers, to = socketPath) {
88
- return (await exchanged(path, 'POST', body, headers, to)).toString('utf8');
89
- }
90
87
  async function direct(message) {
91
88
  const ownAgent = await readOwnAgent();
92
89
  if (ownAgent === null)
@@ -94,11 +91,14 @@ async function direct(message) {
94
91
  selectRelay(process.env.HOUSE_KIT_RELAY || ownAgent.relay);
95
92
  setGlobalProxyFromEnv();
96
93
  const answer = await callHouse(ownAgent.house, ownAgent.credential, message).catch(() => {
97
- throw new Error(UNREACHABLE);
94
+ throw new Unsettled(UNREACHABLE);
98
95
  });
99
96
  if (answer.status === 200)
100
97
  return answer.text;
101
- throw new Error(answer.status === 401 ? DISCONNECTED : (refusalOf(answer.text)?.text ?? unconfirmed(answer.status)));
98
+ if (answer.status === 401)
99
+ throw new Error(DISCONNECTED);
100
+ const refusal = refusalOf(answer.text)?.text ?? unconfirmed(answer.status);
101
+ throw retryable(answer.text) === false ? new Error(refusal) : new Unsettled(refusal);
102
102
  }
103
103
  async function mcp(method, params) {
104
104
  const message = rpc(method, params);
@@ -110,31 +110,32 @@ async function mcp(method, params) {
110
110
  async function tools() {
111
111
  return (await mcp('tools/list', {})).tools;
112
112
  }
113
- async function localFile(path) {
114
- try {
115
- return await readFile(path);
116
- }
117
- catch (error) {
118
- throw new Error(error.code === 'EISDIR'
119
- ? `${path} is a folder; upload one file at a time`
120
- : `${path} is not a readable file`);
121
- }
113
+ function unreadable(path, error) {
114
+ return new Error(error.code === 'EISDIR'
115
+ ? `${path} is a folder; upload one file at a time`
116
+ : `${path} is not a readable file`);
122
117
  }
123
- async function transferred(url, method, bytes, headers) {
124
- if (socketPath !== undefined)
125
- return exchanged(url, method, bytes, headers);
126
- setGlobalProxyFromEnv();
127
- const answer = await fetch(relayed(url), { method, ...(headers === undefined ? {} : { headers }), ...(bytes === undefined ? {} : { body: bytes }) }).catch(() => {
128
- throw new Error(UNREACHABLE);
118
+ function opened(url, method, headers, body) {
119
+ return new Promise((answered, failed) => {
120
+ const local = socketPath !== undefined;
121
+ const target = local ? null : relayed(url);
122
+ if (!local)
123
+ setGlobalProxyFromEnv();
124
+ const sent = target === null
125
+ ? request({ socketPath, path: url, method, headers }, answered)
126
+ : (target.protocol === 'https:' ? httpsRequest : request)(target, { method, headers }, answered);
127
+ sent.on('error', (error) => failed(new Error(!local ? UNREACHABLE : error.code === 'EPERM' || error.code === 'EACCES' ? SANDBOXED : CLOSED)));
128
+ if (body === undefined)
129
+ sent.end();
130
+ else
131
+ pipeline(body, sent).catch(() => undefined);
129
132
  });
130
- const received = Buffer.from(await answer.arrayBuffer());
131
- if (answer.status === 200)
132
- return received;
133
- throw new Error(refused(url, method, answer.status, received.toString('utf8'), false));
134
133
  }
135
- async function sent(url, bytes, operation) {
136
- const headers = { 'content-type': 'application/octet-stream', 'x-house-byte-operation': operation };
137
- return (await transferred(url, 'POST', bytes, headers)).toString('utf8');
134
+ async function sentPart(url, file, start, end) {
135
+ const answer = await opened(url, 'PUT', { 'content-length': String(end - start) }, end === start ? undefined : createReadStream(file, { start, end: end - 1 }));
136
+ await answer.toArray();
137
+ if (answer.statusCode !== 200)
138
+ throw storeRefusal('upload', answer.statusCode);
138
139
  }
139
140
  async function download(args) {
140
141
  const { flags, positionals: [argument] } = accepted(args, ['--help'], 1, DOWNLOAD_USAGE);
@@ -143,58 +144,52 @@ async function download(args) {
143
144
  const { file, ...selected } = argumentsOf(argument);
144
145
  if (typeof file !== 'string' || file === '')
145
146
  throw new Error('download needs "file", the local file to write');
146
- const inspected = parse(await called('inspect', selected));
147
- if (typeof inspected?.download !== 'string')
148
- throw new Error(`${String(selected.path)} holds no binary file; shell reads its text`);
149
- const bytes = verified(await transferred(inspected.download, 'GET'), String(inspected.sha256), String(selected.path));
150
147
  const written = resolve(file);
151
- await mkdir(dirname(written), { recursive: true });
152
- await writeFile(written, bytes);
148
+ await downloaded(async () => {
149
+ const file = inspectedFile(await called('inspect', selected));
150
+ if (file === null)
151
+ throw new Error(`${String(selected.path)} holds no binary file; shell reads its text`);
152
+ return file;
153
+ }, async (url) => {
154
+ const answer = await opened(url, 'GET', {});
155
+ return { status: answer.statusCode, body: answer };
156
+ }, written, String(selected.path));
153
157
  return written;
154
158
  }
155
159
  async function described(file) {
156
- const bytes = await localFile(file);
160
+ const digest = createHash('sha256');
161
+ let bytes = 0;
162
+ try {
163
+ for await (const chunk of createReadStream(file)) {
164
+ digest.update(chunk);
165
+ bytes += chunk.length;
166
+ }
167
+ }
168
+ catch (error) {
169
+ throw unreadable(file, error);
170
+ }
157
171
  const mediaType = MEDIA_TYPES.get(extname(file).slice(1).toLowerCase());
158
- return {
159
- bytes,
160
- description: {
161
- bytes: bytes.length,
162
- sha256: createHash('sha256').update(bytes).digest('hex'),
163
- ...(mediaType === undefined ? {} : { media_type: mediaType }),
164
- },
165
- };
172
+ return { bytes, sha256: digest.digest('hex'), ...(mediaType === undefined ? {} : { media_type: mediaType }) };
173
+ }
174
+ async function finished(upload, room) {
175
+ const text = await called('upload', { upload, room_ref: room });
176
+ const answer = parse(text);
177
+ return answer !== null && ('upload' in answer || 'checking' in answer) ? answer : { text };
166
178
  }
167
179
  async function upload({ file, ...destination }) {
168
180
  if (typeof file !== 'string' || file === '')
169
181
  throw new Error('upload needs "file", a local file');
170
- const { bytes, description } = await described(file);
182
+ const description = await described(file);
171
183
  const answer = await called('upload', { ...destination, ...description });
172
184
  const declared = parse(answer);
173
- if (declared.upload === undefined)
185
+ if (declared?.upload === undefined)
174
186
  return answer;
175
- const written = JSON.parse(await sent(declared.upload.url, bytes, declared.upload.operation));
176
- return stringify(written, { lineWidth: 0 }).trimEnd();
177
- }
178
- async function appendRecord(args) {
179
- const held = new Map();
180
- const attachments = Array.isArray(args.attachments)
181
- ? await Promise.all(args.attachments.map(async (attachment) => {
182
- if (typeof attachment !== 'string')
183
- return attachment;
184
- const { bytes, description } = await described(attachment);
185
- held.set(description.sha256, bytes);
186
- return { name: basename(attachment), ...description };
187
- }))
188
- : args.attachments;
189
- const record = { ...args, attachments };
190
- for (;;) {
191
- const answer = await called('append_record', record);
192
- const { upload } = parse(answer);
193
- if (upload === undefined)
194
- return answer;
195
- for (const grant of upload)
196
- await sent(grant.url, held.get(grant.sha256), grant.operation);
197
- }
187
+ const accepted = await uploaded(declared.upload, {
188
+ bytes: description.bytes,
189
+ send: (url, start, end) => sentPart(url, file, start, end),
190
+ finish: (id) => finished(id, destination.room_ref),
191
+ });
192
+ return accepted.text;
198
193
  }
199
194
  function unexpected(argument, usage) {
200
195
  return new Error(`unexpected argument ${argument}; ${usage}`);
@@ -220,7 +215,7 @@ async function push(args) {
220
215
  throw new Error('--owner is not for Agent conversations; run house push without it');
221
216
  if (!owner && socketPath === undefined)
222
217
  throw new Error('outside an Agent conversation, house push needs --owner');
223
- const pushed = JSON.parse(await bridged('/git/push', { cwd: process.cwd(), commit }, undefined, owner ? ownerSocket() : socketPath));
218
+ const pushed = JSON.parse(await bridged('/git/push', { cwd: process.cwd(), commit }, owner ? ownerSocket() : socketPath));
224
219
  if (pushed.refused)
225
220
  throw new Error(pushed.text);
226
221
  return pushed.text;
@@ -228,7 +223,7 @@ async function push(args) {
228
223
  async function fetched(args) {
229
224
  if (accepted(args, ['--help'], 0, FETCH_USAGE).flags.has('--help'))
230
225
  return FETCH_USAGE;
231
- const answer = JSON.parse(await bridged('/git/fetch', {}, undefined, socketPath ?? ownerSocket()));
226
+ const answer = JSON.parse(await bridged('/git/fetch', {}, socketPath ?? ownerSocket()));
232
227
  if (answer.refused)
233
228
  throw new Error(answer.text);
234
229
  return answer.text;
@@ -263,7 +258,7 @@ async function house(args) {
263
258
  return [...(tool.description === undefined ? [] : [tool.description]), JSON.stringify(tool.inputSchema)].join('\n');
264
259
  }
265
260
  const parsed = argumentsOf(argument);
266
- return verb === 'upload' ? upload(parsed) : verb === 'append_record' ? appendRecord(parsed) : called(verb, parsed);
261
+ return verb === 'upload' ? upload(parsed) : called(verb, parsed);
267
262
  }
268
263
  async function called(name, args) {
269
264
  const result = await mcp('tools/call', { name, arguments: args });