@agentshouse/kit 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (142) hide show
  1. package/README.md +722 -263
  2. package/bin/connect-linux.sh +200 -25
  3. package/bin/kit.js +179 -157
  4. package/bootstrap.json +17 -0
  5. package/package.json +7 -2
  6. package/src/adapter-mcp.js +76 -12
  7. package/src/agents.js +63 -8
  8. package/src/binding.js +52 -0
  9. package/src/blocks.js +2 -26
  10. package/src/bridge.js +28 -14
  11. package/src/bytes.js +19 -8
  12. package/src/configuration.js +13 -209
  13. package/src/connector-login.js +3 -84
  14. package/src/contract.js +36 -11
  15. package/src/delivery.js +4 -7
  16. package/src/directory.js +6 -26
  17. package/src/draft.js +4 -82
  18. package/src/git-main.js +138 -0
  19. package/src/git.js +62 -0
  20. package/src/google/calendar.js +39 -45
  21. package/src/google/drive.js +61 -186
  22. package/src/google/entries.js +346 -0
  23. package/src/google/gateway.js +3 -6
  24. package/src/google/gmail.js +33 -23
  25. package/src/google/oauth.js +106 -66
  26. package/src/google/source.js +21 -23
  27. package/src/host.js +6 -2
  28. package/src/house-main.js +65 -8
  29. package/src/house.js +446 -127
  30. package/src/how-we-work.md +25 -0
  31. package/src/inbound.js +2 -18
  32. package/src/kit-work-stream.js +118 -0
  33. package/src/launcher.js +46 -10
  34. package/src/local-files.js +3 -11
  35. package/src/login.js +23 -11
  36. package/src/mcp.js +18 -0
  37. package/src/mount.js +13 -21
  38. package/src/original.js +4 -4
  39. package/src/pacing.js +41 -0
  40. package/src/plan.js +37 -45
  41. package/src/repository.js +3 -61
  42. package/src/session-bin/git +2 -0
  43. package/src/setup.js +14 -15
  44. package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
  45. package/src/skill-set/ask-matt/SKILL.md +91 -0
  46. package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
  47. package/src/skill-set/code-review/LICENSE +21 -0
  48. package/src/skill-set/code-review/SKILL.md +89 -0
  49. package/src/skill-set/code-review/agents/openai.yaml +3 -0
  50. package/src/skill-set/codebase-design/DEEPENING.md +37 -0
  51. package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
  52. package/src/skill-set/codebase-design/LICENSE +21 -0
  53. package/src/skill-set/codebase-design/SKILL.md +116 -0
  54. package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
  55. package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
  56. package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
  57. package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
  58. package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  59. package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
  60. package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
  61. package/src/skill-set/domain-modeling/LICENSE +21 -0
  62. package/src/skill-set/domain-modeling/SKILL.md +76 -0
  63. package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
  64. package/src/skill-set/grill-me/LICENSE +21 -0
  65. package/src/skill-set/grill-me/SKILL.md +8 -0
  66. package/src/skill-set/grill-me/agents/openai.yaml +4 -0
  67. package/src/skill-set/grill-with-docs/LICENSE +21 -0
  68. package/src/skill-set/grill-with-docs/SKILL.md +8 -0
  69. package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
  70. package/src/skill-set/grilling/LICENSE +21 -0
  71. package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
  72. package/src/skill-set/handoff/LICENSE +21 -0
  73. package/src/skill-set/handoff/SKILL.md +17 -0
  74. package/src/skill-set/handoff/agents/openai.yaml +4 -0
  75. package/src/skill-set/implement/LICENSE +21 -0
  76. package/src/skill-set/implement/SKILL.md +16 -0
  77. package/src/skill-set/implement/agents/openai.yaml +4 -0
  78. package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
  79. package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
  80. package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
  81. package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
  82. package/src/skill-set/prototype/LICENSE +21 -0
  83. package/src/skill-set/prototype/LOGIC.md +67 -0
  84. package/src/skill-set/prototype/SKILL.md +28 -0
  85. package/src/skill-set/prototype/UI.md +112 -0
  86. package/src/skill-set/prototype/agents/openai.yaml +3 -0
  87. package/src/skill-set/research/LICENSE +21 -0
  88. package/src/skill-set/research/SKILL.md +14 -0
  89. package/src/skill-set/research/agents/openai.yaml +3 -0
  90. package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
  91. package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
  92. package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
  93. package/src/skill-set/tdd/LICENSE +21 -0
  94. package/src/skill-set/tdd/SKILL.md +40 -0
  95. package/src/skill-set/tdd/agents/openai.yaml +3 -0
  96. package/src/skill-set/tdd/mocking.md +59 -0
  97. package/src/skill-set/tdd/tests.md +77 -0
  98. package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
  99. package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
  100. package/src/skill-set/teach/LICENSE +21 -0
  101. package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
  102. package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
  103. package/src/skill-set/teach/SKILL.md +141 -0
  104. package/src/skill-set/teach/agents/openai.yaml +4 -0
  105. package/src/skill-set/to-questionnaire/LICENSE +21 -0
  106. package/src/skill-set/to-questionnaire/SKILL.md +55 -0
  107. package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
  108. package/src/skill-set/to-spec/LICENSE +21 -0
  109. package/src/skill-set/to-spec/SKILL.md +76 -0
  110. package/src/skill-set/to-spec/agents/openai.yaml +4 -0
  111. package/src/skill-set/to-tickets/LICENSE +21 -0
  112. package/src/skill-set/to-tickets/SKILL.md +106 -0
  113. package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
  114. package/src/skill-set/wait-what/LICENSE +21 -0
  115. package/src/skill-set/wait-what/SKILL.md +8 -0
  116. package/src/skill-set/wait-what/agents/openai.yaml +4 -0
  117. package/src/skill-set/wayfinder/LICENSE +21 -0
  118. package/src/skill-set/wayfinder/SKILL.md +129 -0
  119. package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
  120. package/src/skill-set/writing-for-agents/LICENSE +21 -0
  121. package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
  122. package/src/skill-set/writing-for-agents/SKILL.md +83 -0
  123. package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
  124. package/src/skill-set.js +85 -0
  125. package/src/source.js +93 -54
  126. package/src/state.js +19 -1
  127. package/src/sync-library.js +7 -11
  128. package/src/sync-state.js +16 -3
  129. package/src/sync.js +129 -41
  130. package/src/tasks.js +75 -0
  131. package/src/telegram-view.js +214 -0
  132. package/src/telegram.js +161 -66
  133. package/src/turn-frames.js +69 -0
  134. package/src/worker.js +685 -184
  135. package/src/working-copy.js +1221 -0
  136. package/src/workspace.js +40 -32
  137. package/src/google/declared.js +0 -146
  138. package/src/google/id.js +0 -1
  139. package/src/instance/google.js +0 -1
  140. package/src/starter-set.js +0 -17
  141. /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
  142. /package/src/{starter-set → skill-set}/grilling/agents/openai.yaml +0 -0
@@ -0,0 +1,25 @@
1
+ # How we work
2
+
3
+ A Room is where information people share is kept; it is not the place for
4
+ private information. Work goes into the Room it belongs to, or the First Room
5
+ when none is named. Read and write Rooms with the `house` CLI.
6
+
7
+ The skills are already set up; never run `setup-matt-pocock-skills`.
8
+
9
+ ## Where skill output goes
10
+
11
+ - Glossary (`CONTEXT.md`): `library/glossary.md`
12
+ - Decisions (ADRs): `library/decisions/NNNN-<slug>.md`
13
+ - Plan (spec): `library/projects/<name>/plan.md`
14
+ - Tasks (tickets): `library/projects/<name>/tasks/NN-<slug>.md`, with a
15
+ `Status:` line (`needs-triage`, `needs-info`, `ready-for-agent`,
16
+ `ready-for-human`, `wontfix`), a `Blocked by:` line and comments under
17
+ `## Comments`
18
+ - Map (Wayfinding): `library/projects/<name>/map.md`, its tasks under
19
+ `tasks/` with `Type:`, `Status: claimed|resolved` and `Blocked by:` lines
20
+ - Research: `library/research/<slug>.md`
21
+ - Questionnaires: `library/questionnaires/<slug>.md`
22
+ - Learning: `library/learning/<topic>/`
23
+ - Files a person should open, such as HTML pages, are uploaded to the Room and
24
+ linked from the document they belong to; a prototype is one HTML file with
25
+ every variant on one page.
package/src/inbound.js CHANGED
@@ -134,13 +134,9 @@ export async function reconcileInbound(directory, session) {
134
134
  }
135
135
 
136
136
  export async function localRequest(directory, run, request) {
137
- const turn = request?.conversation?.turn ?? (request?.kind === "conversation" ? request.turn : null);
138
- if (turn === null || turn === undefined) return request;
137
+ if (request.kind !== "conversation") return request;
139
138
  const result = structuredClone(request);
140
- const localTurn = result.conversation?.turn ?? result.turn;
141
- if (!Array.isArray(localTurn.messages)) throw new KitRefusal("kit_run_input_unreadable");
142
- for (const message of localTurn.messages) {
143
- if (!Array.isArray(message.files)) throw new KitRefusal("kit_run_input_unreadable");
139
+ for (const message of result.turn.messages) {
144
140
  for (const file of message.files) {
145
141
  const parts = typeof file.path === "string" ? file.path.split("/") : [];
146
142
  if (parts.length !== 4 || parts[0] !== ROOT || parts[1] !== run || !UUID.test(parts[1]) || !UUID.test(parts[2]) || !segment(parts[3])) {
@@ -161,15 +157,3 @@ export async function localRequest(directory, run, request) {
161
157
  }
162
158
  return result;
163
159
  }
164
-
165
- export async function localReply(directory, run, frame, reply) {
166
- if (frame.method !== "tools/call" || frame.params?.name !== "session_get_request" || reply.error || reply.result?.isError) return reply;
167
- const result = structuredClone(reply);
168
- for (const item of result.result?.content ?? []) {
169
- if (item.type !== "text") continue;
170
- let input;
171
- try { input = JSON.parse(item.text); } catch { throw new KitRefusal("kit_run_input_unreadable"); }
172
- item.text = JSON.stringify(await localRequest(directory, run, input));
173
- }
174
- return result;
175
- }
@@ -0,0 +1,118 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import WebSocket from "ws";
3
+ import {
4
+ KIT_CONTRACT_VERSION,
5
+ KIT_STREAM_RECONNECT_MAX_MS,
6
+ KIT_STREAM_RECONNECT_MS,
7
+ KIT_STREAM_REJOIN_MS,
8
+ } from "./contract.js";
9
+ import { heldControl } from "./house.js";
10
+ import { KitRefusal } from "./refusal.js";
11
+
12
+ const PROTOCOL = "house.kit.stream.1";
13
+ const WORK_SUBJECTS = ["runs", "commands", "agents", "working_copy", "sources"];
14
+ const FINAL_REFUSALS = ["kit_upgrade_required", "kit_stream_replaced"];
15
+ const VIEW_FRAMES = ["draft", "plan"];
16
+
17
+ function streamAddress(house) {
18
+ const url = new URL("/kit/stream", house);
19
+ if (url.protocol === "https:") url.protocol = "wss:";
20
+ else if (url.protocol === "http:") url.protocol = "ws:";
21
+ else throw new KitRefusal("kit_house_unavailable", "/kit/stream");
22
+ return url;
23
+ }
24
+
25
+ function frameOf(data) {
26
+ try {
27
+ return JSON.parse(data.toString());
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ async function held(session, signal, heard, dependencies) {
34
+ const authorization = await heldControl(session, signal);
35
+ return new Promise((resolve, reject) => {
36
+ const Socket = dependencies.workSocket ?? WebSocket;
37
+ const socket = new Socket(streamAddress(session.house), PROTOCOL, {
38
+ headers: { authorization, "x-house-kit-version": KIT_CONTRACT_VERSION },
39
+ handshakeTimeout: 10_000,
40
+ perMessageDeflate: false,
41
+ });
42
+ let finished = false;
43
+ const finish = (reason = null, refusal = null) => {
44
+ if (finished) return;
45
+ finished = true;
46
+ signal.removeEventListener("abort", stop);
47
+ heard.closed?.();
48
+ if (refusal === null) resolve(reason);
49
+ else reject(refusal);
50
+ };
51
+ const stop = () => {
52
+ socket.terminate();
53
+ finish();
54
+ };
55
+ signal.addEventListener("abort", stop, { once: true });
56
+ socket.on("open", () => {
57
+ if (finished) return;
58
+ heard.opened?.((frame) => socket.send(JSON.stringify(frame), () => undefined));
59
+ heard.available(null);
60
+ });
61
+ socket.on("message", (data) => {
62
+ const frame = frameOf(data);
63
+ if (frame?.type === "work_available" && WORK_SUBJECTS.includes(frame.subject)) {
64
+ heard.available(frame.subject);
65
+ } else if (frame?.type === "entries") {
66
+ heard.changed();
67
+ } else if (VIEW_FRAMES.includes(frame?.type)) {
68
+ heard.viewed?.(frame);
69
+ }
70
+ });
71
+ socket.on("unexpected-response", (_request, response) => {
72
+ response.resume();
73
+ if (response.statusCode === 409) session.dependencies.contractRefused?.();
74
+ finish(null, response.statusCode === 409 ? new KitRefusal("kit_upgrade_required") : null);
75
+ socket.terminate();
76
+ });
77
+ socket.on("error", () => {
78
+ socket.terminate();
79
+ finish();
80
+ });
81
+ socket.on("close", (_code, reason) => {
82
+ const closed = reason?.toString() ?? "";
83
+ finish(closed, closed === "replaced" ? new KitRefusal("kit_stream_replaced") : null);
84
+ });
85
+ if (signal.aborted) stop();
86
+ });
87
+ }
88
+
89
+ export async function watchKitWork(session, signal, heard, dependencies = {}) {
90
+ const random = dependencies.random ?? Math.random;
91
+ const sleep = dependencies.streamSleep ?? ((milliseconds) => delay(milliseconds, undefined, { signal }));
92
+ let failures = 0;
93
+ while (!signal.aborted) {
94
+ const began = Date.now();
95
+ let reason = null;
96
+ try {
97
+ reason = await held(session, signal, heard, dependencies);
98
+ } catch (error) {
99
+ if (error instanceof KitRefusal && FINAL_REFUSALS.includes(error.code)) throw error;
100
+ }
101
+ if (signal.aborted) return;
102
+ let wait;
103
+ if (reason === "authority_changed") {
104
+ failures = 0;
105
+ wait = random() * KIT_STREAM_REJOIN_MS;
106
+ } else {
107
+ if (Date.now() - began >= KIT_STREAM_RECONNECT_MAX_MS) failures = 0;
108
+ const ceiling = Math.min(KIT_STREAM_RECONNECT_MAX_MS, KIT_STREAM_RECONNECT_MS * 2 ** failures);
109
+ failures += 1;
110
+ wait = ceiling / 2 + (random() * ceiling) / 2;
111
+ }
112
+ try {
113
+ await sleep(wait);
114
+ } catch (error) {
115
+ if (!signal.aborted) throw error;
116
+ }
117
+ }
118
+ }
package/src/launcher.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { commandHouseInstructions, commandHouseRequest } from "./adapter-mcp.js";
2
+ import {
3
+ commandHouseCreateHowWeWork,
4
+ commandHouseHowWeWork,
5
+ commandHouseInstructions,
6
+ } from "./adapter-mcp.js";
3
7
  import {
4
8
  compiledContract,
5
9
  initializeCommandAdapter,
@@ -7,7 +11,7 @@ import {
7
11
  startCommandAdapter,
8
12
  stopCommandAdapter,
9
13
  } from "./adapter.js";
10
- import { PERMISSION_OPTION_KINDS, SESSION_SET_CONFIG_OPTION } from "./contract.js";
14
+ import { HOUSE_CLI_LINE, PERMISSION_OPTION_KINDS, SESSION_SET_CONFIG_OPTION } from "./contract.js";
11
15
  import { observedAssistantDrafts } from "./draft.js";
12
16
  import { observedPlans } from "./plan.js";
13
17
  import { bridgePeer } from "./peer.js";
@@ -129,6 +133,20 @@ async function configureSession(acp, contract, sessionId, adapter) {
129
133
  }
130
134
  }
131
135
 
136
+ async function sessionOpening(channel, bridge, start) {
137
+ if (start.create_how_we_work === true && (await commandHouseCreateHowWeWork(bridge))) {
138
+ channel.send({ type: "how_we_work" });
139
+ }
140
+ const parts = [HOUSE_CLI_LINE, start.base_instructions, await commandHouseHowWeWork(bridge)];
141
+ return [{ type: "text", text: parts.filter((part) => part.trim() !== "").join("\n\n") }];
142
+ }
143
+
144
+ function workingCopyContext(copies) {
145
+ if (!Array.isArray(copies) || copies.length === 0) return [];
146
+ const lines = copies.map((copy) => `${copy.path}: ${copy.repository}`);
147
+ return [{ type: "text", text: ["Local Git working copies of House paths:", ...lines].join("\n") }];
148
+ }
149
+
132
150
  async function runTurn(channel, start, bridge, turn) {
133
151
  const contract = compiledContract(start.adapter?.kind);
134
152
  const adapter = startCommandAdapter(
@@ -137,11 +155,23 @@ async function runTurn(channel, start, bridge, turn) {
137
155
  );
138
156
  const said = [];
139
157
  const message = { text: "" };
140
- const drafts = observedAssistantDrafts({ send: (text) => channel.send({ type: "draft", text }) });
158
+ const sequences = { draft: 0, plan: 0 };
159
+ const drafts = observedAssistantDrafts({
160
+ send: (text) => channel.send({ type: "draft", text, sequence: (sequences.draft += 1) }),
161
+ });
141
162
  const plans = observedPlans({
142
- send: (snapshot) =>
143
- channel.send({ type: "plan", provider_turn: turn.providerTurn, ...snapshot }),
163
+ send: (steps) =>
164
+ channel.send({
165
+ type: "plan",
166
+ provider_turn: turn.providerTurn,
167
+ sequence: (sequences.plan += 1),
168
+ steps,
169
+ }),
144
170
  });
171
+ turn.plan = () => {
172
+ const steps = plans.latest();
173
+ return steps === null ? null : { provider_turn: turn.providerTurn, steps };
174
+ };
145
175
  const wholeSoFar = () => [...said, message.text].filter((text) => text !== "").join("\n\n");
146
176
  let loading = false;
147
177
  const flush = () => {
@@ -149,7 +179,7 @@ async function runTurn(channel, start, bridge, turn) {
149
179
  channel.send({ type: "assistant", text: message.text });
150
180
  said.push(message.text);
151
181
  message.text = "";
152
- drafts.complete(wholeSoFar());
182
+ drafts.observe(wholeSoFar());
153
183
  };
154
184
  const handlers = {
155
185
  onPermission: (params) => resolvedPermission(params, start.execution_authority),
@@ -173,6 +203,8 @@ async function runTurn(channel, start, bridge, turn) {
173
203
  const { acp } = await initializeCommandAdapter(adapter, contract, handlers);
174
204
  const instructions = await commandHouseInstructions(bridge);
175
205
  let providerSessionId = start.provider_session ?? null;
206
+ const opening =
207
+ providerSessionId === null ? await sessionOpening(channel, bridge, start) : [];
176
208
  const setup = { cwd: start.working_directory, mcpServers: [] };
177
209
  if (providerSessionId === null) {
178
210
  providerSessionId = openedSession(await acp.request("session/new", setup));
@@ -185,28 +217,29 @@ async function runTurn(channel, start, bridge, turn) {
185
217
  loading = false;
186
218
  }
187
219
  await configureSession(acp, contract, providerSessionId, start.adapter);
188
- const request = start.request === null ? await commandHouseRequest(bridge) : start.request;
189
220
  turn.adapterSession = true;
190
221
  turn.cancel = () => acp.cancel({ sessionId: providerSessionId });
191
222
  const prompted = await acp.request("session/prompt", {
192
223
  sessionId: providerSessionId,
193
224
  prompt: [
194
- { type: "text", text: JSON.stringify(request) },
225
+ ...opening,
226
+ { type: "text", text: JSON.stringify(start.request) },
195
227
  { type: "text", text: instructions },
228
+ ...workingCopyContext(start.working_copies),
196
229
  ],
197
230
  });
198
231
  await new Promise((resolve) => setImmediate(resolve));
199
232
  flush();
200
- plans.complete();
201
233
  channel.send({
202
234
  type: "turn_ended",
203
235
  stop_outcome: turnOutcome(contract, prompted?.stopReason),
204
236
  provider_continuation: contract.sessionLoad,
205
237
  provider_session_id: providerSessionId,
238
+ plan: turn.plan(),
206
239
  });
207
240
  } finally {
208
241
  turn.cancel = null;
209
- plans.complete();
242
+ plans.close();
210
243
  abandonQuestions(channel, turn);
211
244
  drafts.close();
212
245
  stopCommandAdapter(adapter);
@@ -219,6 +252,7 @@ export async function launchWorkerSession(bridge) {
219
252
  providerTurn: randomUUID(),
220
253
  questions: new Map(),
221
254
  cancel: null,
255
+ plan: () => null,
222
256
  };
223
257
  const peer = bridgePeer({
224
258
  socket: bridge.socket,
@@ -246,6 +280,7 @@ export async function launchWorkerSession(bridge) {
246
280
  type: "interrupt_applied",
247
281
  interrupt: frame.interrupt,
248
282
  outcome: "interrupted",
283
+ plan: turn.plan(),
249
284
  }),
250
285
  () =>
251
286
  peer.send({
@@ -268,6 +303,7 @@ export async function launchWorkerSession(bridge) {
268
303
  type: "failed",
269
304
  code: refusal instanceof KitRefusal ? refusal.code : "kit_adapter_failed",
270
305
  detail: refusal instanceof Error ? refusal.message : null,
306
+ plan: turn.plan(),
271
307
  });
272
308
  }
273
309
  peer.send({ type: "exiting" });
@@ -1,6 +1,7 @@
1
1
  import { isAbsolute } from "node:path";
2
- import { parse, stringify } from "yaml";
2
+ import { stringify } from "yaml";
3
3
  import { AGENT_OPERATION_META, ATTACHMENT_FILE_META } from "./contract.js";
4
+ import { answerText, answerYaml } from "./mcp.js";
4
5
  import { derivedOperationId } from "./operation.js";
5
6
  import { localAttachment, sendLocalConversationFile, uploadLocalAttachment } from "./original.js";
6
7
  import { KitRefusal } from "./refusal.js";
@@ -12,18 +13,9 @@ class HouseRefused extends Error {
12
13
  }
13
14
  }
14
15
 
15
- function parsed(text) {
16
- try {
17
- return parse(text);
18
- } catch {
19
- return null;
20
- }
21
- }
22
-
23
16
  function answerOf(frame) {
24
17
  if (frame.error !== undefined || frame.result?.isError === true) throw new HouseRefused(frame);
25
- const text = (frame.result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
26
- const answer = parsed(text);
18
+ const answer = answerYaml(answerText(frame.result));
27
19
  if (answer === null || typeof answer !== "object") throw new KitRefusal("kit_house_answer_unreadable");
28
20
  return answer;
29
21
  }
package/src/login.js CHANGED
@@ -8,12 +8,13 @@ import { createInterface } from "node:readline/promises";
8
8
  import { stdin, stdout } from "node:process";
9
9
  import { KIT_CREDENTIAL } from "./contract.js";
10
10
  import { launchBase, prepareLaunchBase } from "./directory.js";
11
- import { houseRequest } from "./house.js";
11
+ import { houseRequest, refuseDrained } from "./house.js";
12
12
  import { KitRefusal } from "./refusal.js";
13
13
  import { writeText } from "./state.js";
14
14
 
15
15
  const ENVIRONMENT =
16
16
  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ const DEFAULT_HOUSE = "https://agents.house";
17
18
 
18
19
  function verifier() {
19
20
  return randomBytes(32).toString("base64url");
@@ -112,19 +113,29 @@ function houseOrigin(value) {
112
113
  return value;
113
114
  }
114
115
 
115
- export async function storedHouse(directory, firstLogin = false, requested = undefined) {
116
- let value;
116
+ async function recordedHouse(directory) {
117
117
  try {
118
- value = (await readFile(join(directory, "house"), "utf8")).trim();
118
+ return (await readFile(join(directory, "house"), "utf8")).trim();
119
119
  } catch (error) {
120
- if (error.code === "ENOENT") {
121
- if ((firstLogin || requested !== undefined) &&
122
- await storedEnvironment(directory) === null && !await storedCredentialPresent(directory)) {
123
- return houseOrigin(requested ?? "https://agents.house");
124
- }
125
- }
120
+ if (error.code === "ENOENT") return null;
126
121
  throw new KitRefusal("kit_house_invalid", error.code ?? "unreadable");
127
122
  }
123
+ }
124
+
125
+ export async function storedHouse(directory) {
126
+ const value = await recordedHouse(directory);
127
+ if (value === null) throw new KitRefusal("kit_house_invalid", "ENOENT");
128
+ return houseOrigin(value);
129
+ }
130
+
131
+ export async function loginHouse(directory, requested) {
132
+ const value = await recordedHouse(directory);
133
+ if (value === null) {
134
+ if (await storedEnvironment(directory) !== null || await storedCredentialPresent(directory)) {
135
+ throw new KitRefusal("kit_house_invalid", "ENOENT");
136
+ }
137
+ return houseOrigin(requested ?? DEFAULT_HOUSE);
138
+ }
128
139
  houseOrigin(value);
129
140
  if (requested !== undefined && requested !== value) {
130
141
  throw new KitRefusal("kit_house_mismatch", "this Environment is bound to another House origin");
@@ -133,7 +144,7 @@ export async function storedHouse(directory, firstLogin = false, requested = und
133
144
  }
134
145
 
135
146
  export function enrollmentAct(options, retained, host) {
136
- if (options.label !== undefined || (options.environment !== undefined && !ENVIRONMENT.test(options.environment))) {
147
+ if (options.environment !== undefined && !ENVIRONMENT.test(options.environment)) {
137
148
  throw new KitRefusal("kit_login_rejected", ENROLLMENT_USAGE);
138
149
  }
139
150
  const environment = options.environment ?? retained;
@@ -260,6 +271,7 @@ export async function openBrowser(url) {
260
271
  }
261
272
 
262
273
  export async function connectHouse(options, dependencies = {}) {
274
+ refuseDrained(options.directory);
263
275
  await prepareLaunchBase(launchBase(dependencies));
264
276
  if (options.presentation === "manual") return login(options, dependencies);
265
277
  const callback = await loopback();
package/src/mcp.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { parse } from "yaml";
2
3
  import { bridgePeer } from "./peer.js";
3
4
  import { KitRefusal } from "./refusal.js";
4
5
 
@@ -69,3 +70,20 @@ export async function bridgeCall(bridge, request, signal = undefined) {
69
70
  await peer.end();
70
71
  }
71
72
  }
73
+
74
+ export function answerText(result) {
75
+ return (result?.content ?? []).filter((item) => item.type === "text").map((item) => item.text).join("\n");
76
+ }
77
+
78
+ export function answerYaml(text) {
79
+ try {
80
+ return parse(text);
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ export function editRefusal(text) {
87
+ const conflicts = answerYaml(text.slice(text.indexOf("\n") + 1))?.conflicts;
88
+ return { code: text.split(":")[0].trim(), conflicts: Array.isArray(conflicts) ? conflicts : null };
89
+ }
package/src/mount.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { join } from "node:path";
3
3
  import { readFile, writeFile } from "node:fs/promises";
4
- import { apply, links, parse, serialize } from "@agentshouse/mdmodel";
4
+ import { apply, links, parse, retarget, serialize } from "@agentshouse/mdmodel";
5
5
  import {
6
6
  KIT_DOOR_BATCH_PATH,
7
7
  KIT_DOOR_BOOTSTRAP_PATH,
@@ -84,7 +84,6 @@ export function mountTarget(subtree) {
84
84
  refuse("kit_mount_subtree_invalid", subtree);
85
85
  }
86
86
  return {
87
- kind: own ? "private" : "room",
88
87
  handle: own ? null : segments[1],
89
88
  root: own ? "/private" : `/rooms/${segments[1]}`,
90
89
  subtree,
@@ -107,10 +106,11 @@ function discoveryOf(value) {
107
106
 
108
107
  function authorityFor(discovery, target) {
109
108
  const found = discovery.authorities.find((entry) =>
110
- target.kind === "private" ? entry.kind === "private" : entry.room_handle === target.handle,
109
+ entry.kind === "room" && entry.room_handle === target.handle,
111
110
  );
112
111
  if (found === undefined) refuse("kit_mount_authority_unknown", target.root);
113
112
  if (
113
+ typeof found.room_ref !== "string" ||
114
114
  !Array.isArray(found.protected_paths) ||
115
115
  found.operations === null ||
116
116
  typeof found.operations !== "object" ||
@@ -119,8 +119,8 @@ function authorityFor(discovery, target) {
119
119
  refuse("kit_mount_rejected", "discover");
120
120
  }
121
121
  return {
122
- kind: found.kind,
123
- authority: found.kind === "private" ? "private" : found.room_ref,
122
+ kind: "room",
123
+ authority: found.room_ref,
124
124
  path: target.root,
125
125
  protected_paths: found.protected_paths,
126
126
  operations: found.operations,
@@ -128,16 +128,11 @@ function authorityFor(discovery, target) {
128
128
  };
129
129
  }
130
130
 
131
- function hrefSuffix(href) {
132
- const match = /[?#]/.exec(href);
133
- return match === null ? "" : href.slice(match.index);
134
- }
135
-
136
131
  function replaceLink(document, href, destination) {
137
132
  const marker = `house-kit-link:${operationId()}`;
138
133
  const located = apply(document, { kind: "link", href, to: marker });
139
134
  if (located.outcome !== "applied") refuse("kit_mount_link_unwritable", href);
140
- const cleared = parse(serialize(located.document).replaceAll(`${marker}${hrefSuffix(href)}`, marker));
135
+ const cleared = parse(serialize(located.document).replaceAll(retarget(href, marker), marker));
141
136
  if (!cleared.ok) refuse("kit_mount_link_unwritable", href);
142
137
  const changed = apply(cleared.document, { kind: "link", href: marker, to: destination });
143
138
  if (changed.outcome !== "applied") refuse("kit_mount_link_unwritable", href);
@@ -178,11 +173,8 @@ export function rewriteAttachmentLinks(held, mintVersion) {
178
173
  refuse("kit_migrator_referrer_undeclared", `${relative} -> ${path}`);
179
174
  }
180
175
  const version = mintVersion(path);
181
- const applied = apply(document, {
182
- kind: "link",
183
- href: link.href,
184
- to: `${PENDING_LINK_SCHEME}:${version}`,
185
- });
176
+ const pending = `${PENDING_LINK_SCHEME}:${version}`;
177
+ const applied = apply(document, { kind: "link", href: link.href, to: pending });
186
178
  if (applied.outcome !== "applied") refuse("kit_mount_link_unwritable", link.href);
187
179
  document = applied.document;
188
180
  let save = saves.find((entry) => entry.path === path);
@@ -191,8 +183,8 @@ export function rewriteAttachmentLinks(held, mintVersion) {
191
183
  saves.push(save);
192
184
  }
193
185
  if (!save.documents.includes(relative)) save.documents.push(relative);
194
- const suffix = hrefSuffix(link.href);
195
- const href = `${PENDING_LINK_SCHEME}:${version}${suffix}`;
186
+ const suffix = retarget(link.href, "");
187
+ const href = retarget(link.href, pending);
196
188
  if (!save.hrefs.some((entry) => entry.document === relative && entry.href === href)) {
197
189
  save.hrefs.push({ document: relative, href, suffix });
198
190
  }
@@ -326,7 +318,7 @@ function pendingTransfer(held, save) {
326
318
  };
327
319
  }
328
320
 
329
- async function submitAttachments(session, held, target, authority, plan, signal) {
321
+ async function submitAttachments(session, held, authority, plan, signal) {
330
322
  const transfers = [];
331
323
  for (const save of plan.saves) {
332
324
  const local = held.attachments.get(save.path);
@@ -341,7 +333,7 @@ async function submitAttachments(session, held, target, authority, plan, signal)
341
333
  version: save.version,
342
334
  name: declared.name,
343
335
  mediaType: declared.mediaType ?? undefined,
344
- room: target.kind === "room" ? authority.authority : null,
336
+ room: authority.authority,
345
337
  body,
346
338
  signal,
347
339
  });
@@ -471,7 +463,7 @@ export async function mountPackage(held, options, dependencies = {}) {
471
463
  };
472
464
  const localReport = join(held.root, "MIGRATION.json");
473
465
  await writeFile(localReport, `${JSON.stringify(result, null, 2)}\n`);
474
- result.transfers = await submitAttachments(session, held, target, authority, plan, signal);
466
+ result.transfers = await submitAttachments(session, held, authority, plan, signal);
475
467
 
476
468
  const report = `${target.subtree}/${MOUNT_REPORT_NAME}`;
477
469
  await writeFile(localReport, `${JSON.stringify(result, null, 2)}\n`);
package/src/original.js CHANGED
@@ -133,9 +133,9 @@ function outcomeOf(answer) {
133
133
 
134
134
  function grantOf(value, kind) {
135
135
  if (value === undefined) return null;
136
- if (typeof value?.url !== "string" || typeof value?.operation !== "string") {
137
- refuse("kit_original_grant_rejected", kind);
138
- }
136
+ if (typeof value?.url !== "string") refuse("kit_original_grant_rejected", kind);
137
+ if (kind === "download") return { url: value.url };
138
+ if (typeof value.operation !== "string") refuse("kit_original_grant_rejected", kind);
139
139
  return { url: value.url, operation: value.operation };
140
140
  }
141
141
 
@@ -148,7 +148,7 @@ function sendBytes(dependencies, grant, body, signal) {
148
148
 
149
149
  async function receiveBytes(session, grant, signal) {
150
150
  const transferred = await transferBytes(
151
- { url: grant.url, method: "GET", operation: grant.operation, signal },
151
+ { url: grant.url, method: "GET", signal },
152
152
  session.dependencies,
153
153
  );
154
154
  return transferred.bytes;
package/src/pacing.js ADDED
@@ -0,0 +1,41 @@
1
+ const PACING_INTERVAL_MS = 500;
2
+
3
+ export function pacedLatest(options) {
4
+ const intervalMs = options.intervalMs ?? PACING_INTERVAL_MS;
5
+ const now = options.now ?? (() => Date.now());
6
+ let timer = null;
7
+ let sentAt = null;
8
+ let sent = null;
9
+ let latest = null;
10
+ let closed = false;
11
+
12
+ function write() {
13
+ timer = null;
14
+ const serialized = JSON.stringify(latest);
15
+ if (latest === null || serialized === sent) return;
16
+ sent = serialized;
17
+ sentAt = now();
18
+ options.send(latest);
19
+ }
20
+
21
+ return {
22
+ observe(value) {
23
+ latest = value;
24
+ if (closed || timer !== null) return;
25
+ const wait = sentAt === null ? 0 : sentAt + intervalMs - now();
26
+ if (wait <= 0) {
27
+ write();
28
+ return;
29
+ }
30
+ timer = setTimeout(write, wait);
31
+ timer.unref?.();
32
+ },
33
+ latest: () => latest,
34
+ close() {
35
+ closed = true;
36
+ if (timer === null) return;
37
+ clearTimeout(timer);
38
+ timer = null;
39
+ },
40
+ };
41
+ }