@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
package/src/sync.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  assertWritableChanges,
14
14
  authorityFor,
15
15
  deriveChanges,
16
+ housePath,
16
17
  OPERATIONS,
17
18
  relativeTo,
18
19
  resultingFiles,
@@ -41,12 +42,12 @@ function sessionDependencies(options, dependencies) {
41
42
  };
42
43
  }
43
44
 
44
- async function withSession(options, dependencies, operation) {
45
+ export async function withSession(options, dependencies, operation) {
45
46
  const session = await openSession(options, sessionDependencies(options, dependencies));
46
47
  return maintainControl(session, (signal) => operation(session, signal), options.signal);
47
48
  }
48
49
 
49
- async function door(session, path, body, signal) {
50
+ export async function door(session, path, body, signal) {
50
51
  return houseRequest(
51
52
  session.house,
52
53
  await heldControl(session, signal),
@@ -83,7 +84,7 @@ function changePosition(value, declaration) {
83
84
  return { position: value.position, log_epoch: value.log_epoch };
84
85
  }
85
86
 
86
- function discoveryOf(value) {
87
+ export function discoveryOf(value) {
87
88
  if (typeof value?.md_model_version !== "string" || !Array.isArray(value.authorities)) {
88
89
  throw new KitRefusal("kit_sync_rejected", "discover");
89
90
  }
@@ -93,7 +94,7 @@ function discoveryOf(value) {
93
94
  return value;
94
95
  }
95
96
 
96
- async function bootstrapBundle(session, prefixes, signal) {
97
+ export async function bootstrapBundle(session, prefixes, signal) {
97
98
  const answer = await door(session, KIT_DOOR_BOOTSTRAP_PATH, { prefixes }, signal);
98
99
  if (typeof answer?.transfer?.url === "string") {
99
100
  const granted = await houseGetJson(answer.transfer.url, session.dependencies, signal);
@@ -105,15 +106,12 @@ async function bootstrapBundle(session, prefixes, signal) {
105
106
  }
106
107
 
107
108
  function matchedDiscovery(discovery, scope) {
108
- if (scope.manifest?.scope === "private") {
109
- return discovery.authorities.find((entry) => entry.kind === "private");
110
- }
111
109
  return discovery.authorities.find(
112
110
  (entry) => entry.kind === "room" && entry.room_ref === scope.manifest?.anchor,
113
111
  );
114
112
  }
115
113
 
116
- async function writeScope(replica, scope, signal) {
114
+ function scopeContents(replica, scope) {
117
115
  if (
118
116
  typeof scope?.manifest?.path !== "string" ||
119
117
  typeof scope.manifest.position !== "string" ||
@@ -124,11 +122,18 @@ async function writeScope(replica, scope, signal) {
124
122
  if (!Array.isArray(scope.contents) || !Array.isArray(scope.manifest.files)) {
125
123
  throw new KitRefusal("kit_bootstrap_rejected");
126
124
  }
127
- const files = [];
128
125
  for (const entry of scope.contents) {
129
126
  if (typeof entry?.path !== "string" || typeof entry.content !== "string") {
130
127
  throw new KitRefusal("kit_bootstrap_rejected");
131
128
  }
129
+ locate(replica, scope.manifest.path, entry.path);
130
+ }
131
+ return scope.contents;
132
+ }
133
+
134
+ async function writeScope(replica, scope, signal) {
135
+ const files = [];
136
+ for (const entry of scopeContents(replica, scope)) {
132
137
  const meta = scope.manifest.files.find((file) => file.path === entry.path);
133
138
  await writeReplicaFile(locate(replica, scope.manifest.path, entry.path), entry.content, signal);
134
139
  files.push({
@@ -142,18 +147,21 @@ async function writeScope(replica, scope, signal) {
142
147
  }
143
148
 
144
149
  async function replaceScope(replica, previous, scope, signal) {
145
- const next = await writeScope(replica, scope, signal);
146
- const kept = new Set(next.map((file) => file.path));
150
+ const kept = new Set(scopeContents(replica, scope).map((entry) => entry.path));
147
151
  const root = locate(replica, scope.manifest.path);
148
152
  for (const path of await listFiles(root)) {
149
- if (!kept.has(path)) await removeReplicaFile(locate(replica, scope.manifest.path, path));
153
+ if (!kept.has(path)) await removeReplicaFile(replica, scope.manifest.path, path);
150
154
  }
151
155
  if (previous !== undefined) {
152
156
  for (const file of previous.files) {
153
- if (!kept.has(file.path)) await removeReplicaFile(locate(replica, previous.path, file.path));
157
+ if (!kept.has(file.path)) await removeReplicaFile(replica, previous.path, file.path);
154
158
  }
155
159
  }
156
- return next;
160
+ return writeScope(replica, scope, signal);
161
+ }
162
+
163
+ function removalsFirst(entries, removal) {
164
+ return [...entries.filter(removal), ...entries.filter((entry) => !removal(entry))];
157
165
  }
158
166
 
159
167
  function stringList(value) {
@@ -173,11 +181,17 @@ function operationsOf(value) {
173
181
  function authorityRecord(scope, discovery, files) {
174
182
  const found = matchedDiscovery(discovery, scope);
175
183
  const operations = found === undefined ? null : operationsOf(found.operations);
176
- if (found === undefined || !stringList(found.protected_paths) || operations === null) {
184
+ if (
185
+ scope.manifest?.scope !== "room" ||
186
+ typeof scope.manifest.anchor !== "string" ||
187
+ found === undefined ||
188
+ !stringList(found.protected_paths) ||
189
+ operations === null
190
+ ) {
177
191
  throw new KitRefusal("kit_bootstrap_rejected");
178
192
  }
179
193
  return {
180
- kind: scope.manifest.scope === "private" ? "private" : "room",
194
+ kind: "room",
181
195
  authority: scope.manifest.anchor,
182
196
  room_handle: scope.manifest.room_handle,
183
197
  path: scope.manifest.path,
@@ -192,9 +206,10 @@ function authorityRecord(scope, discovery, files) {
192
206
  async function materialize(options, discovery, bundle, signal, previous = null) {
193
207
  const authorities = [];
194
208
  for (const scope of bundle.scopes) {
209
+ const record = authorityRecord(scope, discovery, []);
195
210
  const held = previous?.authorities.find((entry) => entry.authority === scope.manifest?.anchor);
196
211
  const files = await replaceScope(options.replica, held, scope, signal);
197
- authorities.push(authorityRecord(scope, discovery, files));
212
+ authorities.push({ ...record, files });
198
213
  }
199
214
  const state = {
200
215
  version: EXPLICIT_SYNC_VERSION,
@@ -234,6 +249,12 @@ function enumeratedRecord(path, content, change) {
234
249
  };
235
250
  }
236
251
 
252
+ async function writeSidecar(options, authority, recorded, path, change, signal) {
253
+ const sidecar = sidecarPath(path);
254
+ await writeReplicaFile(locate(options.replica, authority.path, sidecar), change.provenance, signal);
255
+ recorded.set(sidecar, enumeratedRecord(sidecar, change.provenance, change));
256
+ }
257
+
237
258
  async function applyEnumeration(options, state, enumeration, signal) {
238
259
  if (!Array.isArray(enumeration?.authorities)) throw new KitRefusal("kit_sync_rejected", "enumerate");
239
260
  for (const view of enumeration.authorities) {
@@ -241,10 +262,10 @@ async function applyEnumeration(options, state, enumeration, signal) {
241
262
  if (authority === undefined) throw new KitRefusal("kit_sync_rejected", view.authority);
242
263
  const reached = changePosition(view, "enumerate");
243
264
  const recorded = new Map(authority.files.map((file) => [file.path, file]));
244
- for (const change of view.changes ?? []) {
265
+ for (const change of removalsFirst(view.changes ?? [], (change) => change?.removed === true)) {
245
266
  if (typeof change?.path !== "string") throw new KitRefusal("kit_sync_rejected", "enumerate");
246
267
  if (change.removed === true) {
247
- await removeReplicaFile(locate(options.replica, authority.path, change.path));
268
+ await removeReplicaFile(options.replica, authority.path, change.path);
248
269
  recorded.delete(change.path);
249
270
  continue;
250
271
  }
@@ -257,13 +278,7 @@ async function applyEnumeration(options, state, enumeration, signal) {
257
278
  recorded.set(change.path, enumeratedRecord(change.path, change.content, change));
258
279
  }
259
280
  if (typeof change.provenance === "string") {
260
- const sidecar = sidecarPath(change.path);
261
- await writeReplicaFile(
262
- locate(options.replica, authority.path, sidecar),
263
- change.provenance,
264
- signal,
265
- );
266
- recorded.set(sidecar, enumeratedRecord(sidecar, change.provenance, change));
281
+ await writeSidecar(options, authority, recorded, change.path, change, signal);
267
282
  }
268
283
  }
269
284
  authority.files = [...recorded.values()];
@@ -295,13 +310,41 @@ function uncertainTransport(error) {
295
310
  );
296
311
  }
297
312
 
298
- async function acceptBatch(options, state, outcome, signal, pending = null) {
313
+ async function servedProvenance(session, options, state, authority, signal) {
314
+ const enumeration = await replicaDoor(
315
+ session,
316
+ options,
317
+ state,
318
+ KIT_DOOR_ENUMERATE_PATH,
319
+ {
320
+ prefixes: state.prefixes.filter((prefix) => authorityFor(state, prefix) === authority),
321
+ positions: [
322
+ { authority: authority.authority, position: authority.position, log_epoch: authority.log_epoch },
323
+ ],
324
+ },
325
+ signal,
326
+ );
327
+ const view = Array.isArray(enumeration?.authorities)
328
+ ? enumeration.authorities.find((entry) => entry?.authority === authority.authority)
329
+ : undefined;
330
+ if (!Array.isArray(view?.changes)) throw new KitRefusal("kit_sync_rejected", "enumerate");
331
+ return new Map(
332
+ view.changes
333
+ .filter((change) => typeof change?.provenance === "string")
334
+ .map((change) => [change.path, change]),
335
+ );
336
+ }
337
+
338
+ async function acceptBatch(session, options, state, outcome, signal, pending = null) {
299
339
  const authority = state.authorities.find((entry) => entry.authority === outcome.authority);
300
340
  if (authority === undefined) throw new KitRefusal("kit_sync_rejected", outcome.authority);
301
341
  const reached = changePosition(outcome, "batch");
302
342
  const recorded = new Map(authority.files.map((file) => [file.path, file]));
303
343
  const predicted = predictedReplica(authority, pending);
304
- for (const file of outcome.contents ?? []) {
344
+ const provenance = (outcome.contents ?? []).some((file) => typeof file?.content === "string")
345
+ ? await servedProvenance(session, options, state, authority, signal)
346
+ : new Map();
347
+ for (const file of removalsFirst(outcome.contents ?? [], (file) => file?.content === null)) {
305
348
  const relative = relativeTo(authority.path, file.path);
306
349
  const destination = locate(options.replica, authority.path, relative);
307
350
  const local = await replicaText(destination);
@@ -315,7 +358,7 @@ async function acceptBatch(options, state, outcome, signal, pending = null) {
315
358
  local !== accepted &&
316
359
  local !== expected;
317
360
  if (accepted === null) {
318
- if (!preserve) await removeReplicaFile(destination);
361
+ if (!preserve) await removeReplicaFile(options.replica, authority.path, relative);
319
362
  recorded.delete(relative);
320
363
  continue;
321
364
  }
@@ -326,6 +369,10 @@ async function acceptBatch(options, state, outcome, signal, pending = null) {
326
369
  sha256: "",
327
370
  content: accepted,
328
371
  });
372
+ const served = provenance.get(relative);
373
+ if (served !== undefined && served.revision === file.revision) {
374
+ await writeSidecar(options, authority, recorded, relative, served, signal);
375
+ }
329
376
  }
330
377
  authority.files = [...recorded.values()];
331
378
  Object.assign(authority, reached);
@@ -337,16 +384,65 @@ function sameBody(left, right) {
337
384
  return JSON.stringify(left) === JSON.stringify(right);
338
385
  }
339
386
 
387
+ function contentMap(authority) {
388
+ return Object.fromEntries(authority.files.map((file) => [file.path, file.content]));
389
+ }
390
+
391
+ async function pullConfirmed(session, options, state, signal) {
392
+ const enumeration = await replicaDoor(
393
+ session,
394
+ options,
395
+ state,
396
+ KIT_DOOR_ENUMERATE_PATH,
397
+ { prefixes: state.prefixes, positions: positionsOf(state) },
398
+ signal,
399
+ );
400
+ return applyEnumeration(options, state, enumeration, signal);
401
+ }
402
+
403
+ async function settleExpired(session, options, state, pending, signal) {
404
+ const authority = authorityFor(state, pending.body.changes[0].path);
405
+ const predicted = predictedReplica(authority, pending);
406
+ const before = contentMap(authority);
407
+ await pullConfirmed(session, options, state, signal);
408
+ const confirmed = contentMap(authority);
409
+ const touched = [...new Set([...Object.keys(before), ...Object.keys(predicted)])]
410
+ .filter((path) => before[path] !== predicted[path])
411
+ .sort();
412
+ const landed = touched.length > 0 && touched.every((path) => confirmed[path] === predicted[path]);
413
+ await clearPending(options.directory, state.directory);
414
+ if (!landed) {
415
+ return { pending, accepted: await sendBatch(session, options, state, pending.body.changes, signal) };
416
+ }
417
+ const recorded = new Map(authority.files.map((file) => [file.path, file]));
418
+ return {
419
+ pending,
420
+ accepted: {
421
+ authority: authority.authority,
422
+ position: authority.position,
423
+ log_epoch: authority.log_epoch,
424
+ contents: touched.map((path) => ({
425
+ path: housePath(authority.path, path),
426
+ content: recorded.get(path)?.content ?? null,
427
+ revision: recorded.get(path)?.revision ?? null,
428
+ })),
429
+ },
430
+ };
431
+ }
432
+
340
433
  async function recoverPending(session, options, state, signal) {
341
434
  const pending = await readPending(options.directory, state.directory);
342
435
  if (pending === null) return null;
343
436
  try {
344
437
  const outcome = await replicaDoor(session, options, state, pending.path, pending.body, signal);
345
- const accepted = await acceptBatch(options, state, outcome, signal, pending);
438
+ const accepted = await acceptBatch(session, options, state, outcome, signal, pending);
346
439
  await clearPending(options.directory, state.directory);
347
440
  return { pending, accepted };
348
441
  } catch (error) {
349
442
  if (uncertainTransport(error)) throw error;
443
+ if (error.code === "operation_expired") {
444
+ return settleExpired(session, options, state, pending, signal);
445
+ }
350
446
  await clearPending(options.directory, state.directory);
351
447
  throw error;
352
448
  }
@@ -382,7 +478,7 @@ async function sendBatch(session, options, state, changes, signal) {
382
478
  );
383
479
  try {
384
480
  const outcome = await replicaDoor(session, options, state, KIT_DOOR_BATCH_PATH, body, signal);
385
- last = await acceptBatch(options, state, outcome, signal);
481
+ last = await acceptBatch(session, options, state, outcome, signal);
386
482
  await clearPending(options.directory, state.directory);
387
483
  } catch (error) {
388
484
  if (uncertainTransport(error)) throw error;
@@ -409,6 +505,7 @@ export async function bootstrap(options, dependencies = {}) {
409
505
  const canonical = await canonicalReplica(options.replica);
410
506
  const existing = await readReplicaState(options.directory, canonical);
411
507
  if (existing !== null) throw new KitRefusal("kit_sync_already_bootstrapped", canonical);
508
+ await clearPending(options.directory, canonical);
412
509
  return withSession(options, dependencies, async (session, signal) => {
413
510
  const discovery = discoveryOf(
414
511
  await door(session, KIT_DOOR_DISCOVER_PATH, { prefixes: options.prefixes }, signal),
@@ -426,16 +523,7 @@ export async function bootstrap(options, dependencies = {}) {
426
523
 
427
524
  export async function pull(options, dependencies = {}) {
428
525
  return withSession(options, dependencies, async (session, signal) => {
429
- const state = await loadState(options);
430
- const enumeration = await replicaDoor(
431
- session,
432
- options,
433
- state,
434
- KIT_DOOR_ENUMERATE_PATH,
435
- { prefixes: state.prefixes, positions: positionsOf(state) },
436
- signal,
437
- );
438
- const next = await applyEnumeration(options, state, enumeration, signal);
526
+ const next = await pullConfirmed(session, options, await loadState(options), signal);
439
527
  return { positions: positionsOf(next) };
440
528
  });
441
529
  }
package/src/tasks.js ADDED
@@ -0,0 +1,75 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ export function coalescedTask(signal, fail, work) {
4
+ let pending = false;
5
+ let task = null;
6
+ const trigger = () => {
7
+ pending = true;
8
+ if (task !== null) return;
9
+ task = (async () => {
10
+ while (pending && !signal.aborted) {
11
+ pending = false;
12
+ await work();
13
+ }
14
+ })()
15
+ .catch((error) => {
16
+ if (signal.aborted) return;
17
+ fail(error);
18
+ })
19
+ .finally(() => {
20
+ task = null;
21
+ if (pending && !signal.aborted) trigger();
22
+ });
23
+ };
24
+ return { trigger, idle: () => task === null, settled: () => task };
25
+ }
26
+
27
+ export function residentWake() {
28
+ let pending = false;
29
+ let release = null;
30
+ return {
31
+ notify() {
32
+ pending = true;
33
+ release?.();
34
+ },
35
+ async wait(milliseconds, signal) {
36
+ if (!pending && !signal.aborted) {
37
+ const pause = new AbortController();
38
+ const paused = AbortSignal.any([signal, pause.signal]);
39
+ try {
40
+ await Promise.race([
41
+ new Promise((resolve) => {
42
+ release = resolve;
43
+ }),
44
+ Number.isFinite(milliseconds)
45
+ ? delay(milliseconds, undefined, { signal: paused })
46
+ : new Promise((resolve) => paused.addEventListener("abort", resolve, { once: true })),
47
+ ]);
48
+ } catch (error) {
49
+ if (!signal.aborted) throw error;
50
+ } finally {
51
+ release = null;
52
+ pause.abort();
53
+ }
54
+ }
55
+ const woken = pending;
56
+ pending = false;
57
+ return woken;
58
+ },
59
+ };
60
+ }
61
+
62
+ const turns = new Map();
63
+
64
+ export function exclusively(path, work) {
65
+ const turn = (turns.get(path) ?? Promise.resolve()).then(work);
66
+ const settled = turn.then(
67
+ () => undefined,
68
+ () => undefined,
69
+ );
70
+ turns.set(path, settled);
71
+ void settled.then(() => {
72
+ if (turns.get(path) === settled) turns.delete(path);
73
+ });
74
+ return turn;
75
+ }
@@ -0,0 +1,214 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ TELEGRAM_RICH_MESSAGE_BLOCKS_MAX,
4
+ TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX,
5
+ } from "./contract.js";
6
+
7
+ const PARTS = Object.freeze({ draft: "blocks", plan: "steps" });
8
+ const UNCOUNTED = new Set(["type", "language"]);
9
+
10
+ function richCharacters(value, key = null) {
11
+ if (typeof value === "string") return UNCOUNTED.has(key) ? 0 : value.length;
12
+ if (Array.isArray(value)) return value.reduce((sum, item) => sum + richCharacters(item), 0);
13
+ if (typeof value === "object" && value !== null) {
14
+ return Object.entries(value).reduce((sum, [name, item]) => sum + richCharacters(item, name), 0);
15
+ }
16
+ return 0;
17
+ }
18
+
19
+ function richText(value, key = null) {
20
+ if (typeof value === "string") return UNCOUNTED.has(key) ? [] : [value];
21
+ if (Array.isArray(value)) return value.flatMap((item) => richText(item));
22
+ if (typeof value === "object" && value !== null) {
23
+ return Object.entries(value).flatMap(([name, item]) => richText(item, name));
24
+ }
25
+ return [];
26
+ }
27
+
28
+ function slices(text) {
29
+ const pieces = [];
30
+ let start = 0;
31
+ while (start < text.length) {
32
+ let end = Math.min(start + TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX, text.length);
33
+ const last = text.charCodeAt(end - 1);
34
+ if (end < text.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
35
+ pieces.push(text.slice(start, end));
36
+ start = end;
37
+ }
38
+ return pieces;
39
+ }
40
+
41
+ function grouped(block, listed) {
42
+ const groups = [];
43
+ let group = [];
44
+ let characters = 0;
45
+ for (const entry of block[listed]) {
46
+ const length = richCharacters(entry);
47
+ if (group.length > 0 && characters + length > TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) {
48
+ groups.push(group);
49
+ group = [];
50
+ characters = 0;
51
+ }
52
+ group.push(entry);
53
+ characters += length;
54
+ }
55
+ if (group.length > 0) groups.push(group);
56
+ return groups.flatMap((entries) => fitted({ ...block, [listed]: entries }));
57
+ }
58
+
59
+ function fitted(block) {
60
+ if (richCharacters(block) <= TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) return [block];
61
+ if (typeof block.text === "string") return slices(block.text).map((text) => ({ ...block, text }));
62
+ if (typeof block.expression === "string") {
63
+ return slices(block.expression).map((expression) => ({ ...block, expression }));
64
+ }
65
+ for (const listed of ["items", "cells"]) {
66
+ if (Array.isArray(block[listed]) && block[listed].length > 1) return grouped(block, listed);
67
+ }
68
+ return slices(richText(block).join("\n")).map((text) => ({ type: "paragraph", text }));
69
+ }
70
+
71
+ export function richPieces(message) {
72
+ const pieces = [];
73
+ let blocks = [];
74
+ let characters = 0;
75
+ for (const part of message.blocks.flatMap(fitted)) {
76
+ const length = richCharacters(part);
77
+ if (
78
+ blocks.length > 0 &&
79
+ (blocks.length >= TELEGRAM_RICH_MESSAGE_BLOCKS_MAX ||
80
+ characters + length > TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX)
81
+ ) {
82
+ pieces.push({ ...message, blocks });
83
+ blocks = [];
84
+ characters = 0;
85
+ }
86
+ blocks.push(part);
87
+ characters += length;
88
+ }
89
+ pieces.push({ ...message, blocks });
90
+ return pieces;
91
+ }
92
+
93
+ function listItems(items, ordered) {
94
+ return items.map((item, index) => ({
95
+ blocks: [{ type: "paragraph", text: item }],
96
+ ...(ordered ? { value: index + 1, type: "1" } : {}),
97
+ }));
98
+ }
99
+
100
+ function telegramBlock(block) {
101
+ if (typeof block !== "object" || block === null) return null;
102
+ if (block.type === "paragraph") return { type: "paragraph", text: block.text };
103
+ if (block.type === "heading") return { type: "heading", size: block.level, text: block.text };
104
+ if (block.type === "code") {
105
+ return {
106
+ type: "pre",
107
+ ...(typeof block.language === "string" ? { language: block.language } : {}),
108
+ text: block.text,
109
+ };
110
+ }
111
+ if (block.type === "list" && Array.isArray(block.items)) {
112
+ return { type: "list", items: listItems(block.items, block.ordered === true) };
113
+ }
114
+ if (block.type === "table" && Array.isArray(block.headers) && Array.isArray(block.rows)) {
115
+ return {
116
+ type: "table",
117
+ cells: [
118
+ block.headers.map((text) => ({ text, is_header: true })),
119
+ ...block.rows.map((row) => (Array.isArray(row) ? row : []).map((text) => ({ text }))),
120
+ ],
121
+ is_bordered: true,
122
+ };
123
+ }
124
+ if (block.type === "formula") return { type: "mathematical_expression", expression: block.expression };
125
+ return null;
126
+ }
127
+
128
+ function planBlocks(steps) {
129
+ return [
130
+ { type: "heading", text: "Plan", size: 3 },
131
+ {
132
+ type: "list",
133
+ items: steps.map((step, index) => ({
134
+ blocks: [{ type: "paragraph", text: [`${step.label} — `, { type: "bold", text: step.status }] }],
135
+ value: index + 1,
136
+ type: "1",
137
+ })),
138
+ },
139
+ ];
140
+ }
141
+
142
+ function draftId(turn) {
143
+ return (createHash("sha256").update(turn).digest().readUInt32BE(0) % 2_147_483_647) + 1;
144
+ }
145
+
146
+ function viewFrame(frame) {
147
+ if (typeof frame !== "object" || frame === null || !Object.hasOwn(PARTS, frame.type)) return null;
148
+ for (const named of ["conversation_id", "turn_id"]) {
149
+ if (typeof frame[named] !== "string" || frame[named] === "") return null;
150
+ }
151
+ if (!Number.isSafeInteger(frame.sequence) || frame.sequence < 1) return null;
152
+ if (!Number.isSafeInteger(frame.from) || frame.from < 0) return null;
153
+ return Array.isArray(frame[PARTS[frame.type]]) ? frame : null;
154
+ }
155
+
156
+ export function turnViews(options) {
157
+ let threads = new Map();
158
+ const views = new Map();
159
+
160
+ function applied(view, frame) {
161
+ const items = frame[PARTS[frame.type]];
162
+ const held = view[frame.type];
163
+ if (held !== null && frame.sequence <= held.sequence) return false;
164
+ if (frame.from === 0) {
165
+ view[frame.type] = { sequence: frame.sequence, items };
166
+ view.asked = false;
167
+ return true;
168
+ }
169
+ if (held === null || frame.sequence !== held.sequence + 1 || frame.from > held.items.length) {
170
+ if (!view.asked) {
171
+ view.asked = true;
172
+ options.gap({ type: "gap", conversation_id: frame.conversation_id, turn_id: frame.turn_id });
173
+ }
174
+ return false;
175
+ }
176
+ view[frame.type] = { sequence: frame.sequence, items: [...held.items.slice(0, frame.from), ...items] };
177
+ return true;
178
+ }
179
+
180
+ return {
181
+ address(listed) {
182
+ threads = new Map(listed.map((thread) => [thread.conversation_id, thread]));
183
+ for (const conversation of views.keys()) {
184
+ if (!threads.has(conversation)) views.delete(conversation);
185
+ }
186
+ },
187
+ receive(value) {
188
+ const frame = viewFrame(value);
189
+ if (frame === null || !threads.has(frame.conversation_id)) return null;
190
+ let view = views.get(frame.conversation_id);
191
+ if (view === undefined || view.turn !== frame.turn_id) {
192
+ view = { turn: frame.turn_id, draft: null, plan: null, asked: false };
193
+ views.set(frame.conversation_id, view);
194
+ }
195
+ return applied(view, frame) ? frame.conversation_id : null;
196
+ },
197
+ draft(conversation) {
198
+ const view = views.get(conversation);
199
+ const thread = threads.get(conversation);
200
+ if (view === undefined || thread === undefined) return null;
201
+ const blocks = [
202
+ ...(view.plan === null ? [] : planBlocks(view.plan.items)),
203
+ ...(view.draft === null ? [] : view.draft.items.map(telegramBlock).filter((block) => block !== null)),
204
+ ];
205
+ if (blocks.length === 0) return null;
206
+ return {
207
+ chat_id: thread.chat_id,
208
+ topic_id: thread.topic_id,
209
+ draft_id: draftId(view.turn),
210
+ rich_message: richPieces({ blocks })[0],
211
+ };
212
+ },
213
+ };
214
+ }