@agentshouse/kit 0.2.1-c7f53ff9eaf98 → 0.2.1-c845e172df1af

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.
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
  }
@@ -58,6 +58,9 @@ function expiredAuthority(error) {
58
58
  return undefined;
59
59
  }
60
60
  }
61
+ function grouped(items, size) {
62
+ return items.flatMap((_, index) => (index % size === 0 ? [items.slice(index, index + size)] : []));
63
+ }
61
64
  function covers(path, prefix) {
62
65
  return prefix === '' || path === prefix || path.startsWith(`${prefix}/`);
63
66
  }
@@ -130,7 +133,7 @@ async function ignoreRules(repository, writable, copy) {
130
133
  ].map(pattern);
131
134
  const granted = readOnly.filter((path) => !protectedPath(path)).map(pattern);
132
135
  await ruled(join(repository, '.gitignore'), [
133
- '# 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',
134
137
  '# content: Git does not track them, and House refuses a change to them even',
135
138
  '# when this file is edited or a path is force-added.',
136
139
  '/.gitignore',
@@ -145,7 +148,7 @@ async function ignoreRules(repository, writable, copy) {
145
148
  '',
146
149
  ].join('\n'));
147
150
  await ruled(join(repository, '.ignore'), [
148
- '# 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',
149
152
  '# content that .gitignore keeps out of Git.',
150
153
  `!${PROVENANCE}`,
151
154
  ...[...house, ...granted].map((rule) => `!${rule}`),
@@ -215,53 +218,56 @@ export class LocalCopies {
215
218
  async mapping() {
216
219
  await this.answering;
217
220
  return [...this.copies.values()]
218
- .filter((copy) => copy.erasing === undefined)
221
+ .filter((copy) => copy.unfollowed === undefined)
219
222
  .map((copy) => `${rootOf(copy.room_handle)}: ${copy.repository}`)
220
223
  .sort();
221
224
  }
222
- async connected() {
223
- const copied = await this.answered(() => this.fetched(false));
224
- if (copied.refused)
225
- logged(copied.text);
225
+ connected() {
226
+ return this.selected();
227
+ }
228
+ async changed(rooms) {
229
+ if (rooms.length > 0)
230
+ await this.selected(rooms);
226
231
  }
227
232
  fetch() {
228
233
  return this.answered(() => this.fetched(true));
229
234
  }
235
+ async selected(rooms) {
236
+ const copied = await this.answered(() => this.fetched(false, rooms));
237
+ if (copied.refused)
238
+ logged(copied.text);
239
+ }
230
240
  answered(work) {
231
241
  const answered = this.answering.then(work);
232
242
  this.answering = answered.catch(() => undefined);
233
243
  return answered;
234
244
  }
235
- async fetched(refreshing) {
236
- const { local_copy: answer } = await this.house.post('/kit/local-copy/selection', {});
245
+ async fetched(refreshing, rooms) {
246
+ const { local_copy: answer } = await this.house.post('/kit/local-copy/selection', rooms === undefined ? {} : { rooms });
237
247
  const synced = new Map(answer.rooms.map((room) => [`rooms/${room.room_ref}`, room.room_handle]));
238
- return this.holding([...new Set([...this.copies.keys(), ...synced.keys()])].sort(), async () => {
248
+ const named = rooms === undefined ? null : new Set(rooms.map((room) => `rooms/${room}`));
249
+ const held = () => [...this.copies.values()].filter((copy) => named === null || named.has(copy.key));
250
+ return this.holding([...new Set([...held().map((copy) => copy.key), ...synced.keys()])].sort(), async () => {
239
251
  const said = [];
240
252
  let refused = false;
241
- for (const copy of [...this.copies.values()]) {
242
- const returned = synced.has(copy.key);
243
- if (returned && copy.erasing === undefined)
253
+ const mark = refreshing ? 'said' : 'unsaid';
254
+ for (const copy of held()) {
255
+ if (synced.has(copy.key) || copy.unfollowed === 'said' || copy.unfollowed === mark)
244
256
  continue;
245
- try {
246
- await this.erase(copy);
247
- said.push(`${rootOf(copy.room_handle)}: ${returned ? '' : 'no longer synced to this Environment; '}erased from the Local copy`);
248
- }
249
- catch (error) {
250
- refused = true;
251
- said.push(`${rootOf(copy.room_handle)}: ${causeOf(error)}`);
252
- }
257
+ await this.save({ ...copy, unfollowed: mark });
258
+ if (refreshing)
259
+ said.push(`${rootOf(copy.room_handle)}: no longer followed; kept as it is`);
253
260
  }
254
261
  const current = [];
255
262
  for (const [key, handle] of synced) {
256
263
  try {
257
264
  const held = this.copies.get(key);
258
- if (held?.erasing)
259
- continue;
260
265
  if (held === undefined || !(await exists(held.repository))) {
261
266
  said.push(await this.materialize(key, handle));
262
267
  continue;
263
268
  }
264
- 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 }));
265
271
  }
266
272
  catch (error) {
267
273
  refused = true;
@@ -269,13 +275,12 @@ export class LocalCopies {
269
275
  }
270
276
  }
271
277
  if (refreshing && current.length > 0) {
272
- try {
273
- const refreshed = await this.refreshed(current);
274
- current.forEach((copy, index) => said.push(...moved(copy, refreshed[index])));
275
- }
276
- catch (error) {
278
+ const failed = await this.refreshed(current).then(() => null, houseCause);
279
+ for (const copy of current)
280
+ said.push(...moved(copy, this.copies.get(copy.key)));
281
+ if (failed !== null) {
277
282
  refused = true;
278
- said.push(houseCause(error));
283
+ said.push(failed);
279
284
  }
280
285
  }
281
286
  return { refused, text: said.length === 0 ? 'Nothing new from House.' : said.join('\n') };
@@ -287,8 +292,6 @@ export class LocalCopies {
287
292
  return;
288
293
  await this.exclusive(key, async () => {
289
294
  const copy = this.copies.get(key);
290
- if (copy === undefined || copy.erasing)
291
- return;
292
295
  let moves = copy.moves;
293
296
  for (const { from, to } of report.moves) {
294
297
  const held = moves.find((move) => move.worktree === report.worktree && move.to === from);
@@ -344,12 +347,6 @@ export class LocalCopies {
344
347
  this.copies.set(copy.key, copy);
345
348
  return copy;
346
349
  }
347
- async erase(copy) {
348
- await this.save({ ...copy, erasing: true });
349
- await rm(copy.repository, { recursive: true, force: true });
350
- await rm(recordOf(copy.key), { force: true });
351
- this.copies.delete(copy.key);
352
- }
353
350
  async read(handle) {
354
351
  const prefixes = [rootOf(handle)];
355
352
  const discovery = await this.house.post('/kit/door/discover', { prefixes });
@@ -391,7 +388,7 @@ export class LocalCopies {
391
388
  throw new Error('House serves nothing of it to copy');
392
389
  const repository = join(localCopies(), 'rooms', read.anchor);
393
390
  if (await exists(repository))
394
- 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`);
395
392
  const staging = join(dirname(repository), `.${randomUUID()}`);
396
393
  await mkdir(staging, { recursive: true });
397
394
  let house;
@@ -450,19 +447,24 @@ export class LocalCopies {
450
447
  return next;
451
448
  }
452
449
  async refreshed(copies) {
453
- const discovery = await this.house.post('/kit/door/discover', {
454
- prefixes: copies.map((copy) => rootOf(copy.room_handle)),
455
- });
456
- const regranted = new Map();
450
+ const discover = (group) => this.house.post('/kit/door/discover', { prefixes: group.map((copy) => rootOf(copy.room_handle)) });
451
+ const [first, ...rest] = copies;
452
+ const { bounds, authorities } = await discover([first]);
453
+ for (const group of grouped(rest, bounds.max_prefixes))
454
+ authorities.push(...(await discover(group)).authorities);
455
+ const unchanged = [];
457
456
  for (const copy of copies) {
458
- const authority = discovery.authorities.find((entry) => entry.room_ref === copy.room_ref);
457
+ const authority = authorities.find((entry) => entry.room_ref === copy.room_ref);
459
458
  if (authority !== undefined &&
460
459
  JSON.stringify([authority.operations.write, authority.protected_paths]) !== JSON.stringify([copy.write, copy.protected])) {
461
- regranted.set(copy.key, await this.save(await this.received(copy)));
460
+ await this.save(await this.received(copy));
461
+ }
462
+ else {
463
+ unchanged.push(copy);
462
464
  }
463
465
  }
464
- const caught = await this.caughtUp(copies.filter((copy) => !regranted.has(copy.key)), false);
465
- return copies.map((copy) => regranted.get(copy.key) ?? caught.find((held) => held.key === copy.key));
466
+ for (const group of grouped(unchanged, bounds.max_prefixes))
467
+ await this.caughtUp(group, false);
466
468
  }
467
469
  async caughtUp(copies, whole) {
468
470
  const caught = new Map(copies.map((copy) => [copy.key, copy]));
@@ -544,8 +546,6 @@ export class LocalCopies {
544
546
  }
545
547
  async pushed(key, worktree, selector, caller) {
546
548
  let copy = this.copies.get(key);
547
- if (copy === undefined || copy.erasing)
548
- return outside(worktree);
549
549
  if (copy.push?.state === 'submitted')
550
550
  return this.settle(copy, caller);
551
551
  const selected = await run(worktree, ['rev-parse', '--verify', '--quiet', '--end-of-options', `${selector}^{commit}`]);
@@ -660,8 +660,10 @@ export class LocalCopies {
660
660
  }
661
661
  if (outcome.refused.code === 'expired')
662
662
  return this.recovered(copy);
663
- const rebased = rebaseFixes(outcome.refused);
664
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);
665
667
  try {
666
668
  settled = (await this.caughtUp([settled], true))[0];
667
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,131 @@
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 { retrying, retryDelay } from './api.js';
8
+ import { Unsettled } from './mcp.js';
5
9
  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
+ const STALLED_ROUNDS = 8;
11
+ function paused(milliseconds) {
12
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
13
+ }
14
+ export function storeRefusal(act, status) {
15
+ return new Error(`the ${act} answered ${status}; ${act} the file again`);
16
+ }
17
+ export async function uploaded(declared, uploading) {
18
+ let upload = declared;
19
+ let idle = 0;
20
+ let stalled = 0;
21
+ for (;;) {
22
+ let sent = 0;
23
+ let failure = null;
24
+ for (const { part, url } of upload.parts) {
25
+ const start = (part - 1) * upload.part_bytes;
26
+ try {
27
+ await uploading.send(url, start, Math.min(start + upload.part_bytes, uploading.bytes));
28
+ sent++;
29
+ }
30
+ catch (error) {
31
+ failure = error;
32
+ break;
33
+ }
34
+ }
35
+ if (sent > 0) {
36
+ idle = 0;
37
+ stalled = 0;
38
+ }
39
+ else {
40
+ if (failure !== null && ++stalled >= STALLED_ROUNDS)
41
+ throw failure;
42
+ // A round that sent no part, its store unreachable or not yet started, is followed by the next finishing call on the jittered curve.
43
+ await paused(retryDelay(idle++));
44
+ }
45
+ let answer = await finishing(uploading, upload.id);
46
+ while ('checking' in answer) {
47
+ // 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.
48
+ await paused(5_000);
49
+ answer = await finishing(uploading, upload.id);
50
+ }
51
+ if (!('upload' in answer))
52
+ return answer;
53
+ upload = answer.upload;
54
+ }
55
+ }
56
+ async function finishing(uploading, upload) {
57
+ for (let unsettled = 1;; unsettled++) {
58
+ try {
59
+ return await uploading.finish(upload);
60
+ }
61
+ catch (error) {
62
+ if (!(error instanceof Unsettled) || unsettled === STALLED_ROUNDS)
63
+ throw error;
64
+ }
65
+ // 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.
66
+ await paused(retryDelay(unsettled - 1));
67
+ }
68
+ }
69
+ async function drained(body) {
70
+ for await (const _chunk of body)
71
+ ;
72
+ }
73
+ export async function downloaded(address, fetching, destination, name) {
74
+ for (let asked = 1;; asked++) {
75
+ const { download, sha256 } = await address();
76
+ const answer = await fetching(download);
77
+ if (answer.status === 403 && asked === 1) {
78
+ await drained(answer.body);
79
+ continue;
80
+ }
81
+ if (answer.status !== 200) {
82
+ await drained(answer.body);
83
+ throw storeRefusal('download', answer.status);
84
+ }
85
+ await mkdir(dirname(destination), { recursive: true });
86
+ const partial = join(dirname(destination), `.${basename(destination)}.${randomUUID()}`);
87
+ const digest = createHash('sha256');
88
+ const hashed = new Transform({
89
+ transform(chunk, _encoding, done) {
90
+ digest.update(chunk);
91
+ done(null, chunk);
92
+ },
93
+ });
94
+ try {
95
+ await pipeline(answer.body, hashed, createWriteStream(partial, { flags: 'wx' }));
96
+ if (digest.digest('hex') !== sha256)
97
+ throw new Error(`${name} did not arrive whole; download it again`);
98
+ await rename(partial, destination);
99
+ }
100
+ catch (error) {
101
+ await rm(partial, { force: true });
102
+ throw error;
103
+ }
104
+ return;
105
+ }
106
+ }
107
+ async function fetched(url, signal) {
108
+ const answer = await fetch(relayed(url), { signal });
109
+ return { status: answer.status, body: answer.body ?? Readable.from([]) };
10
110
  }
11
111
  export async function placeFiles(house, directory, files, signal) {
12
112
  const paths = [];
13
113
  for (const file of files) {
14
- const original = await house.post('/kit/conversation/originals/get', { version: file.version, download: true }, 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);
114
+ const original = () => retrying(() => house.post('/kit/conversation/originals/get', { version: file.version, download: true }, signal), signal);
115
+ let first = await original();
116
+ const folder = join(directory, '.house', 'files', first.message, file.version);
19
117
  await mkdir(join(directory, '.house')).catch((error) => {
20
118
  if (error.code !== 'EEXIST')
21
119
  throw error;
22
120
  });
23
121
  await mkdir(folder, { recursive: true });
24
122
  const path = join(folder, file.name);
25
- await writeFile(path, verified(Buffer.from(await answer.arrayBuffer()), file.sha256, file.name));
123
+ await downloaded(async () => {
124
+ const answered = first ?? (await original());
125
+ first = null;
126
+ return { download: answered.download.url, sha256: file.sha256 };
127
+ }, (url) => fetched(url, signal), path, file.name);
26
128
  paths.push(path);
27
129
  }
28
130
  return paths;
29
131
  }
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,
36
- });
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
- });
42
- }
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 } });