@agentshouse/kit 0.1.0-alpha.32 → 0.1.0-alpha.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@ The Skill set Kit installs is Matt Pocock's
12
12
  On Linux `amd64` or `arm64`, run the published, versioned bootstrap once:
13
13
 
14
14
  ```sh
15
- bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.32/connect-linux.sh)
15
+ bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.34/connect-linux.sh)
16
16
  ```
17
17
 
18
18
  The bootstrap first establishes Docker Engine from what it observes on the
@@ -145,6 +145,37 @@ matching directory beneath `/agents/house`, through the resident's lease when
145
145
  the container runs, and refuses a directory outside the workspace before
146
146
  anything reaches House.
147
147
 
148
+ ## Connect a macOS computer
149
+
150
+ On a Mac with Apple silicon or an Intel chip, run the same
151
+ `bash <(curl -fsSL …/connect-linux.sh)` command as
152
+ [a Linux computer](#connect-a-linux-computer). It runs in macOS's own
153
+ `bash` and reads the chip from the hardware, so it selects `arm64` or `amd64`
154
+ correctly even from a terminal running under Rosetta.
155
+
156
+ The bootstrap first establishes Docker Desktop from what it observes. A Docker
157
+ Desktop that already answers is used as it is. Without
158
+ `/Applications/Docker.app` it follows Docker's
159
+ [command-line installation](https://docs.docker.com/desktop/setup/install/mac-install/):
160
+ it downloads `Docker.dmg` for the chip from `desktop.docker.com`, attaches it
161
+ through `sudo hdiutil`, runs its `install --user=<account>` through `sudo`, and
162
+ detaches it. `sudo` asks for the password as it ordinarily does, and the
163
+ installer does not accept Docker's Subscription Service Agreement for you. The
164
+ bootstrap then opens Docker Desktop, including one a restart left stopped, and
165
+ waits up to five minutes for its Linux containers to answer while you accept
166
+ its terms or finish what its window asks. If Docker Desktop is still not ready,
167
+ or the download or the installer fails, the bootstrap names the cause and changes
168
+ nothing further; running it again continues from that state. Docker Desktop
169
+ built for the other chip is refused. The bootstrap reaches `docker` in
170
+ Docker Desktop's app bundle when no terminal has added it to `PATH` yet.
171
+
172
+ Everything after that is the Linux journey above, with `open` as the host
173
+ browser opener. With an HTTPS House, login's container publishes its one
174
+ callback port on the Mac's `127.0.0.1`, so House Login returns from the Mac's
175
+ browser, and no Kit container uses the host network. A loopback House needs
176
+ Docker Desktop's host networking enabled. A `--workspace` root must be a
177
+ directory Docker Desktop shares, such as one under `/Users`.
178
+
148
179
  ## Building the package
149
180
 
150
181
  The repository checkout is the development source. Build the package that can
@@ -414,7 +445,8 @@ service to a signed-in account asks only for that service's consent, and each
414
445
  service keeps its own grant. An address that answers another consent, or a code
415
446
  Google refuses to exchange, stores nothing; a declined consent or a ceremony
416
447
  that outlives its ten-minute bound cancels the sign-in, and the Kit tries again
417
- at the next reconciliation.
448
+ at the next reconciliation. A capture that finds the grant revoked removes it,
449
+ reports the entry `signed_out` and opens a new sign-in at once.
418
450
 
419
451
  The resident captures each signed-in entry every 15 minutes, with no command.
420
452
  An occurrence never overlaps its entry's previous one, and one missed while the
@@ -1545,8 +1577,9 @@ kit sync rebootstrap ./replica
1545
1577
  ```
1546
1578
 
1547
1579
  `bootstrap` writes `ROOM.md`, Library text, House-derived projections, and
1548
- Capture text with its provenance sidecar, and records one position per selected
1549
- authority from the bundle, with the log epoch House issued it in, together with
1580
+ Capture text, with the provenance House serves on a file's manifest entry as
1581
+ its `_provenance` sidecar, and records one position per selected authority from
1582
+ the bundle, with the log epoch House issued it in, together with
1550
1583
  the positive operation scopes House declared for it: `read`, `sync`, `write`,
1551
1584
  and `delete`, each a list of paths relative to the authority under which that
1552
1585
  operation is admitted. Managed replication needs `read` plus `sync` on a Room;
@@ -1555,11 +1588,11 @@ with `operation_denied`. Generated `_provenance` files delivered beside authored
1555
1588
  Library paths are House metadata, not authored Library documents. `pull` presents
1556
1589
  each recorded position with its log epoch, applies each enumerated path's whole
1557
1590
  content or removal, and advances those positions. A removal, pulled or accepted
1558
- from `submit`, also removes each directory it leaves empty up to the authority,
1559
- so a later file can take that path; a directory still holding a file, such as a
1560
- local draft, stays. A position older than House's
1561
- declared retention, or one of a log epoch before House's database was restored,
1562
- is reported as `position_expired`; only `rebootstrap` rebuilds the directory.
1591
+ from `submit`, also removes the file's `_provenance` sidecar and each directory
1592
+ it leaves empty up to the authority, so a later file can take that path; a
1593
+ directory still holding a file, such as a local draft, stays. A position older
1594
+ than House's declared retention, or one of a log epoch before House's database
1595
+ was restored, is reported as `position_expired`; only `rebootstrap` rebuilds the directory.
1563
1596
 
1564
1597
  `submit` sends field, section, and preamble operations with that value's prior
1565
1598
  content as base, and whole-file, rename, and removal operations with the file
@@ -1772,7 +1805,12 @@ another, beside the live drafts described below:
1772
1805
  the answer is absent or unreadable. File items are pulled back from House,
1773
1806
  refused as `file_too_large` above the 10 MiB image ceiling, uploaded as
1774
1807
  `sendPhoto` with the caption House composed around the link to the retained
1775
- original, and reported the same way; a pull that fails reports
1808
+ original, and reported the same way. A caption past Telegram's
1809
+ 1,024-character caption limit keeps its head under the image, cut at its last
1810
+ line break that fits or else at the limit, and the rest follows in the topic
1811
+ as ordinary messages; the upload is reported with the image's answer when
1812
+ every part was delivered and otherwise with the answer of the first part that
1813
+ was not. A pull that fails reports
1776
1814
  `transfer_failed` and an ambiguous upload answer reports `delivery_unknown`.
1777
1815
  Only an image ever becomes a file item, because House offers every other file
1778
1816
  as a `conversation_file_link` message this queue delivers like any other. The
@@ -1783,6 +1821,10 @@ another, beside the live drafts described below:
1783
1821
  between blocks and inside a block only when that block alone exceeds them,
1784
1822
  and reported once, `delivered` with the first message's id when every part
1785
1823
  was delivered and otherwise with the answer of the first part that was not.
1824
+ A new plain-text message past Telegram's 4,096-character message limit, such
1825
+ as a `conversation_file_link` text, is sent and reported the same way, cut
1826
+ at the last line break that fits or else at the limit, so the first message
1827
+ keeps its reply and keyboard and a link on its own line stays whole.
1786
1828
  Each answer also names the `threads` the bot's topics carry, a
1787
1829
  `conversation_id` with its `chat_id` and `topic_id`, which is where live
1788
1830
  drafts go.
@@ -15,7 +15,12 @@ FORWARD=()
15
15
  UBUNTU_RELEASES=(jammy noble resolute)
16
16
  DOCKER_PACKAGES=(docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin)
17
17
  CONFLICTING_PACKAGES=(docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc)
18
- DOCKER_GROUP=0
18
+ DOCKER_APP=/Applications/Docker.app
19
+ DESKTOP_WAIT_SECONDS=300
20
+ RUNTIME='Docker Engine'
21
+ OPENER=xdg-open
22
+ NETWORK=(--network host)
23
+ PUBLISHED=()
19
24
 
20
25
  refuse() {
21
26
  printf 'kit_bootstrap_refused: %s\n' "$1" >&2
@@ -32,11 +37,11 @@ login_kit() {
32
37
  "${DOCKER_RUN[@]:0:2}" -i "${DOCKER_RUN[@]:2}" login "$@"
33
38
  return
34
39
  fi
35
- command -v xdg-open >/dev/null || refuse 'no host browser opener; rerun with --manual'
36
- "${DOCKER_RUN[@]}" login "$@" --host-browser | while IFS= read -r line; do
40
+ command -v "$OPENER" >/dev/null || refuse 'no host browser opener; rerun with --manual'
41
+ "${DOCKER_RUN[@]:0:2}" ${PUBLISHED[@]+"${PUBLISHED[@]}"} "${DOCKER_RUN[@]:2}" login "$@" --host-browser | while IFS= read -r line; do
37
42
  if [[ "$line" == 'Open '* ]]; then
38
43
  printf 'Opening House Login in the host browser.\n'
39
- xdg-open "${line#Open }" >/dev/null 2>&1 || refuse 'host browser did not open; rerun with --manual'
44
+ "$OPENER" "${line#Open }" >/dev/null 2>&1 || refuse 'host browser did not open; rerun with --manual'
40
45
  else
41
46
  printf '%s\n' "$line"
42
47
  fi
@@ -46,15 +51,7 @@ login_kit() {
46
51
  start_resident() {
47
52
  local network=()
48
53
  [[ "$HOUSE" == https://* ]] || network=(--network host)
49
- docker run -d --name "$NAME" --restart unless-stopped "${network[@]}" --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --label "agentshouse.house=$HOUSE" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 "$IMAGE" resident >/dev/null
50
- }
51
-
52
- docker() {
53
- if ((DOCKER_GROUP)); then
54
- sudo -u "$(id -un)" docker "$@"
55
- else
56
- command docker "$@"
57
- fi
54
+ docker run -d --name "$NAME" --restart unless-stopped ${network[@]+"${network[@]}"} --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --label "agentshouse.house=$HOUSE" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 "$IMAGE" resident >/dev/null
58
55
  }
59
56
 
60
57
  elevated() {
@@ -82,6 +79,41 @@ unanswered() {
82
79
  printf '; Docker Engine does not answer: %s' "$(docker_cause)"
83
80
  }
84
81
 
82
+ install_docker_desktop() {
83
+ local mount
84
+ DOWNLOAD=$(mktemp -d) || refuse 'no temporary directory for the Docker Desktop download'
85
+ trap 'rm -rf "$DOWNLOAD"' EXIT
86
+ mount="$DOWNLOAD/volume"
87
+ printf 'Downloading Docker Desktop for %s from docker.com.\n' "$1"
88
+ curl -fsSL -o "$DOWNLOAD/Docker.dmg" "https://desktop.docker.com/mac/main/$ARCH/Docker.dmg" || refuse 'Docker Desktop could not be downloaded'
89
+ printf 'Installing Docker Desktop in /Applications; sudo may ask for your password.\n'
90
+ mkdir "$mount" || refuse 'the Docker Desktop disk image has no mount point'
91
+ elevated hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$DOWNLOAD/Docker.dmg" >/dev/null || refuse 'the Docker Desktop disk image could not be opened'
92
+ if ! elevated "$mount/Docker.app/Contents/MacOS/install" "--user=$(id -un)"; then
93
+ elevated hdiutil detach "$mount" >/dev/null 2>&1 || true
94
+ refuse 'the Docker Desktop installer did not finish'
95
+ fi
96
+ elevated hdiutil detach "$mount" >/dev/null || refuse 'the Docker Desktop disk image could not be closed'
97
+ }
98
+
99
+ establish_docker_desktop() {
100
+ PATH="$PATH:$DOCKER_APP/Contents/Resources/bin"
101
+ docker_answers && return
102
+ if ((FORWARDING)); then
103
+ [[ -d "$DOCKER_APP" ]] || refuse 'House Kit is not installed; run this bootstrap without arguments first'
104
+ refuse "Docker Desktop does not answer: $(docker_cause); start Docker Desktop, then rerun this command"
105
+ fi
106
+ [[ -d "$DOCKER_APP" ]] || install_docker_desktop "$1"
107
+ printf 'Starting Docker Desktop; accept its terms or finish its setup if its window asks.\n'
108
+ open "$DOCKER_APP" || refuse 'Docker Desktop could not be opened'
109
+ local waited=0
110
+ until docker_answers; do
111
+ ((waited < DESKTOP_WAIT_SECONDS)) || refuse "Docker Desktop is not ready: $(docker_cause); finish what its window asks, then rerun this bootstrap"
112
+ sleep 2
113
+ waited=$((waited + 2))
114
+ done
115
+ }
116
+
85
117
  installed_packages() {
86
118
  { dpkg-query -W -f='${db:Status-Status} ${Package}\n' "$@" 2>/dev/null || true; } | awk '$1 == "installed" { print $2 }'
87
119
  }
@@ -132,7 +164,7 @@ establish_docker_engine() {
132
164
  printf 'Adding %s to the docker group; it applies from your next sign-in, so this run reaches Docker Engine through sudo.\n' "$(id -un)"
133
165
  elevated usermod -aG docker "$(id -un)" || refuse "$(id -un) could not be added to the docker group"
134
166
  fi
135
- docker_member || DOCKER_GROUP=1
167
+ docker_member || docker() { sudo -u "$(id -un)" docker "$@"; }
136
168
  fi
137
169
  docker_answers || refuse "Docker Engine does not answer: $(docker_cause)"
138
170
  }
@@ -172,20 +204,39 @@ while (($#)); do
172
204
  done
173
205
 
174
206
  [[ "$IMAGE" =~ ^ghcr\.io/agentshouse/kit@sha256:[0-9a-f]{64}$ ]] || refuse 'this bootstrap has not been published with an immutable image digest'
175
- [[ "$(uname -s)" == Linux ]] || refuse "unsupported host $(uname -s); this bootstrap is for Linux"
176
- case "$(uname -m)" in
177
- x86_64|amd64) ARCH=amd64 ;;
178
- aarch64|arm64) ARCH=arm64 ;;
179
- *) refuse "unsupported Linux architecture $(uname -m)" ;;
207
+ case "$(uname -s)" in
208
+ Linux)
209
+ case "$(uname -m)" in
210
+ x86_64|amd64) ARCH=amd64 ;;
211
+ aarch64|arm64) ARCH=arm64 ;;
212
+ *) refuse "unsupported Linux architecture $(uname -m)" ;;
213
+ esac
214
+ ;;
215
+ Darwin)
216
+ RUNTIME='Docker Desktop'
217
+ OPENER=open
218
+ if [[ "$(sysctl -n hw.optional.arm64 2>/dev/null)" == 1 ]]; then
219
+ ARCH=arm64
220
+ CHIP='Apple silicon'
221
+ else
222
+ ARCH=amd64
223
+ CHIP='an Intel chip'
224
+ fi
225
+ ;;
226
+ *) refuse "unsupported host $(uname -s); this bootstrap is for Linux and macOS" ;;
180
227
  esac
181
228
  [[ "$WORKSPACE" == /* ]] || refuse 'workspace root must be an absolute path'
182
- establish_docker_engine
229
+ if [[ "$RUNTIME" == 'Docker Desktop' ]]; then
230
+ establish_docker_desktop "$CHIP"
231
+ else
232
+ establish_docker_engine
233
+ fi
183
234
  case "$DOCKER_PLATFORM" in
184
235
  linux/x86_64|linux/amd64) DOCKER_ARCH=amd64 ;;
185
236
  linux/aarch64|linux/arm64) DOCKER_ARCH=arm64 ;;
186
- *) refuse "unsupported Docker Engine platform $DOCKER_PLATFORM" ;;
237
+ *) refuse "unsupported $RUNTIME platform $DOCKER_PLATFORM" ;;
187
238
  esac
188
- [[ "$DOCKER_ARCH" == "$ARCH" ]] || refuse "host linux/$ARCH does not match Docker Engine linux/$DOCKER_ARCH"
239
+ [[ "$DOCKER_ARCH" == "$ARCH" ]] || refuse "host linux/$ARCH does not match $RUNTIME linux/$DOCKER_ARCH"
189
240
  if ((FORWARDING)); then
190
241
  docker container inspect "$NAME" >/dev/null 2>&1 || refuse 'House Kit is not installed; run this bootstrap without arguments first'
191
242
  WORKSPACE=$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/agents/house"}}{{.Source}}{{end}}{{end}}' "$NAME")
@@ -197,7 +248,7 @@ for path in "$KIT_HOME" "$WORKSPACE"; do
197
248
  [[ ! -L "$path" ]] || refuse "$path is a symbolic link"
198
249
  mkdir -p -m 700 "$path" || refuse "$path could not be created"
199
250
  [[ -d "$path" ]] || refuse "$path is not a directory"
200
- [[ "$(stat -c %u "$path")" == "$(id -u)" ]] || refuse "$path is not owned by this User"
251
+ [[ -O "$path" ]] || refuse "$path is not owned by this User"
201
252
  chmod 700 "$path" || refuse "$path could not be owner-protected"
202
253
  done
203
254
  KIT_HOME=$(cd "$KIT_HOME" && pwd -P)
@@ -219,13 +270,19 @@ if ((FORWARDING == 0)); then
219
270
  if [[ ! -f "$KIT_HOME/kit.json" ]]; then
220
271
  printf '{"availability":"intermittent","execution_authority":"allow","migrators":[],"instances":[],"clients":[],"host":null,"skills":%s}\n' "$SKILLS" > "$KIT_HOME/kit.json"
221
272
  elif grep -Eq '"skills"[[:space:]]*:' "$KIT_HOME/kit.json"; then
222
- sed -E -i "s/(\"skills\"[[:space:]]*:[[:space:]]*)(true|false)/\\1$SKILLS/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
273
+ sed -E -i.old "s/(\"skills\"[[:space:]]*:[[:space:]]*)(true|false)/\\1$SKILLS/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
223
274
  else
224
- sed -E -i "1s/^([[:space:]]*)\\{/\\1{\"skills\":$SKILLS,/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
275
+ sed -E -i.old "1s/^([[:space:]]*)\\{/\\1{\"skills\":$SKILLS,/" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
225
276
  fi
277
+ rm -f "$KIT_HOME/kit.json.old"
226
278
  grep -Eq "\"skills\"[[:space:]]*:[[:space:]]*$SKILLS" "$KIT_HOME/kit.json" || refuse 'kit.json could not be updated'
227
279
  fi
228
- DOCKER_RUN=(docker run --rm --network host --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 --env "HOUSE_KIT_HOSTNAME=$(hostname)" "$IMAGE")
280
+ if [[ "$RUNTIME" == 'Docker Desktop' && "$HOUSE" == https://* ]]; then
281
+ NETWORK=()
282
+ LOGIN_PORT=$((49152 + RANDOM % 16384))
283
+ PUBLISHED=(--publish "127.0.0.1:$LOGIN_PORT:$LOGIN_PORT" --env "HOUSE_KIT_LOGIN_PORT=$LOGIN_PORT")
284
+ fi
285
+ DOCKER_RUN=(docker run --rm ${NETWORK[@]+"${NETWORK[@]}"} --user "$(id -u):$(id -g)" --mount "$KIT_HOME_MOUNT" --mount "$WORKSPACE_MOUNT" --env HOME=/kit-home --env HOUSE_KIT_HOME=/kit-home --env HOUSE_KIT_CONTAINER=1 --env "HOUSE_KIT_HOSTNAME=$(hostname)" "$IMAGE")
229
286
 
230
287
  if docker container inspect "$NAME" >/dev/null 2>&1; then
231
288
  INSTALLED_IMAGE=$(docker inspect --format '{{.Config.Image}}' "$NAME")
@@ -240,7 +297,7 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
240
297
  [[ -f "$KIT_HOME/credential" && -f "$KIT_HOME/environment" && -f "$KIT_HOME/house" ]] || refuse 'the installed Kit has no enrolled authority'
241
298
  if ((FORWARDING)); then
242
299
  if [[ "$FORWARDED" == kit && "${FORWARD[0]:-}" == login ]]; then
243
- login_kit "${FORWARD[@]:1}"
300
+ login_kit ${FORWARD[@]+"${FORWARD[@]:1}"}
244
301
  exit 0
245
302
  fi
246
303
  PLACED=()
@@ -259,9 +316,9 @@ if docker container inspect "$NAME" >/dev/null 2>&1; then
259
316
  stty -echo
260
317
  fi
261
318
  if [[ "$(docker inspect --format '{{.State.Running}}' "$NAME")" == true ]]; then
262
- docker exec -i "${TERMINAL[@]}" "${PLACED[@]}" "$NAME" "$FORWARDED" "${FORWARD[@]}"
319
+ docker exec -i ${TERMINAL[@]+"${TERMINAL[@]}"} ${PLACED[@]+"${PLACED[@]}"} "$NAME" "$FORWARDED" ${FORWARD[@]+"${FORWARD[@]}"}
263
320
  else
264
- "${DOCKER_RUN[@]:0:2}" -i "${TERMINAL[@]}" "${PLACED[@]}" "${ENTRY[@]}" "${DOCKER_RUN[@]:2}" "${FORWARD[@]}"
321
+ "${DOCKER_RUN[@]:0:2}" -i ${TERMINAL[@]+"${TERMINAL[@]}"} ${PLACED[@]+"${PLACED[@]}"} ${ENTRY[@]+"${ENTRY[@]}"} "${DOCKER_RUN[@]:2}" ${FORWARD[@]+"${FORWARD[@]}"}
265
322
  fi
266
323
  exit
267
324
  fi
@@ -294,7 +351,7 @@ else
294
351
  LOGIN=()
295
352
  if ((HOUSE_SELECTED)); then LOGIN+=(--house "$HOUSE"); fi
296
353
  if ((MANUAL)); then LOGIN+=(--manual); fi
297
- login_kit "${LOGIN[@]}"
354
+ login_kit ${LOGIN[@]+"${LOGIN[@]}"}
298
355
  fi
299
356
 
300
357
  "${DOCKER_RUN[@]}" apply
package/bin/kit.js CHANGED
@@ -119,6 +119,7 @@ async function runLogin(args, directory) {
119
119
  directory,
120
120
  hostBrowser: values["--host-browser"] === true,
121
121
  hostname: process.env.HOUSE_KIT_HOSTNAME,
122
+ loginPort: process.env.HOUSE_KIT_LOGIN_PORT,
122
123
  ...(values["--environment"] === undefined ? {} : { environment: values["--environment"] }),
123
124
  });
124
125
  }
package/bootstrap.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.1.0-alpha.32",
3
+ "version": "0.1.0-alpha.34",
4
4
  "hosts": {
5
5
  "linux": {
6
- "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.32/connect-linux.sh)",
6
+ "command": "bash <(curl -fsSL https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.34/connect-linux.sh)",
7
7
  "asset": {
8
8
  "name": "connect-linux.sh",
9
- "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.32/connect-linux.sh"
9
+ "url": "https://github.com/agentshouse/kit/releases/download/v0.1.0-alpha.34/connect-linux.sh"
10
10
  },
11
11
  "workspace": {
12
12
  "argument": "--workspace",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentshouse/kit",
3
- "version": "0.1.0-alpha.32",
3
+ "version": "0.1.0-alpha.34",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
package/src/contract.js CHANGED
@@ -61,6 +61,8 @@ export const TELEGRAM_DOWNLOAD_CEILING_BYTES = 20 * 1024 * 1024;
61
61
  export const TELEGRAM_BOT_API_TIMEOUT_MS = 30_000;
62
62
  export const TELEGRAM_GATEWAY_POLL_MS = 2_000;
63
63
  export const TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX = 32_768;
64
+ export const TELEGRAM_MESSAGE_CHARACTERS_MAX = 4_096;
65
+ export const TELEGRAM_CAPTION_CHARACTERS_MAX = 1_024;
64
66
  export const TELEGRAM_RICH_MESSAGE_BLOCKS_MAX = 500;
65
67
  export const TELEGRAM_CONTROL_KEYBOARD_COMMANDS = ["/stop", "/kill"];
66
68
  export const TELEGRAM_MEDIA_KINDS = [
@@ -213,7 +213,10 @@ export function sourceEntries(context) {
213
213
  const task = signIn(occasion(record), entry);
214
214
  signingIn = { source: entry.source, task };
215
215
  try {
216
- if (await task) record.last = null;
216
+ if (await task) {
217
+ record.last = null;
218
+ attempted.delete(entry.source);
219
+ }
217
220
  } catch (error) {
218
221
  if (!record.stop.signal.aborted) throw error;
219
222
  } finally {
package/src/login.js CHANGED
@@ -216,11 +216,12 @@ export async function login(options, dependencies = {}) {
216
216
  return { environment: exchanged.environment };
217
217
  }
218
218
 
219
- export async function loopback() {
219
+ export async function loopback(published) {
220
220
  const server = createServer();
221
221
  await new Promise((resolve, reject) => {
222
222
  server.once("error", reject);
223
- server.listen(0, "127.0.0.1", resolve);
223
+ if (published === undefined) server.listen(0, "127.0.0.1", resolve);
224
+ else server.listen(Number(published), "0.0.0.0", resolve);
224
225
  });
225
226
  const address = server.address();
226
227
  const code = new Promise((resolve, reject) => {
@@ -274,7 +275,7 @@ export async function connectHouse(options, dependencies = {}) {
274
275
  refuseDrained(options.directory);
275
276
  await prepareLaunchBase(launchBase(dependencies));
276
277
  if (options.presentation === "manual") return login(options, dependencies);
277
- const callback = await loopback();
278
+ const callback = await loopback(options.loginPort);
278
279
  try {
279
280
  return await login(
280
281
  { ...options, presentation: "loopback", loopbackUri: callback.uri },
package/src/sync.js CHANGED
@@ -131,23 +131,31 @@ function scopeContents(replica, scope) {
131
131
  return scope.contents;
132
132
  }
133
133
 
134
+ function servedFile(scope, path) {
135
+ return scope.manifest.files.find((file) => file?.path === path);
136
+ }
137
+
134
138
  async function writeScope(replica, scope, signal) {
135
- const files = [];
139
+ const recorded = new Map();
136
140
  for (const entry of scopeContents(replica, scope)) {
137
- const meta = scope.manifest.files.find((file) => file.path === entry.path);
141
+ const meta = servedFile(scope, entry.path) ?? {};
138
142
  await writeReplicaFile(locate(replica, scope.manifest.path, entry.path), entry.content, signal);
139
- files.push({
140
- path: entry.path,
141
- revision: typeof meta?.revision === "string" ? meta.revision : "",
142
- sha256: typeof meta?.sha256 === "string" ? meta.sha256 : "",
143
- content: entry.content,
144
- });
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
+ }
145
147
  }
146
- return files;
148
+ return [...recorded.values()];
147
149
  }
148
150
 
149
151
  async function replaceScope(replica, previous, scope, signal) {
150
- const kept = new Set(scopeContents(replica, scope).map((entry) => entry.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
+ );
151
159
  const root = locate(replica, scope.manifest.path);
152
160
  for (const path of await listFiles(root)) {
153
161
  if (!kept.has(path)) await removeReplicaFile(replica, scope.manifest.path, path);
@@ -255,6 +263,12 @@ async function writeSidecar(options, authority, recorded, path, change, signal)
255
263
  recorded.set(sidecar, enumeratedRecord(sidecar, change.provenance, change));
256
264
  }
257
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
+
258
272
  async function applyEnumeration(options, state, enumeration, signal) {
259
273
  if (!Array.isArray(enumeration?.authorities)) throw new KitRefusal("kit_sync_rejected", "enumerate");
260
274
  for (const view of enumeration.authorities) {
@@ -267,6 +281,7 @@ async function applyEnumeration(options, state, enumeration, signal) {
267
281
  if (change.removed === true) {
268
282
  await removeReplicaFile(options.replica, authority.path, change.path);
269
283
  recorded.delete(change.path);
284
+ await removeSidecar(options, authority, recorded, change.path);
270
285
  continue;
271
286
  }
272
287
  if (typeof change.content === "string") {
@@ -360,6 +375,7 @@ async function acceptBatch(session, options, state, outcome, signal, pending = n
360
375
  if (accepted === null) {
361
376
  if (!preserve) await removeReplicaFile(options.replica, authority.path, relative);
362
377
  recorded.delete(relative);
378
+ await removeSidecar(options, authority, recorded, relative);
363
379
  continue;
364
380
  }
365
381
  if (!preserve) await writeReplicaFile(destination, accepted, signal);
@@ -25,13 +25,16 @@ function richText(value, key = null) {
25
25
  return [];
26
26
  }
27
27
 
28
- function slices(text) {
28
+ export function slices(text, limit = TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX) {
29
29
  const pieces = [];
30
30
  let start = 0;
31
31
  while (start < text.length) {
32
- let end = Math.min(start + TELEGRAM_RICH_MESSAGE_CHARACTERS_MAX, text.length);
32
+ let end = Math.min(start + limit, text.length);
33
+ const window = text.slice(start, end);
34
+ const line = window.lastIndexOf("\n");
33
35
  const last = text.charCodeAt(end - 1);
34
- if (end < text.length && last >= 0xd800 && last <= 0xdbff) 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;
35
38
  pieces.push(text.slice(start, end));
36
39
  start = end;
37
40
  }
package/src/telegram.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  PERSONAL_BOT_OFFSET_RELATIVE_PATH,
11
11
  PERSONAL_BOT_TOKEN_RELATIVE_PATH,
12
12
  TELEGRAM_BOT_API_TIMEOUT_MS,
13
+ TELEGRAM_CAPTION_CHARACTERS_MAX,
13
14
  TELEGRAM_CONTROL_KEYBOARD_COMMANDS,
14
15
  TELEGRAM_DELIVERY_BATCH_MAX,
15
16
  TELEGRAM_DELIVERY_KINDS,
@@ -18,6 +19,7 @@ import {
18
19
  TELEGRAM_IMAGE_UPLOAD_CEILING_BYTES,
19
20
  TELEGRAM_INGRESS_BATCH_MAX,
20
21
  TELEGRAM_MEDIA_KINDS,
22
+ TELEGRAM_MESSAGE_CHARACTERS_MAX,
21
23
  TELEGRAM_UNSUPPORTED_MEDIA_FIELDS,
22
24
  TELEGRAM_UPDATE_OUTCOMES,
23
25
  telegramApiUrl,
@@ -27,7 +29,7 @@ import { heldControl, houseRequest, maintainControl, openSession } from "./house
27
29
  import { watchKitWork } from "./kit-work-stream.js";
28
30
  import { KitRefusal } from "./refusal.js";
29
31
  import { writeText } from "./state.js";
30
- import { richPieces, turnViews } from "./telegram-view.js";
32
+ import { richPieces, slices, turnViews } from "./telegram-view.js";
31
33
  import { kitStreamWriter } from "./turn-frames.js";
32
34
 
33
35
  const TOKEN_PROMPT = "Paste the BotFather token for this Environment: ";
@@ -285,8 +287,21 @@ export function deliveryCall(delivery) {
285
287
  return { method: "sendMessage", body: sendBody(delivery) };
286
288
  }
287
289
 
290
+ function topicMessage(chat, topic, text) {
291
+ const body = { chat_id: Number(chat), text };
292
+ if (topic !== null) body.message_thread_id = Number(topic);
293
+ return { method: "sendMessage", body };
294
+ }
295
+
288
296
  function deliveryCalls(delivery) {
289
297
  const call = deliveryCall(delivery);
298
+ if (call.method === "sendMessage" && call.body.text.length > TELEGRAM_MESSAGE_CHARACTERS_MAX) {
299
+ const [first, ...following] = slices(call.body.text, TELEGRAM_MESSAGE_CHARACTERS_MAX);
300
+ return [
301
+ { method: call.method, body: { ...call.body, text: first } },
302
+ ...following.map((text) => topicMessage(delivery.chat_id, delivery.topic_id, text)),
303
+ ];
304
+ }
290
305
  if (call.method !== "sendRichMessage") return [call];
291
306
  return richPieces(call.body.rich_message).map((rich_message) => ({
292
307
  method: call.method,
@@ -791,6 +806,27 @@ async function sendQueuedMessage(gateway, token, delivery) {
791
806
  return false;
792
807
  }
793
808
 
809
+ function captionPieces(caption) {
810
+ if (caption === null || caption.length <= TELEGRAM_CAPTION_CHARACTERS_MAX) return [caption];
811
+ const [head] = slices(caption, TELEGRAM_CAPTION_CHARACTERS_MAX);
812
+ return [head, ...slices(caption.slice(head.length), TELEGRAM_MESSAGE_CHARACTERS_MAX)];
813
+ }
814
+
815
+ async function sentOverflow(gateway, token, upload, overflow) {
816
+ for (const text of overflow) {
817
+ const call = topicMessage(upload.chat_id, upload.topic_id, text);
818
+ let answered;
819
+ try {
820
+ answered = await telegramPost(gateway, telegramApiUrl(token, call.method), call.body);
821
+ } catch (error) {
822
+ if (cancelled(error)) throw error;
823
+ return { settled: uploadOutcome(null), answered: null };
824
+ }
825
+ if (!botApiAccepted(answered)) return { settled: uploadOutcome(answered), answered };
826
+ }
827
+ return null;
828
+ }
829
+
794
830
  async function sendQueuedUpload(gateway, token, upload) {
795
831
  let bytes;
796
832
  try {
@@ -810,12 +846,13 @@ async function sendQueuedUpload(gateway, token, upload) {
810
846
  });
811
847
  return false;
812
848
  }
849
+ const [caption, ...overflow] = captionPieces(upload.caption);
813
850
  let answered;
814
851
  try {
815
852
  answered = await telegramUpload(
816
853
  gateway,
817
854
  telegramApiUrl(token, "sendPhoto"),
818
- uploadFields(upload),
855
+ uploadFields({ ...upload, caption }),
819
856
  { field: "photo", name: upload.file_name, bytes },
820
857
  );
821
858
  } catch (error) {
@@ -826,8 +863,11 @@ async function sendQueuedUpload(gateway, token, upload) {
826
863
  });
827
864
  return false;
828
865
  }
829
- await reportUpload(gateway, upload, uploadOutcome(answered));
830
- const retryAfter = telegramRetryAfter(answered);
866
+ const photographed = uploadOutcome(answered);
867
+ const unsent =
868
+ photographed.outcome === "delivered" ? await sentOverflow(gateway, token, upload, overflow) : null;
869
+ await reportUpload(gateway, upload, unsent?.settled ?? photographed);
870
+ const retryAfter = telegramRetryAfter(unsent === null ? answered : unsent.answered);
831
871
  if (retryAfter === null) return false;
832
872
  gateway.gate.hold(retryAfter);
833
873
  return true;