@agentshouse/kit 0.1.0-alpha.4 → 0.1.0-alpha.40

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 (150) hide show
  1. package/README.md +41 -1671
  2. package/bin/connect-linux.sh +501 -53
  3. package/bin/connect-windows.ps1 +430 -0
  4. package/bin/kit.js +200 -159
  5. package/bootstrap.json +28 -0
  6. package/docs/configuration.md +104 -0
  7. package/docs/google.md +33 -0
  8. package/docs/linux.md +77 -0
  9. package/docs/macos.md +26 -0
  10. package/docs/telegram.md +29 -0
  11. package/docs/windows.md +36 -0
  12. package/package.json +7 -2
  13. package/src/adapter-mcp.js +92 -12
  14. package/src/agents.js +63 -8
  15. package/src/binding.js +52 -0
  16. package/src/blocks.js +2 -26
  17. package/src/bridge.js +28 -14
  18. package/src/bytes.js +19 -8
  19. package/src/configuration.js +14 -211
  20. package/src/connector-login.js +3 -84
  21. package/src/contract.js +44 -12
  22. package/src/conversation-originals.js +1 -3
  23. package/src/delivery.js +4 -7
  24. package/src/directory.js +6 -26
  25. package/src/draft.js +4 -82
  26. package/src/git-main.js +138 -0
  27. package/src/git.js +62 -0
  28. package/src/google/calendar.js +39 -45
  29. package/src/google/drive.js +61 -186
  30. package/src/google/entries.js +349 -0
  31. package/src/google/gateway.js +3 -6
  32. package/src/google/gmail.js +33 -23
  33. package/src/google/oauth.js +106 -66
  34. package/src/google/source.js +21 -23
  35. package/src/host.js +6 -2
  36. package/src/house-main.js +65 -8
  37. package/src/house.js +446 -127
  38. package/src/how-we-work.md +25 -0
  39. package/src/inbound.js +2 -18
  40. package/src/kit-work-stream.js +120 -0
  41. package/src/launcher.js +46 -10
  42. package/src/local-files.js +3 -11
  43. package/src/login.js +27 -14
  44. package/src/mcp.js +18 -0
  45. package/src/mount.js +9 -18
  46. package/src/original.js +4 -4
  47. package/src/pacing.js +41 -0
  48. package/src/plan.js +37 -45
  49. package/src/repository.js +3 -61
  50. package/src/session-bin/git +2 -0
  51. package/src/setup.js +14 -15
  52. package/src/skill-set/ask-matt/PHASE-BOUNDARIES.md +55 -0
  53. package/src/skill-set/ask-matt/SKILL.md +91 -0
  54. package/src/skill-set/ask-matt/agents/openai.yaml +4 -0
  55. package/src/skill-set/code-review/LICENSE +21 -0
  56. package/src/skill-set/code-review/SKILL.md +89 -0
  57. package/src/skill-set/code-review/agents/openai.yaml +3 -0
  58. package/src/skill-set/codebase-design/DEEPENING.md +37 -0
  59. package/src/skill-set/codebase-design/DESIGN-IT-TWICE.md +44 -0
  60. package/src/skill-set/codebase-design/LICENSE +21 -0
  61. package/src/skill-set/codebase-design/SKILL.md +116 -0
  62. package/src/skill-set/codebase-design/agents/openai.yaml +3 -0
  63. package/src/skill-set/diagnosing-bugs/LICENSE +21 -0
  64. package/src/skill-set/diagnosing-bugs/SKILL.md +140 -0
  65. package/src/skill-set/diagnosing-bugs/agents/openai.yaml +3 -0
  66. package/src/skill-set/diagnosing-bugs/scripts/hitl-loop.template.sh +44 -0
  67. package/src/skill-set/domain-modeling/ADR-FORMAT.md +47 -0
  68. package/src/skill-set/domain-modeling/CONTEXT-FORMAT.md +60 -0
  69. package/src/skill-set/domain-modeling/LICENSE +21 -0
  70. package/src/skill-set/domain-modeling/SKILL.md +76 -0
  71. package/src/skill-set/domain-modeling/agents/openai.yaml +3 -0
  72. package/src/skill-set/grill-me/LICENSE +21 -0
  73. package/src/skill-set/grill-me/SKILL.md +8 -0
  74. package/src/skill-set/grill-me/agents/openai.yaml +4 -0
  75. package/src/skill-set/grill-with-docs/LICENSE +21 -0
  76. package/src/skill-set/grill-with-docs/SKILL.md +8 -0
  77. package/src/skill-set/grill-with-docs/agents/openai.yaml +4 -0
  78. package/src/skill-set/grilling/LICENSE +21 -0
  79. package/src/{starter-set → skill-set}/grilling/SKILL.md +1 -1
  80. package/src/skill-set/handoff/LICENSE +21 -0
  81. package/src/skill-set/handoff/SKILL.md +17 -0
  82. package/src/skill-set/handoff/agents/openai.yaml +4 -0
  83. package/src/skill-set/implement/LICENSE +21 -0
  84. package/src/skill-set/implement/SKILL.md +16 -0
  85. package/src/skill-set/implement/agents/openai.yaml +4 -0
  86. package/src/skill-set/improve-codebase-architecture/HTML-REPORT.md +123 -0
  87. package/src/skill-set/improve-codebase-architecture/LICENSE +21 -0
  88. package/src/skill-set/improve-codebase-architecture/SKILL.md +72 -0
  89. package/src/skill-set/improve-codebase-architecture/agents/openai.yaml +4 -0
  90. package/src/skill-set/prototype/LICENSE +21 -0
  91. package/src/skill-set/prototype/LOGIC.md +67 -0
  92. package/src/skill-set/prototype/SKILL.md +28 -0
  93. package/src/skill-set/prototype/UI.md +112 -0
  94. package/src/skill-set/prototype/agents/openai.yaml +3 -0
  95. package/src/skill-set/research/LICENSE +21 -0
  96. package/src/skill-set/research/SKILL.md +14 -0
  97. package/src/skill-set/research/agents/openai.yaml +3 -0
  98. package/src/skill-set/resolving-merge-conflicts/LICENSE +21 -0
  99. package/src/skill-set/resolving-merge-conflicts/SKILL.md +16 -0
  100. package/src/skill-set/resolving-merge-conflicts/agents/openai.yaml +3 -0
  101. package/src/skill-set/tdd/LICENSE +21 -0
  102. package/src/skill-set/tdd/SKILL.md +40 -0
  103. package/src/skill-set/tdd/agents/openai.yaml +3 -0
  104. package/src/skill-set/tdd/mocking.md +59 -0
  105. package/src/skill-set/tdd/tests.md +77 -0
  106. package/src/skill-set/teach/GLOSSARY-FORMAT.md +35 -0
  107. package/src/skill-set/teach/LEARNING-RECORD-FORMAT.md +46 -0
  108. package/src/skill-set/teach/LICENSE +21 -0
  109. package/src/skill-set/teach/MISSION-FORMAT.md +31 -0
  110. package/src/skill-set/teach/RESOURCES-FORMAT.md +32 -0
  111. package/src/skill-set/teach/SKILL.md +141 -0
  112. package/src/skill-set/teach/agents/openai.yaml +4 -0
  113. package/src/skill-set/to-questionnaire/LICENSE +21 -0
  114. package/src/skill-set/to-questionnaire/SKILL.md +55 -0
  115. package/src/skill-set/to-questionnaire/agents/openai.yaml +4 -0
  116. package/src/skill-set/to-spec/LICENSE +21 -0
  117. package/src/skill-set/to-spec/SKILL.md +76 -0
  118. package/src/skill-set/to-spec/agents/openai.yaml +4 -0
  119. package/src/skill-set/to-tickets/LICENSE +21 -0
  120. package/src/skill-set/to-tickets/SKILL.md +106 -0
  121. package/src/skill-set/to-tickets/agents/openai.yaml +4 -0
  122. package/src/skill-set/wait-what/LICENSE +21 -0
  123. package/src/skill-set/wait-what/SKILL.md +8 -0
  124. package/src/skill-set/wait-what/agents/openai.yaml +4 -0
  125. package/src/skill-set/wayfinder/LICENSE +21 -0
  126. package/src/skill-set/wayfinder/SKILL.md +129 -0
  127. package/src/skill-set/wayfinder/agents/openai.yaml +4 -0
  128. package/src/skill-set/writing-for-agents/LICENSE +21 -0
  129. package/src/skill-set/writing-for-agents/SKILL-MECHANICS.md +22 -0
  130. package/src/skill-set/writing-for-agents/SKILL.md +83 -0
  131. package/src/skill-set/writing-for-agents/agents/openai.yaml +3 -0
  132. package/src/skill-set.js +85 -0
  133. package/src/source.js +93 -54
  134. package/src/state.js +19 -1
  135. package/src/sync-library.js +7 -11
  136. package/src/sync-state.js +12 -3
  137. package/src/sync.js +142 -43
  138. package/src/tasks.js +92 -0
  139. package/src/telegram-view.js +217 -0
  140. package/src/telegram.js +204 -69
  141. package/src/turn-frames.js +69 -0
  142. package/src/worker.js +738 -189
  143. package/src/working-copy.js +1274 -0
  144. package/src/workspace.js +299 -88
  145. package/src/google/declared.js +0 -146
  146. package/src/google/id.js +0 -1
  147. package/src/instance/google.js +0 -1
  148. package/src/starter-set.js +0 -17
  149. /package/src/{starter-set/grilling → skill-set/ask-matt}/LICENSE +0 -0
  150. /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);
@@ -110,7 +111,7 @@ function matchedDiscovery(discovery, scope) {
110
111
  );
111
112
  }
112
113
 
113
- async function writeScope(replica, scope, signal) {
114
+ function scopeContents(replica, scope) {
114
115
  if (
115
116
  typeof scope?.manifest?.path !== "string" ||
116
117
  typeof scope.manifest.position !== "string" ||
@@ -121,36 +122,54 @@ async function writeScope(replica, scope, signal) {
121
122
  if (!Array.isArray(scope.contents) || !Array.isArray(scope.manifest.files)) {
122
123
  throw new KitRefusal("kit_bootstrap_rejected");
123
124
  }
124
- const files = [];
125
125
  for (const entry of scope.contents) {
126
126
  if (typeof entry?.path !== "string" || typeof entry.content !== "string") {
127
127
  throw new KitRefusal("kit_bootstrap_rejected");
128
128
  }
129
- const meta = scope.manifest.files.find((file) => file.path === entry.path);
129
+ locate(replica, scope.manifest.path, entry.path);
130
+ }
131
+ return scope.contents;
132
+ }
133
+
134
+ function servedFile(scope, path) {
135
+ return scope.manifest.files.find((file) => file?.path === path);
136
+ }
137
+
138
+ async function writeScope(replica, scope, signal) {
139
+ const recorded = new Map();
140
+ for (const entry of scopeContents(replica, scope)) {
141
+ const meta = servedFile(scope, entry.path) ?? {};
130
142
  await writeReplicaFile(locate(replica, scope.manifest.path, entry.path), entry.content, signal);
131
- files.push({
132
- path: entry.path,
133
- revision: typeof meta?.revision === "string" ? meta.revision : "",
134
- sha256: typeof meta?.sha256 === "string" ? meta.sha256 : "",
135
- content: entry.content,
136
- });
143
+ recorded.set(entry.path, enumeratedRecord(entry.path, entry.content, meta));
144
+ if (typeof meta.provenance === "string") {
145
+ await writeSidecar({ replica }, scope.manifest, recorded, entry.path, meta, signal);
146
+ }
137
147
  }
138
- return files;
148
+ return [...recorded.values()];
139
149
  }
140
150
 
141
151
  async function replaceScope(replica, previous, scope, signal) {
142
- const next = await writeScope(replica, scope, signal);
143
- const kept = new Set(next.map((file) => file.path));
152
+ const kept = new Set(
153
+ scopeContents(replica, scope).flatMap((entry) =>
154
+ typeof servedFile(scope, entry.path)?.provenance === "string"
155
+ ? [entry.path, sidecarPath(entry.path)]
156
+ : [entry.path],
157
+ ),
158
+ );
144
159
  const root = locate(replica, scope.manifest.path);
145
160
  for (const path of await listFiles(root)) {
146
- if (!kept.has(path)) await removeReplicaFile(locate(replica, scope.manifest.path, path));
161
+ if (!kept.has(path)) await removeReplicaFile(replica, scope.manifest.path, path);
147
162
  }
148
163
  if (previous !== undefined) {
149
164
  for (const file of previous.files) {
150
- if (!kept.has(file.path)) await removeReplicaFile(locate(replica, previous.path, file.path));
165
+ if (!kept.has(file.path)) await removeReplicaFile(replica, previous.path, file.path);
151
166
  }
152
167
  }
153
- return next;
168
+ return writeScope(replica, scope, signal);
169
+ }
170
+
171
+ function removalsFirst(entries, removal) {
172
+ return [...entries.filter(removal), ...entries.filter((entry) => !removal(entry))];
154
173
  }
155
174
 
156
175
  function stringList(value) {
@@ -238,6 +257,18 @@ function enumeratedRecord(path, content, change) {
238
257
  };
239
258
  }
240
259
 
260
+ async function writeSidecar(options, authority, recorded, path, change, signal) {
261
+ const sidecar = sidecarPath(path);
262
+ await writeReplicaFile(locate(options.replica, authority.path, sidecar), change.provenance, signal);
263
+ recorded.set(sidecar, enumeratedRecord(sidecar, change.provenance, change));
264
+ }
265
+
266
+ async function removeSidecar(options, authority, recorded, path) {
267
+ const sidecar = sidecarPath(path);
268
+ await removeReplicaFile(options.replica, authority.path, sidecar);
269
+ recorded.delete(sidecar);
270
+ }
271
+
241
272
  async function applyEnumeration(options, state, enumeration, signal) {
242
273
  if (!Array.isArray(enumeration?.authorities)) throw new KitRefusal("kit_sync_rejected", "enumerate");
243
274
  for (const view of enumeration.authorities) {
@@ -245,11 +276,12 @@ async function applyEnumeration(options, state, enumeration, signal) {
245
276
  if (authority === undefined) throw new KitRefusal("kit_sync_rejected", view.authority);
246
277
  const reached = changePosition(view, "enumerate");
247
278
  const recorded = new Map(authority.files.map((file) => [file.path, file]));
248
- for (const change of view.changes ?? []) {
279
+ for (const change of removalsFirst(view.changes ?? [], (change) => change?.removed === true)) {
249
280
  if (typeof change?.path !== "string") throw new KitRefusal("kit_sync_rejected", "enumerate");
250
281
  if (change.removed === true) {
251
- await removeReplicaFile(locate(options.replica, authority.path, change.path));
282
+ await removeReplicaFile(options.replica, authority.path, change.path);
252
283
  recorded.delete(change.path);
284
+ await removeSidecar(options, authority, recorded, change.path);
253
285
  continue;
254
286
  }
255
287
  if (typeof change.content === "string") {
@@ -261,13 +293,7 @@ async function applyEnumeration(options, state, enumeration, signal) {
261
293
  recorded.set(change.path, enumeratedRecord(change.path, change.content, change));
262
294
  }
263
295
  if (typeof change.provenance === "string") {
264
- const sidecar = sidecarPath(change.path);
265
- await writeReplicaFile(
266
- locate(options.replica, authority.path, sidecar),
267
- change.provenance,
268
- signal,
269
- );
270
- recorded.set(sidecar, enumeratedRecord(sidecar, change.provenance, change));
296
+ await writeSidecar(options, authority, recorded, change.path, change, signal);
271
297
  }
272
298
  }
273
299
  authority.files = [...recorded.values()];
@@ -299,13 +325,41 @@ function uncertainTransport(error) {
299
325
  );
300
326
  }
301
327
 
302
- async function acceptBatch(options, state, outcome, signal, pending = null) {
328
+ async function servedProvenance(session, options, state, authority, signal) {
329
+ const enumeration = await replicaDoor(
330
+ session,
331
+ options,
332
+ state,
333
+ KIT_DOOR_ENUMERATE_PATH,
334
+ {
335
+ prefixes: state.prefixes.filter((prefix) => authorityFor(state, prefix) === authority),
336
+ positions: [
337
+ { authority: authority.authority, position: authority.position, log_epoch: authority.log_epoch },
338
+ ],
339
+ },
340
+ signal,
341
+ );
342
+ const view = Array.isArray(enumeration?.authorities)
343
+ ? enumeration.authorities.find((entry) => entry?.authority === authority.authority)
344
+ : undefined;
345
+ if (!Array.isArray(view?.changes)) throw new KitRefusal("kit_sync_rejected", "enumerate");
346
+ return new Map(
347
+ view.changes
348
+ .filter((change) => typeof change?.provenance === "string")
349
+ .map((change) => [change.path, change]),
350
+ );
351
+ }
352
+
353
+ async function acceptBatch(session, options, state, outcome, signal, pending = null) {
303
354
  const authority = state.authorities.find((entry) => entry.authority === outcome.authority);
304
355
  if (authority === undefined) throw new KitRefusal("kit_sync_rejected", outcome.authority);
305
356
  const reached = changePosition(outcome, "batch");
306
357
  const recorded = new Map(authority.files.map((file) => [file.path, file]));
307
358
  const predicted = predictedReplica(authority, pending);
308
- for (const file of outcome.contents ?? []) {
359
+ const provenance = (outcome.contents ?? []).some((file) => typeof file?.content === "string")
360
+ ? await servedProvenance(session, options, state, authority, signal)
361
+ : new Map();
362
+ for (const file of removalsFirst(outcome.contents ?? [], (file) => file?.content === null)) {
309
363
  const relative = relativeTo(authority.path, file.path);
310
364
  const destination = locate(options.replica, authority.path, relative);
311
365
  const local = await replicaText(destination);
@@ -319,8 +373,9 @@ async function acceptBatch(options, state, outcome, signal, pending = null) {
319
373
  local !== accepted &&
320
374
  local !== expected;
321
375
  if (accepted === null) {
322
- if (!preserve) await removeReplicaFile(destination);
376
+ if (!preserve) await removeReplicaFile(options.replica, authority.path, relative);
323
377
  recorded.delete(relative);
378
+ await removeSidecar(options, authority, recorded, relative);
324
379
  continue;
325
380
  }
326
381
  if (!preserve) await writeReplicaFile(destination, accepted, signal);
@@ -330,6 +385,10 @@ async function acceptBatch(options, state, outcome, signal, pending = null) {
330
385
  sha256: "",
331
386
  content: accepted,
332
387
  });
388
+ const served = provenance.get(relative);
389
+ if (served !== undefined && served.revision === file.revision) {
390
+ await writeSidecar(options, authority, recorded, relative, served, signal);
391
+ }
333
392
  }
334
393
  authority.files = [...recorded.values()];
335
394
  Object.assign(authority, reached);
@@ -341,16 +400,65 @@ function sameBody(left, right) {
341
400
  return JSON.stringify(left) === JSON.stringify(right);
342
401
  }
343
402
 
403
+ function contentMap(authority) {
404
+ return Object.fromEntries(authority.files.map((file) => [file.path, file.content]));
405
+ }
406
+
407
+ async function pullConfirmed(session, options, state, signal) {
408
+ const enumeration = await replicaDoor(
409
+ session,
410
+ options,
411
+ state,
412
+ KIT_DOOR_ENUMERATE_PATH,
413
+ { prefixes: state.prefixes, positions: positionsOf(state) },
414
+ signal,
415
+ );
416
+ return applyEnumeration(options, state, enumeration, signal);
417
+ }
418
+
419
+ async function settleExpired(session, options, state, pending, signal) {
420
+ const authority = authorityFor(state, pending.body.changes[0].path);
421
+ const predicted = predictedReplica(authority, pending);
422
+ const before = contentMap(authority);
423
+ await pullConfirmed(session, options, state, signal);
424
+ const confirmed = contentMap(authority);
425
+ const touched = [...new Set([...Object.keys(before), ...Object.keys(predicted)])]
426
+ .filter((path) => before[path] !== predicted[path])
427
+ .sort();
428
+ const landed = touched.length > 0 && touched.every((path) => confirmed[path] === predicted[path]);
429
+ await clearPending(options.directory, state.directory);
430
+ if (!landed) {
431
+ return { pending, accepted: await sendBatch(session, options, state, pending.body.changes, signal) };
432
+ }
433
+ const recorded = new Map(authority.files.map((file) => [file.path, file]));
434
+ return {
435
+ pending,
436
+ accepted: {
437
+ authority: authority.authority,
438
+ position: authority.position,
439
+ log_epoch: authority.log_epoch,
440
+ contents: touched.map((path) => ({
441
+ path: housePath(authority.path, path),
442
+ content: recorded.get(path)?.content ?? null,
443
+ revision: recorded.get(path)?.revision ?? null,
444
+ })),
445
+ },
446
+ };
447
+ }
448
+
344
449
  async function recoverPending(session, options, state, signal) {
345
450
  const pending = await readPending(options.directory, state.directory);
346
451
  if (pending === null) return null;
347
452
  try {
348
453
  const outcome = await replicaDoor(session, options, state, pending.path, pending.body, signal);
349
- const accepted = await acceptBatch(options, state, outcome, signal, pending);
454
+ const accepted = await acceptBatch(session, options, state, outcome, signal, pending);
350
455
  await clearPending(options.directory, state.directory);
351
456
  return { pending, accepted };
352
457
  } catch (error) {
353
458
  if (uncertainTransport(error)) throw error;
459
+ if (error.code === "operation_expired") {
460
+ return settleExpired(session, options, state, pending, signal);
461
+ }
354
462
  await clearPending(options.directory, state.directory);
355
463
  throw error;
356
464
  }
@@ -386,7 +494,7 @@ async function sendBatch(session, options, state, changes, signal) {
386
494
  );
387
495
  try {
388
496
  const outcome = await replicaDoor(session, options, state, KIT_DOOR_BATCH_PATH, body, signal);
389
- last = await acceptBatch(options, state, outcome, signal);
497
+ last = await acceptBatch(session, options, state, outcome, signal);
390
498
  await clearPending(options.directory, state.directory);
391
499
  } catch (error) {
392
500
  if (uncertainTransport(error)) throw error;
@@ -431,16 +539,7 @@ export async function bootstrap(options, dependencies = {}) {
431
539
 
432
540
  export async function pull(options, dependencies = {}) {
433
541
  return withSession(options, dependencies, async (session, signal) => {
434
- const state = await loadState(options);
435
- const enumeration = await replicaDoor(
436
- session,
437
- options,
438
- state,
439
- KIT_DOOR_ENUMERATE_PATH,
440
- { prefixes: state.prefixes, positions: positionsOf(state) },
441
- signal,
442
- );
443
- const next = await applyEnumeration(options, state, enumeration, signal);
542
+ const next = await pullConfirmed(session, options, await loadState(options), signal);
444
543
  return { positions: positionsOf(next) };
445
544
  });
446
545
  }
package/src/tasks.js ADDED
@@ -0,0 +1,92 @@
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 orderedTask(signal, fail, work) {
28
+ let task = null;
29
+ const push = (...args) => {
30
+ const next = (task ?? Promise.resolve())
31
+ .then(() => (signal.aborted ? undefined : work(...args)))
32
+ .catch((error) => {
33
+ if (signal.aborted) return;
34
+ fail(error);
35
+ });
36
+ task = next;
37
+ void next.then(() => {
38
+ if (task === next) task = null;
39
+ });
40
+ };
41
+ return { push, idle: () => task === null, settled: () => task };
42
+ }
43
+
44
+ export function residentWake() {
45
+ let pending = false;
46
+ let release = null;
47
+ return {
48
+ notify() {
49
+ pending = true;
50
+ release?.();
51
+ },
52
+ async wait(milliseconds, signal) {
53
+ if (!pending && !signal.aborted) {
54
+ const pause = new AbortController();
55
+ const paused = AbortSignal.any([signal, pause.signal]);
56
+ try {
57
+ await Promise.race([
58
+ new Promise((resolve) => {
59
+ release = resolve;
60
+ }),
61
+ Number.isFinite(milliseconds)
62
+ ? delay(milliseconds, undefined, { signal: paused })
63
+ : new Promise((resolve) => paused.addEventListener("abort", resolve, { once: true })),
64
+ ]);
65
+ } catch (error) {
66
+ if (!signal.aborted) throw error;
67
+ } finally {
68
+ release = null;
69
+ pause.abort();
70
+ }
71
+ }
72
+ const woken = pending;
73
+ pending = false;
74
+ return woken;
75
+ },
76
+ };
77
+ }
78
+
79
+ const turns = new Map();
80
+
81
+ export function exclusively(path, work) {
82
+ const turn = (turns.get(path) ?? Promise.resolve()).then(work);
83
+ const settled = turn.then(
84
+ () => undefined,
85
+ () => undefined,
86
+ );
87
+ turns.set(path, settled);
88
+ void settled.then(() => {
89
+ if (turns.get(path) === settled) turns.delete(path);
90
+ });
91
+ return turn;
92
+ }
@@ -0,0 +1,217 @@
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
+ export function slices(text, limit = TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) {
29
+ const pieces = [];
30
+ let start = 0;
31
+ while (start < text.length) {
32
+ let end = Math.min(start + limit, text.length);
33
+ const window = text.slice(start, end);
34
+ const line = window.lastIndexOf("\n");
35
+ const last = text.charCodeAt(end - 1);
36
+ if (end < text.length && line > 0 && /\S/.test(window.slice(0, line))) end = start + line + 1;
37
+ else if (end < text.length && last >= 0xd800 && last <= 0xdbff) end -= 1;
38
+ pieces.push(text.slice(start, end));
39
+ start = end;
40
+ }
41
+ return pieces;
42
+ }
43
+
44
+ function grouped(block, listed) {
45
+ const groups = [];
46
+ let group = [];
47
+ let characters = 0;
48
+ for (const entry of block[listed]) {
49
+ const length = richCharacters(entry);
50
+ if (group.length > 0 && characters + length > TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) {
51
+ groups.push(group);
52
+ group = [];
53
+ characters = 0;
54
+ }
55
+ group.push(entry);
56
+ characters += length;
57
+ }
58
+ if (group.length > 0) groups.push(group);
59
+ return groups.flatMap((entries) => fitted({ ...block, [listed]: entries }));
60
+ }
61
+
62
+ function fitted(block) {
63
+ if (richCharacters(block) <= TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) return [block];
64
+ if (typeof block.text === "string") return slices(block.text).map((text) => ({ ...block, text }));
65
+ if (typeof block.expression === "string") {
66
+ return slices(block.expression).map((expression) => ({ ...block, expression }));
67
+ }
68
+ for (const listed of ["items", "cells"]) {
69
+ if (Array.isArray(block[listed]) && block[listed].length > 1) return grouped(block, listed);
70
+ }
71
+ return slices(richText(block).join("\n")).map((text) => ({ type: "paragraph", text }));
72
+ }
73
+
74
+ export function richPieces(message) {
75
+ const pieces = [];
76
+ let blocks = [];
77
+ let characters = 0;
78
+ for (const part of message.blocks.flatMap(fitted)) {
79
+ const length = richCharacters(part);
80
+ if (
81
+ blocks.length > 0 &&
82
+ (blocks.length >= TELEGRAM_RICH_MESSAGE_BLOCKS_MAX ||
83
+ characters + length > TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX)
84
+ ) {
85
+ pieces.push({ ...message, blocks });
86
+ blocks = [];
87
+ characters = 0;
88
+ }
89
+ blocks.push(part);
90
+ characters += length;
91
+ }
92
+ pieces.push({ ...message, blocks });
93
+ return pieces;
94
+ }
95
+
96
+ function listItems(items, ordered) {
97
+ return items.map((item, index) => ({
98
+ blocks: [{ type: "paragraph", text: item }],
99
+ ...(ordered ? { value: index + 1, type: "1" } : {}),
100
+ }));
101
+ }
102
+
103
+ function telegramBlock(block) {
104
+ if (typeof block !== "object" || block === null) return null;
105
+ if (block.type === "paragraph") return { type: "paragraph", text: block.text };
106
+ if (block.type === "heading") return { type: "heading", size: block.level, text: block.text };
107
+ if (block.type === "code") {
108
+ return {
109
+ type: "pre",
110
+ ...(typeof block.language === "string" ? { language: block.language } : {}),
111
+ text: block.text,
112
+ };
113
+ }
114
+ if (block.type === "list" && Array.isArray(block.items)) {
115
+ return { type: "list", items: listItems(block.items, block.ordered === true) };
116
+ }
117
+ if (block.type === "table" && Array.isArray(block.headers) && Array.isArray(block.rows)) {
118
+ return {
119
+ type: "table",
120
+ cells: [
121
+ block.headers.map((text) => ({ text, is_header: true })),
122
+ ...block.rows.map((row) => (Array.isArray(row) ? row : []).map((text) => ({ text }))),
123
+ ],
124
+ is_bordered: true,
125
+ };
126
+ }
127
+ if (block.type === "formula") return { type: "mathematical_expression", expression: block.expression };
128
+ return null;
129
+ }
130
+
131
+ function planBlocks(steps) {
132
+ return [
133
+ { type: "heading", text: "Plan", size: 3 },
134
+ {
135
+ type: "list",
136
+ items: steps.map((step, index) => ({
137
+ blocks: [{ type: "paragraph", text: [`${step.label} — `, { type: "bold", text: step.status }] }],
138
+ value: index + 1,
139
+ type: "1",
140
+ })),
141
+ },
142
+ ];
143
+ }
144
+
145
+ function draftId(turn) {
146
+ return (createHash("sha256").update(turn).digest().readUInt32BE(0) % 2_147_483_647) + 1;
147
+ }
148
+
149
+ function viewFrame(frame) {
150
+ if (typeof frame !== "object" || frame === null || !Object.hasOwn(PARTS, frame.type)) return null;
151
+ for (const named of ["conversation_id", "turn_id"]) {
152
+ if (typeof frame[named] !== "string" || frame[named] === "") return null;
153
+ }
154
+ if (!Number.isSafeInteger(frame.sequence) || frame.sequence < 1) return null;
155
+ if (!Number.isSafeInteger(frame.from) || frame.from < 0) return null;
156
+ return Array.isArray(frame[PARTS[frame.type]]) ? frame : null;
157
+ }
158
+
159
+ export function turnViews(options) {
160
+ let threads = new Map();
161
+ const views = new Map();
162
+
163
+ function applied(view, frame) {
164
+ const items = frame[PARTS[frame.type]];
165
+ const held = view[frame.type];
166
+ if (held !== null && frame.sequence <= held.sequence) return false;
167
+ if (frame.from === 0) {
168
+ view[frame.type] = { sequence: frame.sequence, items };
169
+ view.asked = false;
170
+ return true;
171
+ }
172
+ if (held === null || frame.sequence !== held.sequence + 1 || frame.from > held.items.length) {
173
+ if (!view.asked) {
174
+ view.asked = true;
175
+ options.gap({ type: "gap", conversation_id: frame.conversation_id, turn_id: frame.turn_id });
176
+ }
177
+ return false;
178
+ }
179
+ view[frame.type] = { sequence: frame.sequence, items: [...held.items.slice(0, frame.from), ...items] };
180
+ return true;
181
+ }
182
+
183
+ return {
184
+ address(listed) {
185
+ threads = new Map(listed.map((thread) => [thread.conversation_id, thread]));
186
+ for (const conversation of views.keys()) {
187
+ if (!threads.has(conversation)) views.delete(conversation);
188
+ }
189
+ },
190
+ receive(value) {
191
+ const frame = viewFrame(value);
192
+ if (frame === null || !threads.has(frame.conversation_id)) return null;
193
+ let view = views.get(frame.conversation_id);
194
+ if (view === undefined || view.turn !== frame.turn_id) {
195
+ view = { turn: frame.turn_id, draft: null, plan: null, asked: false };
196
+ views.set(frame.conversation_id, view);
197
+ }
198
+ return applied(view, frame) ? frame.conversation_id : null;
199
+ },
200
+ draft(conversation) {
201
+ const view = views.get(conversation);
202
+ const thread = threads.get(conversation);
203
+ if (view === undefined || thread === undefined) return null;
204
+ const blocks = [
205
+ ...(view.plan === null ? [] : planBlocks(view.plan.items)),
206
+ ...(view.draft === null ? [] : view.draft.items.map(telegramBlock).filter((block) => block !== null)),
207
+ ];
208
+ if (blocks.length === 0) return null;
209
+ return {
210
+ chat_id: thread.chat_id,
211
+ topic_id: thread.topic_id,
212
+ draft_id: draftId(view.turn),
213
+ rich_message: richPieces({ blocks })[0],
214
+ };
215
+ },
216
+ };
217
+ }