diff --git a/docs/decisions.md b/docs/decisions.md index c53bc180..3f58557e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -19751,6 +19751,159 @@ environment, and `NeedDaemonReload=yes` after a drop-in is written. #616 (point 6). The English names of the user are the person's to give in all three cases until the dialog asks for one form (the shaping's §3.6). +### F64 addendum of 2026-10-25: a change of Buddy's Lazurio layer reaches his running conversations + +**Follows Lazurio/LazurioPlatform#648 and #565 point 4 (plan DEV-6676). Variant B of the +shaping in #565; the readings every two minutes (point 3) are proposed by the agent, who found +that the reading at start alone does not see a layer written after it; the refresh of ended +sessions (point 5) follows a finding on a Ring 0 Buddy of the same day. Implemented in this +revision; Matěj, the Organization Admin, decides it.** Buddy's stored prompts are built from +SOUL.md and from his Lazurio layer in the Folder (`buddyLayerPaths`: `.agents/buddy/.hermes.md` +and his Lazurio skills, F14's addendum of 2026-10-21). F64 refreshed them only when SOUL.md +changed. A Platform release that changes the layer, as v0.1.12-rc.2's base-instructions-76 +does (#645), rewrites those files through whichever of the Folder's entry points runs, and +nothing cleared his stored prompts afterwards. His chat is one endless conversation (F61), so it +kept the old rules. **v0.1.12 ships with this as a known limitation:** after its update, the +owed marker (`prompt-refresh-owed.json`) has to be written by hand and +`lazurio buddy persona --apply` run. This addendum removes that step. + +1. **What the prompts were built from is recorded.** After a complete refresh and the restart + that follows it, where F64 removes the owed marker, the apply also records the digest of the + layer as it found it when it began. The record is + `~/.local/state/lazurio/buddy/prompt-inputs.json` (`schema_version` + `lazurio.buddy-prompt-inputs.v1`, `layer_digest`), beside the marker: 0600 in 0700, written + whole and renamed into place, before the marker goes. The digest is a sha256 over each file + of `buddyLayerPaths`, in order: its path in the Folder, then its length and bytes, or a token + of its own for a file that is absent and another for one that is unreadable (no regular file, + a link included, which is never followed; larger than 1 MiB; a read that fails). It is the + layer at the beginning, not at the end: every turn after the refresh builds its prompt from + the layer as it is then, so a layer that changes during an apply is not recorded and stays + owed. +2. **A refresh is owed when the marker is there, or when the layer's bytes are not the recorded + ones.** No record counts too, so the first apply after this release refreshes once. The read + without `--apply` and the Launchpad's readings count it and write nothing (`prompts: owed`, + and `restart: pending` while the gateway runs). An apply turns it into the marker before + anything else, and F64's flow does the rest: the prompts cleared at the idle moment, one + restart, the record, the marker removed. An apply whose owed refresh waits for the idle moment + now answers `restart: pending`, as the read does; it answered `none` when no file of its own + had changed. An apply during which the layer changed answers `waiting`, and the next one + refreshes again. +3. **The Launchpad applies it on its own.** Where the recorded entry names Buddy, the + Launchpad's persona controller (F65 point 7) reads once when the Launchpad starts and again + every two minutes until it stops, as often as it asks an Organization's settings. Each + reading is the one a page makes and starts the apply under the same rules: at most one at a + time, not again within 15 seconds, retried while Buddy is busy, backed off after a failure. + The start alone would not do. Machines' apply raises the Platform, which restarts the + Launchpad, and only afterwards runs `lazurio machine folder-refresh`, which writes the new + layer and restarts nothing ([machine handover](machine-handover.md)); the user's own + `lazurio update` never writes the Folder (F14). The same readings take up a marker that a + `lazurio buddy persona --apply` left when it had to wait for Buddy. Without such an entry + nothing is read, and the start never waits for a reading. +4. **What fails stays visible.** Buddy busy: `waiting`, restart `pending`. A refresh capped or + incomplete: `failed` (`prompts-incomplete`), and still owed. A file of the layer unreadable: + it counts by its token, so a layer that becomes unreadable owes a refresh, and every answer + names `layer-unreadable`, an apply's as `failed`, which the Launchpad retries like any + failure. The refresh records the token, so a layer that stays unreadable does not restart + Buddy again and again, and its repair owes the next refresh. Not personal, or no Hermes + home: `skipped` or `unavailable`, as before, and nothing is written. +5. **Ended conversations that Hermes resumes are refreshed too.** F64 cleared the prompts of + live sessions only (`ended_at` empty), as the installer's `live_sessions` does. Hermes ends + the web chat's ACP session whenever its last viewer leaves (`end_reason` `acp_disconnect`, + `gateway/session_acp_lifecycle.py`), and T3 Code's next message reopens it with the prompt it + stored (`gateway/session_local_recovery.py` `reopen_local_session`). So after a complete + refresh the chat kept the old prompt, and on Ring 0 it had to be cleared by hand. The refresh + now takes every conversation that holds a stored prompt and can still take a turn, as + Lazurio/hermes-agent v0.22.4-lazurio.3 resumes them: + - a live row, or an ended one with messages (an editor lists and loads no empty session, + `acp_adapter/catalog.py`) whose end does not say that its conversation went on in another + row or stopped: `compression`, `new_session` and the resets (`session_reset`, + `session_switch`, `idle`, `daily`, `suspended`, `resume_pending_expired`; + `hermes_state_common.py` `_BOUNDARY_END_REASONS`). Any other end, `acp_disconnect` or a + runtime that closed, was reaped or was evicted, leaves a row the next turn reopens; + - never a subagent's run (`_delegate_from` in `model_config`) or a tool's session. With + `model_config`, a compression's or a reset's continuation counts: Hermes resumes a + conversation's latest row, and a long conversation goes on in it. A store without these + columns keeps the live, root-only selection; + - the cap of 500 counts them, and so do `refreshed` and `live` (`live`: the conversations that + can still take a turn). `hermes sessions repair-prompts ` clears an ended session as it + clears a live one. Clearing an ended session's prompt is harmless: the turn that resumes it + builds the prompt anew, and the restart that follows the refresh drops the agents the gateway + kept. +6. **Nothing else changes.** Nothing in `src/folder` and no Folder template: the layer's writer + still knows nothing of Buddy's home. The marker, its schema and its rules stay, a change of + the persona owes its refresh as before, and so does the hand-over to the installer and + Machines (point 7). + +| Alternative | Trade-off / disposition | +| --- | --- | +| Variant A of the shaping: the Folder's writer owes the refresh (`writeBuddyLayerLocked` writes the marker before its first changed file) | Needs Buddy's home in `src/folder`, which takes no environment, through six entry points (the CLI's update, the Launchpad's update and tools, the machine handover and refresh, Organization settings, the Tools CLI, the vault host); a caller that forgets is a silent miss, and tests would reach the developer's own home. Rejected for the comparison, which every writer meets | +| An unreadable layer always owed and never recorded | A layer that stays unreadable would clear Buddy's prompts and restart his gateway on every retry; rejected for a token of its own that a refresh records | +| The digest of the layer at the end of the apply | A layer that changes between the refresh and the end would be recorded although his prompts were built from the older one; rejected for the beginning | +| The Launchpad reads only at its start | Machines' apply writes the new layer after the restart, and so does a `machine folder-refresh` after the user's own update; the new rules would wait for a page; rejected for a reading every two minutes | +| `machine folder-refresh` restarts the Launchpad when the layer changed, as it does when the entry changed | One more restart of the person's page per release for what a small reading does, and a change written another way would still wait; rejected | +| Every ended session that holds a prompt | Old Telegram days and finished CLI chats never take a turn again, and each costs a `repair-prompts` process; rejected for Hermes's own end reasons | +| ACP sessions only (`source = 'acp'`) | Misses a compression's or a reset's continuation of the web chat and the other local surfaces Hermes reopens; rejected for the end reason and the subagent marker | +| Hermes's receipts of local sessions (`state_meta`) | Name exactly the latest row of each local conversation, but they are Hermes's private records with a format of their own; rejected for the `sessions` columns | +| A watch on the layer's files (inotify) | A second mechanism with failure modes of its own (a directory not there yet, renames over watched files, platform differences) for a check of a few small reads; rejected | + +**Verified by** `tests/buddy-persona.test.ts`: a Folder refresh (`refreshMachineFolder`) that +rewrites `.hermes.md` owes the refresh, and one that changes nothing owes none (#648's test); +the read reports `prompts: owed` and `restart: pending` and writes neither record; an apply +while Buddy is busy writes the marker and waits; the idle apply clears both live conversations' +prompts, restarts once and records the layer, and the next one clears and restarts nothing. No +record owes the refresh once and then never again, with the record's mode, directory and two +members asserted; a record of another schema, a digest that is not the layer's, a broken one and +one through a link count as none. Rules rewritten while the apply runs leave the beginning's +digest recorded, the answer `waiting`, and the next apply refreshing again. A link at +`.hermes.md` owes the refresh once, is named `layer-unreadable` by the read and the apply, is +not refreshed again, and a regular file again owes the next refresh. The digest goes by path and +bytes: one byte changed at the same length, a byte more, two files' bytes swapped, an empty file +and none, a link and a directory as one token, and the same layer elsewhere with the same +digest. An apply clears the prompt of an ended `acp_disconnect` session with messages and counts +it in `refreshed`, and leaves an ended session without a prompt untouched. The selection takes a +live row, the web chat after a disconnect, a compression's and a reset's continuation and a +closed CLI chat, and never a row ended by a compression, a daily reset or `/new`, an empty one, +a subagent's run or a tool's session; a `model_config` that is no JSON names no subagent, and +the cap counts them all. `tests/buddy-persona-save.test.ts`: a reading takes up a changed layer and a missing +record and writes nothing; the start reads once, waits for the apply it began, and starts +nothing when nothing is owed or the controller is closed; the readings every two minutes take up +new rules written after the start and a marker, start nothing otherwise, and stop at close. +`tests/launchpad-buddy.test.ts`: a real Launchpad start on an entry that names Buddy runs F64's +apply without any request when his rules changed, and none when nothing is owed or the entry +does not name him. Mutation checks: each of 30 guards removed or loosened fails a test (the +marker from a changed layer, the beginning's digest, the read's count, no record counting, a +link followed, `layer-unreadable` in the read and in the apply, the answer after a change during +the apply, the record's schema and its link, the restart pending while the refresh waits, the +controller's comparison, the bytes in the digest, the two tokens kept apart, the record only +after the restart; the watch armed at start, its next reading, its stop, a second watch, the start +waiting for its apply, the Launchpad's start, its condition on Buddy and `settled()` waiting for +it; ended sessions counted, the superseded end reasons and compression among them, the +messages, the subagent marker, the guard against a `model_config` that is no JSON, and the +continuations). One more guard, a sha256 shape for the recorded digest, survived its mutation +and was removed: a value that is no layer's digest never equals one. + +**Honest limits.** +- No real Hermes gateway, Buddy or Osobní Prostředí ran, as for F64. The acceptance is on a + Ring 0 Buddy after a Platform update that changes `.hermes.md`: with no page opened, + `hermes sessions` shows the stored prompt of his web chat's session cleared, ended after a + disconnect or not, and then rebuilt by his next turn, which follows the new rule; + `prompt-inputs.json` holds the new layer's digest; the Launchpad's journal has a + `buddy-persona` `apply` line. +- The first update with this change refreshes every Buddy once, since none has a record yet: + one more prefix-cache miss per conversation that can still take a turn, and one restart at an + idle moment. +- A conversation that begins while the layer is briefly different builds its prompt from it. + When the layer returns to the recorded bytes before an apply, nothing is owed and that prompt + stays. +- Hermes reads the layer as the same account, so a file unreadable here is unreadable to it, + but a link it would follow counts as unreadable here. +- The readings every two minutes cost the persona and a handful of small files on every + Osobní Prostředí with Buddy; nothing else runs unless something is owed. +- The end reasons are Hermes's at v0.22.4-lazurio.3. A reason Hermes adds later counts as + resumable, and so would a renamed boundary: at worst one needless clear per such session. +- Children are told apart by Hermes's `_delegate_from` marker. A subagent's run from before + Hermes wrote it, ended in a way that counts, is cleared once, which is harmless. + ## F65 — Buddy in Začínáme and Nastavení → Buddy: his step first, his persona changed in place, the agents' AI not locked behind him **Proposed for plan DEV-6676 in the Buddy lead's shaping of 2026-10-25 (its §3.2, §3.4 to diff --git a/src/buddy/cli.ts b/src/buddy/cli.ts index 28d35d1c..66a236e2 100644 --- a/src/buddy/cli.ts +++ b/src/buddy/cli.ts @@ -99,14 +99,23 @@ buddy persona [--apply] [--confirm] [--folder ] [--json] hermes-gateway.service.d/lazurio-acting-sender.conf (0644), both byte for byte as buddy-install.sh renders them, whether his gateway still runs from before them (restart pending), whether a refresh of his stored prompts is - owed, and his T3 settings as lazurio buddy chat reads them. --apply, one at + owed (after a change of the persona, or because his Lazurio layer in the + Folder, .agents/buddy/.hermes.md and his skills, is not the one his last + complete refresh was built from, as recorded in + ~/.local/state/lazurio/buddy/prompt-inputs.json; none recorded counts once), + and his T3 settings as lazurio buddy chat reads them. --apply, one at a time, replaces each file that differs on its own, has systemd reread the units, and brings the change to his running conversations: hermes sessions - repair-prompts clears the stored prompt of every live conversation that - holds one, checked in Hermes's store, and once Hermes's active-work answers + repair-prompts clears the stored prompt of every conversation that holds + one and can still take a turn (live, or ended in a way Hermes resumes, as + the web chat's ACP session is after each disconnect), checked in Hermes's + store, and once Hermes's active-work answers that Buddy is idle, hermes-gateway.service restarts once, never on units systemd failed to reread; his T3 settings are applied, his T3 Code's unit - stopped and started around them only when T3 Code has no run going. What + stopped and started around them only when T3 Code has no run going; after + a complete refresh and that restart, the layer as the apply found it when + it began is recorded. A layer file that is no regular file or cannot be read + counts by a token of its own and is named (layer-unreadable). What waits answers waiting, what failed answers failed with its reason, and both stay visible until the next --apply or the Launchpad finishes them. --confirm records that the person confirmed the names (confirmed_at, kept diff --git a/src/buddy/delegace/README.md b/src/buddy/delegace/README.md index 219c09dc..bf94fdc0 100644 --- a/src/buddy/delegace/README.md +++ b/src/buddy/delegace/README.md @@ -220,8 +220,11 @@ it is: the records keep their format, so every open delegation goes on under the new service, and `delegace seznam --otevrene` lists them. A notice delivered before the move names the old command; Buddy uses `delegace` with the same id. A conversation of Buddy's that was open before the move keeps -its stored prompt with the old layer until it is cleared (`hermes sessions -repair-prompts --apply --json`, #565 point 4). +its stored prompt with the old layer until it is cleared. A Platform with +F64's addendum of 2026-10-25 clears it by itself once the Folder's layer has +changed and Buddy is idle, and `lazurio buddy persona --apply` does it at +once; on an older Platform, `hermes sessions repair-prompts +--apply --json` clears one conversation (#565 point 4). ## Known limits of version 1 diff --git a/src/buddy/gateway-refresh.ts b/src/buddy/gateway-refresh.ts index 82332a75..9842a296 100644 --- a/src/buddy/gateway-refresh.ts +++ b/src/buddy/gateway-refresh.ts @@ -1,5 +1,6 @@ import { Database } from "bun:sqlite"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; import { lstat, mkdir, @@ -11,6 +12,7 @@ import { } from "node:fs/promises"; import { createConnection } from "node:net"; import { basename, dirname, isAbsolute, join } from "node:path"; +import { buddyLayerPaths } from "../folder/outputs"; import type { ToolRunner } from "../tools/status"; // How a change of what Buddy's gateway reads reaches his running @@ -23,9 +25,11 @@ import type { ToolRunner } from "../tools/status"; // environment (gateway/run_agent_cache.py). So a change takes two steps, // the installer's (`buddy-install.sh`, step 13): // -// 1. `hermes sessions repair-prompts --apply --json` for every live -// conversation that holds a stored prompt clears it, so its next turn -// builds it anew; the store, not the command's exit, says it worked; +// 1. `hermes sessions repair-prompts --apply --json` for every +// conversation that holds a stored prompt and can still take a turn +// (live, or ended in a way Hermes resumes: `resumableConversations`) +// clears it, so its next turn builds it anew; the store, not the +// command's exit, says it worked; // 2. one restart of `hermes-gateway.service` drops the agents it keeps and // gives it its drop-in's environment, only while Hermes's own // `active-work` verb (gateway/run_active_work_verb.py, the answer its @@ -42,6 +46,14 @@ import type { ToolRunner } from "../tools/status"; // they are stale, so a refresh that is owed is recorded: a marker written // before SOUL.md changes and removed only after a complete refresh and the // restart that follows it. +// +// Buddy's prompts are built from his Lazurio layer in the Folder too +// (`buddyLayerPaths`: `.agents/buddy/.hermes.md`, which Hermes loads from his +// working directory, and his Lazurio skills), which a Platform release +// changes through any of the Folder's entry points, none of which knows his +// home. So a complete refresh also records what that layer was when the +// refresh began, and a layer whose bytes are no longer those owes the next +// refresh, whoever changed it (F64's addendum of 2026-10-25). /** Hermes's own user unit of Buddy's gateway (`hermes gateway install`). */ export const gatewayUnit = "hermes-gateway.service"; @@ -327,13 +339,32 @@ const sessionIdShape = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; * incomplete, and the next one goes on with what still holds a prompt. */ export const promptRefreshCap = 500; -/** A live conversation and the stored prompt it holds: Hermes keeps it by - * content hash (`sessions.system_prompt_hash`, v0.22.4-lazurio.1), an older - * row inline (`system_prompt`). Only the hash, or that an inline prompt is - * there, is read: never a prompt. `unknown` where the store has neither - * column. */ +/** A conversation and the stored prompt it holds: Hermes keeps it by content + * hash (`sessions.system_prompt_hash`, v0.22.4-lazurio.1), an older row + * inline (`system_prompt`). Only the hash, or that an inline prompt is there, + * is read: never a prompt. `unknown` where the store has neither column. */ type Held = Readonly<{ id: unknown; prompt: string | null }>; +/** End reasons after which Hermes never takes a turn in a row again + * (Lazurio/hermes-agent v0.22.4-lazurio.3, `hermes_state_common.py`): a + * compression split goes on in its continuation, and a deliberate boundary + * (`_BOUNDARY_END_REASONS`: the resets and `/new`) in a new session. Any other + * end leaves a row that its conversation's next turn reopens with the prompt + * it stored: the last viewer leaving an ACP session (`acp_disconnect`, + * `gateway/session_acp_lifecycle.py`), a runtime that closed, was reaped or + * was evicted (`gateway/session_local_recovery.py` `reopen_local_session` + * reopens a local conversation's latest row whatever its reason). */ +const supersededEndReasons = [ + "compression", + "new_session", + "session_reset", + "session_switch", + "idle", + "daily", + "suspended", + "resume_pending_expired", +] as const; + type Columns = Readonly<{ hash: boolean; inline: boolean; @@ -386,11 +417,23 @@ async function withStore( const selected = (columns: Columns) => `id, ${columns.hash ? "system_prompt_hash" : "null"} as hash, ${columns.inline ? "system_prompt is not null" : "0"} as inline`; -/** Buddy's live conversations that hold a stored prompt: not ended, no tool's - * or child's session (the installer's `live_sessions`), newest first, at most - * `cap` and whether there were more. Every live conversation where the store - * cannot say whether it holds one. */ -export async function liveConversations( +/** Buddy's conversations that hold a stored prompt and can still take a turn, + * newest first, at most `cap` and whether there were more: + * - live ones, not ended, as the installer's `live_sessions` selects them; + * - ended ones Hermes resumes. The web chat's ACP session ends whenever its + * last viewer leaves (`acp_disconnect`), and T3 Code's next message reopens + * it with the prompt it stored. So an ended row counts unless its end says + * its conversation went on in another row or stopped + * (`supersededEndReasons`), and only with messages: an editor lists and + * loads no empty session (`acp_adapter/catalog.py`). Only where the store + * says which rows hold a prompt and why they ended; + * - never a tool's session or a subagent's run, which Hermes marks + * `_delegate_from` (`model_config`). A compression's or a reset's + * continuation counts: that is the row a long conversation goes on in. + * Without `model_config` every child row is left out, as the installer + * leaves them. + * Every live conversation where the store cannot say whether it holds one. */ +export async function resumableConversations( hermesHome: string, cap: number = promptRefreshCap, ): Promise< @@ -398,14 +441,33 @@ export async function liveConversations( > { return withStore(hermesHome, (store, columns) => { const { names } = columns; - const where = ["ended_at is null"]; - if (names.has("source")) where.push("coalesce(source, '') != 'tool'"); - if (names.has("parent_session_id")) where.push("parent_session_id is null"); const holds = [ ...(columns.hash ? ["system_prompt_hash is not null"] : []), ...(columns.inline ? ["system_prompt is not null"] : []), ]; + const where: string[] = []; if (holds.length > 0) where.push(`(${holds.join(" or ")})`); + if (names.has("source")) where.push("coalesce(source, '') != 'tool'"); + if (names.has("model_config")) + where.push( + "(case when json_valid(model_config) then json_extract(model_config, '$._delegate_from') end) is null", + ); + else if (names.has("parent_session_id")) + where.push("parent_session_id is null"); + const resumed = + holds.length > 0 && names.has("end_reason") + ? [ + `coalesce(end_reason, '') not in (${supersededEndReasons.map((reason) => `'${reason}'`).join(", ")})`, + ...(names.has("message_count") + ? ["coalesce(message_count, 0) > 0"] + : []), + ] + : undefined; + where.push( + resumed === undefined + ? "ended_at is null" + : `(ended_at is null or (${resumed.join(" and ")}))`, + ); const order = ["started_at", "created_at"].find((column) => names.has(column)) ?? "rowid"; @@ -447,11 +509,14 @@ async function heldNow( return now === "absent" ? new Map() : now; } -/** What clearing the stored prompts did. `refreshed`: every live - * conversation that held a stored prompt holds it no more. `incomplete`: some - * still hold the prompt they held (a command that failed or did nothing, an - * id never passed to a command), or there were more than the cap. `skipped`: - * nothing could be cleared. Only the last two leave a refresh owed. */ +/** What clearing the stored prompts did. `refreshed`: every conversation + * that held a stored prompt and can still take a turn holds it no more. + * `incomplete`: some still hold the prompt they held (a command that failed + * or did nothing, an id never passed to a command), or there were more than + * the cap. `skipped`: nothing could be cleared. Only the last two leave a + * refresh owed. `live` counts those conversations, the ended ones Hermes + * resumes included (`resumableConversations`), and `refreshed` those of them + * that hold their prompt no more. */ export type PromptRefresh = | Readonly<{ state: "refreshed"; refreshed: number; live: number }> | Readonly<{ @@ -466,9 +531,12 @@ export type PromptRefresh = reason: "hermes-missing" | "store-unreadable"; }>; -/** Clears the stored prompt of every live conversation that holds one, with - * Hermes's own command, as the installer does; each conversation's next turn - * builds it from the files as they are now. A conversation counts as +/** Clears the stored prompt of every conversation that holds one and can + * still take a turn, with Hermes's own command, as the installer does; each + * conversation's next turn builds it from the files as they are now, an ended + * one's when it is resumed. Its id needs no live row: the command resolves + * any session and clears what it stores (`hermes_cli/sessions_cmd.py` + * `_cmd_repair_prompts`, v0.22.4-lazurio.3). A conversation counts as * refreshed only when the store no longer holds the prompt it held before: * cleared, rebuilt by a turn since, or gone. A store that cannot say which * prompt a conversation holds is judged by the commands' exits alone. */ @@ -485,7 +553,7 @@ export async function refreshPrompts( if (input.hermes === undefined) return Object.freeze({ state: "skipped", reason: "hermes-missing" }); const hermesHome = join(input.home, ".hermes"); - const live = await liveConversations(hermesHome, input.cap); + const live = await resumableConversations(hermesHome, input.cap); if (live === undefined) return Object.freeze({ state: "skipped", reason: "store-unreadable" }); if (live === "absent") @@ -529,48 +597,200 @@ export async function refreshPrompts( // ---- A refresh that is owed ------------------------------------------------- +/** Buddy's state directory, beside the installer's records. */ +const stateDirectory = (home: string): string => + join(home, ".local", "state", "lazurio", "buddy"); + /** The record that Buddy's stored prompts are owed a refresh: written before - * SOUL.md changes, removed only after a complete refresh and the restart that - * follows it (or with no gateway running), so a failure, a cap or anyone's - * restart in between leaves it for the next apply. In Buddy's state directory, - * beside the installer's records; the Platform's own file. */ + * SOUL.md changes, or before a refresh his changed Lazurio layer owes, + * removed only after a complete refresh and the restart that follows it (or + * with no gateway running), so a failure, a cap or anyone's restart in + * between leaves it for the next apply. In Buddy's state directory, beside + * the installer's records; the Platform's own file. */ export const promptRefreshOwedPath = (home: string): string => - join(home, ".local", "state", "lazurio", "buddy", "prompt-refresh-owed.json"); + join(stateDirectory(home), "prompt-refresh-owed.json"); -/** Whether a refresh is owed: anything at the marker's place counts. */ -export async function promptRefreshOwed(home: string): Promise { +/** A file of the Platform's own in Buddy's state directory: 0600 in a 0700 + * directory, written whole and renamed into place. */ +async function writeStateFile( + path: string, + document: Readonly>, +): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${randomUUID()}.lazurio`; + const file = await open(temporary, "wx", 0o600); + let renamed = false; + try { + try { + await file.chmod(0o600); + await file.writeFile(`${JSON.stringify(document)}\n`); + await file.sync(); + } finally { + await file.close(); + } + await rename(temporary, path); + renamed = true; + } finally { + if (!renamed) await rm(temporary, { force: true }); + } +} + +/** Whether the owed refresh is recorded: anything at the marker's place + * counts. */ +export async function promptRefreshMarked(home: string): Promise { return ( (await lstat(promptRefreshOwedPath(home)).catch(() => undefined)) !== undefined ); } -/** Records that a refresh is owed, unless it already is: a 0600 file in a 0700 - * directory, written whole and renamed into place. */ +/** Records that a refresh is owed, unless it already is. */ export async function owePromptRefresh( home: string, now: () => Date = () => new Date(), ): Promise { - if (await promptRefreshOwed(home)) return; - const path = promptRefreshOwedPath(home); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - const temporary = `${path}.${process.pid}.${Date.now()}.lazurio`; - const file = await open(temporary, "wx", 0o600); - try { - await file.chmod(0o600); - await file.writeFile( - `${JSON.stringify({ - schema_version: "lazurio.buddy-prompt-refresh.v1", - owed_since: `${now().toISOString().slice(0, 19)}Z`, - })}\n`, - ); - await file.sync(); - } finally { - await file.close(); - } - await rename(temporary, path); + if (await promptRefreshMarked(home)) return; + await writeStateFile(promptRefreshOwedPath(home), { + schema_version: "lazurio.buddy-prompt-refresh.v1", + owed_since: `${now().toISOString().slice(0, 19)}Z`, + }); } /** The owed refresh is done. */ export const settlePromptRefresh = (home: string): Promise => rm(promptRefreshOwedPath(home), { force: true }); + +// ---- What the stored prompts were built from -------------------------------- + +/** Buddy's Lazurio layer as his prompts would be built from it now. + * `layerDigest`: a sha256 over each file of `buddyLayerPaths`, in order, of + * its path, then its bytes, or a token of its own where it is absent or + * unreadable. `unreadable`: some file is no regular file (a link, which is + * never followed, included), is larger than `layerFileBytes`, or could not be + * read. */ +export type PromptInputs = Readonly<{ + layerDigest: string; + unreadable: boolean; +}>; + +/** A rendered file of the layer is a few kilobytes; a larger one counts as + * unreadable. */ +const layerFileBytes = 1024 * 1024; + +/** One file of the layer: its bytes, `absent`, or `unreadable`. */ +async function layerFile( + path: string, +): Promise { + try { + const handle = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > layerFileBytes) return "unreadable"; + const bytes = await handle.readFile(); + return bytes.byteLength > layerFileBytes ? "unreadable" : bytes; + } finally { + await handle.close(); + } + } catch (error) { + return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT" + ? "absent" + : "unreadable"; + } +} + +/** Buddy's Lazurio layer in `folder`, read only: never a reason to fail. The + * paths are the Folder's own, so a Folder that moves keeps its digest. */ +export async function readPromptInputs(folder: string): Promise { + const hash = createHash("sha256"); + let unreadable = false; + for (const path of buddyLayerPaths) { + const file = await layerFile(join(folder, path)); + hash.update(`${path}\0`); + if (typeof file === "string") { + hash.update(`${file}\0`); + if (file === "unreadable") unreadable = true; + } else { + hash.update(`file ${file.byteLength}\0`); + hash.update(file); + } + } + return Object.freeze({ layerDigest: hash.digest("hex"), unreadable }); +} + +/** What Buddy's stored prompts were last completely refreshed from: the + * digest of his Lazurio layer as it was when that refresh began. Written only + * after a complete refresh and the restart that follows it, with the owed + * marker's removal, never by a reading. Beside the marker; the Platform's own + * file. */ +export const promptInputsPath = (home: string): string => + join(stateDirectory(home), "prompt-inputs.json"); + +const promptInputsSchema = "lazurio.buddy-prompt-inputs.v1"; +const promptInputsBytes = 4096; + +/** The layer digest the last complete refresh recorded; undefined where none + * is: no record, or one that is no regular file of at most 4 KiB holding this + * schema and a digest. A digest that is not a layer's never equals one. */ +export async function recordedPromptInputs( + home: string, +): Promise { + try { + const handle = await open( + promptInputsPath(home), + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + let text: string; + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > promptInputsBytes) return undefined; + text = await handle.readFile("utf8"); + } finally { + await handle.close(); + } + const record: unknown = JSON.parse(text); + return isObject(record) && + record.schema_version === promptInputsSchema && + typeof record.layer_digest === "string" + ? record.layer_digest + : undefined; + } catch { + return undefined; + } +} + +/** Whether `inputs` are not what the last complete refresh was built from; + * no record counts, so the first apply after this record exists refreshes + * once. */ +export async function promptInputsChanged( + home: string, + inputs: PromptInputs, +): Promise { + return inputs.layerDigest !== (await recordedPromptInputs(home)); +} + +/** Records `inputs` as what the stored prompts were built from, after a + * complete refresh that began with them. */ +export const recordPromptInputs = ( + home: string, + inputs: PromptInputs, +): Promise => + writeStateFile(promptInputsPath(home), { + schema_version: promptInputsSchema, + layer_digest: inputs.layerDigest, + }); + +/** Whether a refresh of Buddy's stored prompts is owed: its marker is there, + * or his Lazurio layer in `folder` is not the one the last complete refresh + * was built from (none recorded counts). Reads only, so the read without + * `--apply` and the Launchpad's readings count a changed layer as owed and + * write nothing; an apply turns it into the marker before it refreshes. */ +export async function promptRefreshOwed( + home: string, + folder: string, +): Promise { + if (await promptRefreshMarked(home)) return true; + return promptInputsChanged(home, await readPromptInputs(folder)); +} diff --git a/src/buddy/persona-apply.ts b/src/buddy/persona-apply.ts index 93307315..21a1df9b 100644 --- a/src/buddy/persona-apply.ts +++ b/src/buddy/persona-apply.ts @@ -13,7 +13,11 @@ import { gatewayUnitState, owePromptRefresh, type PromptRefresh, + promptInputsChanged, + promptRefreshMarked, promptRefreshOwed, + readPromptInputs, + recordPromptInputs, refreshPrompts, reloadUnits, restartGateway, @@ -47,9 +51,12 @@ import { buddyT3Quiet, buddyT3Unit } from "./t3-runtime"; // once it has no run going (F61's rule). Each file is replaced atomically on // its own; there is no transaction across SOUL.md, the drop-in and the T3 // settings, so whatever did not happen stays visible and the next apply does -// it. The installer and Machines run it where they rendered these files -// themselves; the Launchpad calls the same core from Začínáme and -// Nastavení → Buddy. +// it. A change of his Lazurio layer in the Folder reaches his conversations +// the same way: the stored prompts are refreshed when the layer is not the +// one the last complete refresh was built from (F64's addendum of +// 2026-10-25). The installer and Machines run it where they rendered these +// files themselves; the Launchpad calls the same core from Začínáme and +// Nastavení → Buddy, and on its own where Buddy is (`persona-save.ts`). /** A derived file: read, it is `in-place` or `outdated`; applied, it is * `in-place`, `written`, or had only its mode set (`mode`). */ @@ -124,7 +131,12 @@ export type BuddyPersonaReason = * clears, or nothing could be cleared: the refresh stays owed. */ | "prompts-incomplete" | "gateway-restart-failed" - | "chat-restart-failed"; + | "chat-restart-failed" + /** A file of Buddy's Lazurio layer in the Folder is no regular file or + * cannot be read, so his prompts lack it: it counts by a token of its own, + * a refresh owed when it became so is done, and the next change of it owes + * another (F64's addendum of 2026-10-25). */ + | "layer-unreadable"; export const invalidReason = ( field: PersonaField, @@ -383,7 +395,9 @@ const chatOf = (answer: BuddyChat): PersonaChat => ...(answer.reason === undefined ? {} : { reason: answer.reason }), }); -/** What is in place, without writing, restarting or clearing anything. */ +/** What is in place, without writing, restarting or clearing anything. A + * Lazurio layer the last complete refresh was not built from counts as an + * owed refresh, and only the next apply records it. */ async function reported( context: Context, files: readonly DerivedFile[], @@ -397,7 +411,10 @@ async function reported( files.map(state), ); const unit = await gatewayUnitState(service); - const owed = await promptRefreshOwed(context.home); + const inputs = await readPromptInputs(context.folder); + const owed = + (await promptRefreshMarked(context.home)) || + (await promptInputsChanged(context.home, inputs)); const running = unit.state === "running"; const restart: GatewayRestart = unit.state === "unknown" @@ -420,6 +437,7 @@ async function reported( return Object.freeze({ kind: "buddy-persona", state: inPlace ? "in-place" : "outdated", + ...(inputs.unreadable ? { reason: "layer-unreadable" as const } : {}), persona, locale, files: Object.freeze({ soul, actingSender }), @@ -431,6 +449,7 @@ async function reported( /** The derived files in place, then their way to the running conversations. * SOUL.md about to change makes a refresh of the stored prompts owed first, + * and so does a Lazurio layer the last complete refresh was not built from, * so a failure anywhere after leaves it for the next apply. systemd rereads * the units, and where it refuses nothing restarts on the old ones. A refresh * that is owed runs when SOUL.md was written now (busy or not: it only clears @@ -438,8 +457,10 @@ async function reported( * moment. The gateway restarts once, only while Hermes's `active-work` says * Buddy is idle, when it runs from before the files or after a complete * refresh that was owed; the owed refresh is settled only after a complete - * refresh and that restart. Buddy's T3 settings follow, his T3 Code stopped - * and started around them only when it has no run going. */ + * refresh and that restart, and then the layer as this apply found it when + * it began is recorded as what the prompts were built from. Buddy's T3 + * settings follow, his T3 Code stopped and started around them only when it + * has no run going. */ async function applied( context: Context, files: readonly DerivedFile[], @@ -447,13 +468,19 @@ async function applied( persona: PersonaView, locale: PersonaLocale, ): Promise { - const { environment, home, hermesHome } = context; + const { environment, home, hermesHome, folder } = context; const { tools } = environment; + // Read first: every turn after the refresh builds its prompt from the layer + // as it is then, this one or a newer one. Only this one is recorded, so a + // layer that changes while this apply runs stays owed. + const inputs = await readPromptInputs(folder); const written: Record = { soul: "in-place", actingSender: "in-place", }; try { + if (await promptInputsChanged(home, inputs)) + await owePromptRefresh(home, environment.now); for (const file of files) { if (file.name === "soul" && (await derivedFileState(file)) === "outdated") await owePromptRefresh(home, environment.now); @@ -473,7 +500,7 @@ async function applied( unit.needsReload && !(await reloadUnits(service)); const running = unit.state === "running"; - const owed = await promptRefreshOwed(home); + const owed = await promptRefreshMarked(home); // Buddy is idle when his gateway says nothing runs, or when it does not run // at all; without an answer he counts as busy. @@ -516,8 +543,15 @@ async function applied( : (await restartGateway(service)) ? "done" : "failed"; - if (owed && refreshed && (!running || restart === "done")) + // An owed refresh that waits for the idle moment waits with its restart, + // as the read says (a changed layer alone writes no file to restart for). + else if (running && owed && moment !== "idle") restart = "pending"; + if (owed && refreshed && (!running || restart === "done")) { + // The record first: a crash between the two leaves the marker, and the + // next apply refreshes once more, never less. + await recordPromptInputs(home, inputs); await settlePromptRefresh(home); + } const chatAnswer = await buddyChat(tools, { folder: context.folder, @@ -539,8 +573,12 @@ async function applied( ? "gateway-restart-failed" : chat.state === "failed" ? "chat-restart-failed" - : undefined; - const stillOwed = await promptRefreshOwed(home); + : inputs.unreadable + ? "layer-unreadable" + : undefined; + // A layer that changed while this apply ran is owed still: the answer + // waits, and the Launchpad applies again. + const stillOwed = await promptRefreshOwed(home, folder); const waiting = restart === "pending" || chat.state === "waiting" || stillOwed; return Object.freeze({ diff --git a/src/buddy/persona-save.ts b/src/buddy/persona-save.ts index d9fa5ed5..ba3610a8 100644 --- a/src/buddy/persona-save.ts +++ b/src/buddy/persona-save.ts @@ -26,10 +26,11 @@ import { // before it restarts his gateway, so what it could not finish yet the // Launchpad finishes: it applies again while Buddy is busy, and whenever a // reading finds a refresh of his stored prompts still owed (the record F64's -// apply keeps until a complete refresh and the restart after it), so a -// Launchpad that restarted takes it up again. Nothing of this is stored -// beside F64's own record. The journal names the operation, its outcome and -// its reason, never a name, a value or a revision. +// apply keeps until a complete refresh and the restart after it, or his +// Lazurio layer in the Folder no longer the one his last complete refresh was +// built from), so a Launchpad that restarted takes it up again. Nothing of +// this is stored beside F64's own records. The journal names the operation, +// its outcome and its reason, never a name, a value or a revision. /** The persona as the page shows it: `revision` names the file a change * must still find (null-free: a page that has none sends null); `pending` @@ -109,6 +110,13 @@ export const personaRetryMs = 15_000; * again, at most `personaRetryMaxMs`. */ export const personaFailedRetryMs = 30_000; export const personaRetryMaxMs = 15 * 60_000; +/** After its start, the Launchpad reads Buddy's persona again this often on + * its own, as often as it asks an Organization's settings (F64's addendum of + * 2026-10-25). A change no page saw then reaches him without one: Machines' + * apply raises the Platform, which restarts the Launchpad, and only then + * refreshes the Folder, which writes his new Lazurio layer; and a + * `lazurio buddy persona --apply` that waited for him leaves its marker. */ +export const personaWatchMs = 2 * 60_000; /** The persona of a reading, as the page shows it; null where there is none * the installer would take (absent, unreadable or refused): the page then @@ -162,11 +170,13 @@ export function appliedOf(answer: BuddyPersona | "running"): PersonaApplied { } /** The Launchpad's persona: `apply` is F64's (`buddyPersona` with `apply`), - * `home` the account's own. */ + * `home` the account's own and `folder` the Folder it serves. */ export function createPersonaSave( input: Readonly<{ /** The account's home; none known reads no persona and writes none. */ home: string | undefined; + /** The Folder whose Lazurio layer Buddy's prompts are built from. */ + folder: string; apply: () => Promise; journal?: ((entry: PersonaJournalEntry) => void) | undefined; /** Tests only: the clock of `confirmed_at` and of the retries. */ @@ -182,6 +192,7 @@ export function createPersonaSave( const journal = (entry: PersonaJournalEntry) => input.journal?.(entry); const home = input.home ?? ""; const homeKnown = home !== "" && isAbsolute(home); + const owedNow = () => promptRefreshOwed(home, input.folder); // One apply at a time in this process (F64's own lock orders it against // the command): a save joins the one that runs, which reads the persona @@ -195,6 +206,8 @@ export function createPersonaSave( let failedDelay = personaFailedRetryMs; let journaled: string | null = null; let closed = false; + // The next reading of the Launchpad's own, after `start`. + let watch: unknown; const schedule = (delay: number) => { if (retry !== undefined) timers.cancel(retry); @@ -274,18 +287,41 @@ export function createPersonaSave( const pending = () => running !== null || retry !== undefined; + /** The persona as it is now. A refresh still owed, a changed layer's + * included, starts an apply, unless one runs, waits or was asked a moment + * ago. */ + async function read(): Promise { + if (!homeKnown) return null; + const [persona, owed] = await Promise.all([readPersona(home), owedNow()]); + if (owed && !closed && !pending() && now().getTime() >= notBefore) + void run(); + return personaShown(persona, owed || pending()); + } + + /** The Launchpad's next reading of its own, `personaWatchMs` from now; an + * apply it starts is the one any reading starts. */ + function watchAgain() { + watch = timers.after(() => { + watch = undefined; + if (closed) return; + watchAgain(); + void read().catch(() => null); + }, personaWatchMs); + } + return Object.freeze({ - /** The persona as it is now. A refresh still owed starts an apply, - * unless one runs, waits or was asked a moment ago. */ - async read(): Promise { - if (!homeKnown) return null; - const [read, owed] = await Promise.all([ - readPersona(home), - promptRefreshOwed(home), - ]); - if (owed && !closed && !pending() && now().getTime() >= notBefore) - void run(); - return personaShown(read, owed || pending()); + read, + + /** The Launchpad starts where Buddy is (F64's addendum of 2026-10-25): + * one reading now, so a refresh owed since it last ran is applied once + * he is idle, without anyone opening a page; a Platform update restarts + * it. Then one every `personaWatchMs` until `close`. Resolves when the + * first reading, and the apply it started, ended. */ + async start(): Promise { + if (!homeKnown) return; + await read(); + if (!closed && watch === undefined) watchAgain(); + await running; }, /** A change from the page, only while the file is the one at @@ -323,7 +359,7 @@ export function createPersonaSave( reason: "persona-changed", current: personaShown( write.current, - pending() || (await promptRefreshOwed(home)), + pending() || (await owedNow()), ), }), "persona-changed", @@ -339,7 +375,7 @@ export function createPersonaSave( outcome: write.kind, applied: applied.state, }); - const owed = await promptRefreshOwed(home); + const owed = await owedNow(); return Object.freeze({ kind: "persona", outcome: write.kind, @@ -352,11 +388,13 @@ export function createPersonaSave( }); }, - /** The Launchpad stops: no retry after it. */ + /** The Launchpad stops: no retry and no reading of its own after it. */ close() { closed = true; if (retry !== undefined) timers.cancel(retry); retry = undefined; + if (watch !== undefined) timers.cancel(watch); + watch = undefined; }, }); } diff --git a/src/launchpad/server.ts b/src/launchpad/server.ts index 5a70be09..bb2a7158 100644 --- a/src/launchpad/server.ts +++ b/src/launchpad/server.ts @@ -1190,6 +1190,7 @@ export async function startLaunchpad( // journal never names a name, a value or a revision. const buddyPersona = createPersonaSave({ home: toolsEnvironment.home, + folder, apply: () => applyBuddyPersona( { tools: toolsEnvironment, env: process.env }, @@ -2829,6 +2830,18 @@ export async function startLaunchpad( if (installed !== undefined && !publicEnvironment) toolsReading.warm(); // The Launchpad's holds, reconciled with the hub's list from now on. await holder?.start(); + // Buddy's stored prompts (decision F64, addendum of 2026-10-25): where the + // entry names him, his persona is read now, in the background, and again + // every two minutes until the Launchpad stops (`personaWatchMs`), so a + // refresh owed since this Launchpad last ran, or by a change no page saw + // (a change of his Lazurio layer included), is applied once he is idle, + // without anyone opening a page. A Platform update restarts the Launchpad; + // the Folder refresh that writes his new layer may come after it. Never a + // reason for the start to wait or fail. + const buddyAtStart = + entry?.buddyOrigin === undefined + ? Promise.resolve() + : buddyPersona.start().catch(() => undefined); // Executor (decision F44, addendum of 2026-10-11, #298): where install and // update only report it, this Launchpad sets it up in the background once // it listens, through the one setup of Settings → Tools. Never awaited and @@ -2893,12 +2906,18 @@ export async function startLaunchpad( hosted: entry !== null, /** Resolves when the reads this Launchpad began on its own in the * background have ended: whose Personalspace this is, decided at its - * start, and the shell's setup, read again after a change. No answer - * waits for them (none waits for GitHub); what observes what the + * start, the shell's setup, read again after a change, and Buddy's + * persona read at its start, with the apply that reading started. No + * answer waits for them (none waits for GitHub); what observes what the * Launchpad asks GitHub or its Folder waits here first, so that it * observes the request it makes and not the start's own work. */ async settled(): Promise { - await Promise.all([personalspace.settled(), setup.settled(), warming]); + await Promise.all([ + personalspace.settled(), + setup.settled(), + warming, + buddyAtStart, + ]); }, close() { closing = true; diff --git a/tests/buddy-persona-save.test.ts b/tests/buddy-persona-save.test.ts index fb2aaa34..ccd4a1e7 100644 --- a/tests/buddy-persona-save.test.ts +++ b/tests/buddy-persona-save.test.ts @@ -1,10 +1,21 @@ import { expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { owePromptRefresh, + promptInputsPath, + promptRefreshMarked, + readPromptInputs, + recordPromptInputs, settlePromptRefresh, } from "../src/buddy/gateway-refresh"; import { personaPath, writePersona } from "../src/buddy/persona"; @@ -16,7 +27,9 @@ import { type PersonaTimers, personaFailedRetryMs, personaRetryMs, + personaWatchMs, } from "../src/buddy/persona-save"; +import { buddyRulesPath } from "../src/folder/outputs"; // Decision F65: Buddy's persona as Začínáme and Nastavení → Buddy read and // change it, over F64's core. A change is written only over the file the @@ -102,17 +115,29 @@ function applies(...answers: BuddyPersona["state"][]) { }; } +/** Where Buddy reads his rules: `.agents/buddy/.hermes.md` of the Folder. */ +const rulesOf = (folder: string) => join(folder, buddyRulesPath); + +/** A home with Buddy's persona and a Folder with his rules, which his last + * complete refresh was built from (F64's addendum of 2026-10-25), as an + * installed Buddy's. */ async function within( - body: (home: string) => Promise, + body: (home: string, folder: string) => Promise, options: Readonly<{ persona?: boolean }> = {}, ) { const home = await realpath(await mkdtemp(join(tmpdir(), "persona-save-"))); + const folder = join(home, "Lazurio"); try { if (options.persona !== false) { await mkdir(dirname(personaPath(home)), { recursive: true, mode: 0o700 }); expect((await writePersona(home, values, null)).kind).toBe("written"); } - await body(home); + await mkdir(dirname(rulesOf(folder)), { recursive: true, mode: 0o700 }); + await writeFile(rulesOf(folder), "Buddy's rules of this release\n", { + mode: 0o600, + }); + await recordPromptInputs(home, await readPromptInputs(folder)); + await body(home, folder); } finally { await rm(home, { recursive: true, force: true }); } @@ -121,8 +146,8 @@ async function within( posix( "the persona as the page shows it: the names, whether confirmed, the revision of its file; none without one", async () => { - await within(async (home) => { - const save = createPersonaSave({ home, apply: applies().apply }); + await within(async (home, folder) => { + const save = createPersonaSave({ home, folder, apply: applies().apply }); expect(await save.read()).toEqual({ buddyName: "Rex", address: "ty", @@ -134,12 +159,17 @@ posix( save.close(); }); await within( - async (home) => { - const save = createPersonaSave({ home, apply: applies().apply }); + async (home, folder) => { + const save = createPersonaSave({ + home, + folder, + apply: applies().apply, + }); expect(await save.read()).toBeNull(); // No home known: nothing read, nothing written. const none = createPersonaSave({ home: undefined, + folder, apply: applies().apply, }); expect(await none.read()).toBeNull(); @@ -156,12 +186,13 @@ posix( posix( "a change is written over the file the page read, then applied; the confirmation stays once given; the journal names no name, value or revision", async () => { - await within(async (home) => { + await within(async (home, folder) => { const journal: PersonaJournalEntry[] = []; const fake = applies("applied"); const hand = handTimers(); const save = createPersonaSave({ home, + folder, apply: fake.apply, journal: (entry) => journal.push(entry), now: hand.now, @@ -228,11 +259,12 @@ posix( posix( "a change over another revision is persona-changed with the persona as it is now, and writes nothing", async () => { - await within(async (home) => { + await within(async (home, folder) => { const journal: PersonaJournalEntry[] = []; const fake = applies("applied"); const save = createPersonaSave({ home, + folder, apply: fake.apply, journal: (entry) => journal.push(entry), }); @@ -269,11 +301,12 @@ posix( posix( "each member the installer would refuse is named, whatever the file, and nothing is written", async () => { - await within(async (home) => { + await within(async (home, folder) => { const journal: PersonaJournalEntry[] = []; const fake = applies("applied"); const save = createPersonaSave({ home, + folder, apply: fake.apply, journal: (entry) => journal.push(entry), }); @@ -317,12 +350,13 @@ posix( posix( "while Buddy is busy the change waits: pending, and the Launchpad applies again until it is in place", async () => { - await within(async (home) => { + await within(async (home, folder) => { const journal: PersonaJournalEntry[] = []; const fake = applies("waiting", "waiting", "applied"); const hand = handTimers(); const save = createPersonaSave({ home, + folder, apply: fake.apply, journal: (entry) => journal.push(entry), now: hand.now, @@ -362,11 +396,12 @@ posix( posix( "an apply that takes longer than a moment answers waiting and finishes on its own; a save joins the apply under way", async () => { - await within(async (home) => { + await within(async (home, folder) => { let finish: (answer: BuddyPersona) => void = () => undefined; let calls = 0; const save = createPersonaSave({ home, + folder, apply: () => { calls += 1; return new Promise((resolve) => { @@ -404,11 +439,12 @@ posix( posix( "a refresh still owed after a restart of the Launchpad is taken up by a reading, once, not again a moment later", async () => { - await within(async (home) => { + await within(async (home, folder) => { const fake = applies("unavailable"); const hand = handTimers(); const save = createPersonaSave({ home, + folder, apply: fake.apply, now: hand.now, timers: hand.timers, @@ -436,15 +472,163 @@ posix( }, ); +posix( + "a reading takes up the refresh Buddy's changed Lazurio layer owes as it takes up the marker (F64's addendum of 2026-10-25): it writes nothing itself and starts one apply; no record counts too", + async () => { + await within(async (home, folder) => { + const fake = applies("applied"); + const hand = handTimers(); + const save = createPersonaSave({ + home, + folder, + apply: fake.apply, + now: hand.now, + timers: hand.timers, + }); + expect((await save.read())?.pending).toBe(false); + await tick(); + expect(fake.calls()).toBe(0); + // A Platform update's Folder refresh rewrites his rules. + const recorded = await readFile(promptInputsPath(home), "utf8"); + await writeFile(rulesOf(folder), "Buddy's rules of the next release\n"); + expect((await save.read())?.pending).toBe(true); + await tick(); + expect(fake.calls()).toBe(1); + // F64's apply is the one writer: the reading left both records alone. + expect(await readFile(promptInputsPath(home), "utf8")).toBe(recorded); + expect(await promptRefreshMarked(home)).toBe(false); + // The apply refreshed and recorded the layer it began with. + await recordPromptInputs(home, await readPromptInputs(folder)); + await hand.pass(personaRetryMs); + expect((await save.read())?.pending).toBe(false); + await tick(); + expect(fake.calls()).toBe(1); + // No record, as on a Buddy before this release: owed as well. + await rm(promptInputsPath(home)); + expect((await save.read())?.pending).toBe(true); + await tick(); + expect(fake.calls()).toBe(2); + save.close(); + }); + }, +); + +posix( + "the Launchpad's start reads once: a refresh owed since it last ran, a changed layer's included, starts one apply, and the start resolves when that apply ended; nothing owed starts none; a closed controller starts none", + async () => { + await within(async (home, folder) => { + // Nothing owed: the start reads and applies nothing. + const quiet = applies("applied"); + const idle = createPersonaSave({ home, folder, apply: quiet.apply }); + await idle.start(); + expect(quiet.calls()).toBe(0); + idle.close(); + + // A Platform update wrote his new rules while no Launchpad ran. + await writeFile(rulesOf(folder), "Buddy's rules of the next release\n"); + let finish: (answer: BuddyPersona) => void = () => undefined; + let calls = 0; + const journal: PersonaJournalEntry[] = []; + const save = createPersonaSave({ + home, + folder, + apply: () => { + calls += 1; + return new Promise((resolve) => { + finish = resolve; + }); + }, + journal: (entry) => journal.push(entry), + }); + let started = false; + const start = save.start().then(() => { + started = true; + }); + await tick(); + expect(calls).toBe(1); + // The start waits for the apply it began. + expect(started).toBe(false); + finish({ kind: "buddy-persona", state: "applied", restart: "done" }); + await start; + expect(started).toBe(true); + expect(calls).toBe(1); + expect(journal).toEqual([{ operation: "apply", outcome: "applied" }]); + save.close(); + + // A controller the Launchpad already closed starts nothing. + const closed = applies("applied"); + const late = createPersonaSave({ home, folder, apply: closed.apply }); + late.close(); + await late.start(); + expect(closed.calls()).toBe(0); + }); + }, +); + +/** Waits until `ready`, at most a second. */ +async function until(ready: () => boolean) { + for (let round = 0; round < 200 && !ready(); round += 1) await tick(); + expect(ready()).toBe(true); +} + +posix( + "after its start the Launchpad reads every two minutes on its own: his new rules, written by a Folder refresh after the Launchpad restarted, and a marker the command left, are each taken up by the next reading; nothing owed starts nothing; close stops it", + async () => { + await within(async (home, folder) => { + const fake = applies("applied"); + const hand = handTimers(); + const save = createPersonaSave({ + home, + folder, + apply: fake.apply, + now: hand.now, + timers: hand.timers, + }); + await save.start(); + expect(fake.calls()).toBe(0); + expect(hand.set()).toEqual([personaWatchMs]); + // Nothing owed: a reading, and no apply. + await hand.pass(personaWatchMs); + for (let round = 0; round < 10; round += 1) await tick(); + expect(fake.calls()).toBe(0); + expect(hand.set()).toEqual([personaWatchMs]); + // Machines' apply refreshes the Folder after the Platform's own update + // restarted the Launchpad: his new rules arrive after its start. + await writeFile(rulesOf(folder), "Buddy's rules of the next release\n"); + await hand.pass(personaWatchMs); + await until(() => fake.calls() === 1); + // Applied and recorded: the next reading finds nothing owed. + await recordPromptInputs(home, await readPromptInputs(folder)); + await hand.pass(personaWatchMs); + for (let round = 0; round < 10; round += 1) await tick(); + expect(fake.calls()).toBe(1); + // A `lazurio buddy persona --apply` that waited for him left its + // marker. + await owePromptRefresh(home); + await hand.pass(personaWatchMs); + await until(() => fake.calls() === 2); + // A second start sets no second watch; close stops the one there is. + await settlePromptRefresh(home); + await save.start(); + expect(hand.set()).toEqual([personaWatchMs]); + save.close(); + expect(hand.set()).toEqual([]); + await hand.pass(personaWatchMs); + expect(fake.calls()).toBe(2); + }); + }, +); + posix( "a failed apply is asked again later and later; an apply that throws counts as failed; after close nothing runs", async () => { - await within(async (home) => { + await within(async (home, folder) => { const journal: PersonaJournalEntry[] = []; let calls = 0; const hand = handTimers(); const save = createPersonaSave({ home, + folder, apply: async () => { calls += 1; if (calls === 1) throw new Error("spawn failed"); diff --git a/tests/buddy-persona.test.ts b/tests/buddy-persona.test.ts index 56bed74d..cc613d7d 100644 --- a/tests/buddy-persona.test.ts +++ b/tests/buddy-persona.test.ts @@ -21,12 +21,17 @@ import { type ActiveWork, askActiveWork, gatewayUnitState, - liveConversations, microsecondsOf, + promptInputsPath, + promptRefreshMarked, promptRefreshOwed, promptRefreshOwedPath, + readPromptInputs, + recordedPromptInputs, + recordPromptInputs, refreshPrompts, restartPending, + resumableConversations, } from "../src/buddy/gateway-refresh"; import { actingSenderMode, @@ -56,7 +61,11 @@ import { type BuddyPersonaEnvironment, buddyPersona, } from "../src/buddy/persona-apply"; -import { initializeMachineFolder } from "../src/machine/cli"; +import { buddyLayerPaths, buddyRulesPath } from "../src/folder/outputs"; +import { + initializeMachineFolder, + refreshMachineFolder, +} from "../src/machine/cli"; import { acquireFileLock } from "../src/platform/flock"; import type { ToolsEnvironment } from "../src/tools/overview"; import type { ToolRunner } from "../src/tools/status"; @@ -772,6 +781,9 @@ async function osobniProstredi(locale: PersonaLocale = "cs") { locale, }); expect(initialized.code).toBe(0); + // His prompts were last refreshed from the Lazurio layer the Folder holds + // (F64's addendum of 2026-10-25), as an installed Buddy's. + await recordPromptInputs(home, await readPromptInputs(folder)); const hermesHome = join(home, ".hermes"); await mkdir(hermesHome, { mode: 0o700 }); await mkdir(dirname(personaPath(home)), { recursive: true, mode: 0o700 }); @@ -1040,7 +1052,7 @@ async function osobniProstredi(locale: PersonaLocale = "cs") { .prepare("update sessions set system_prompt_hash = ? where id = ?") .run(hash, id), ); - const owed = () => promptRefreshOwed(home); + const owed = () => promptRefreshOwed(home, folder); return { home, folder, @@ -1648,6 +1660,359 @@ posix( }), ); +// ---- His Lazurio layer (F64's addendum of 2026-10-25, #648) ---------------- + +/** Both live conversations that hold a stored prompt took a turn, so each + * holds one again. */ +const turns = (env: Prostredi) => { + env.turn("20261025_080000_abc123", "h-telegram-turn"); + env.turn("2b7c1e0a-1111-4222-8333-444455556666", "h-acp-turn"); +}; +const bothRepaired = [ + "2b7c1e0a-1111-4222-8333-444455556666", + "20261025_080000_abc123", +]; + +posix( + "a Folder refresh that rewrites Buddy's rules owes a refresh of his stored prompts: the read reports it and writes nothing, an apply while he is busy records it and waits, the idle one clears the prompts, restarts once and records the layer, and the next apply does nothing; a Folder refresh that changes nothing owes none", + () => + within(async (env) => { + const rules = join(env.folder, buddyRulesPath); + expect(await apply(env)).toMatchObject({ state: "applied" }); + turns(env); + // The same release's Folder refresh renders the same rules. + expect( + await refreshMachineFolder(env.folder, bindings.personal), + ).toMatchObject({ code: 0 }); + expect(await env.owed()).toBe(false); + // His prompts were built from the previous release's rules, and the + // update's Folder refresh writes this release's. + await writeFile(rules, "Buddy's rules of the previous release\n"); + await recordPromptInputs(env.home, await readPromptInputs(env.folder)); + expect( + await refreshMachineFolder(env.folder, bindings.personal), + ).toMatchObject({ code: 0 }); + expect(await readFile(rules, "utf8")).not.toContain("previous release"); + const recorded = await readFile(promptInputsPath(env.home), "utf8"); + env.world.calls.length = 0; + expect(await read(env)).toMatchObject({ + state: "outdated", + files: { soul: "in-place", actingSender: "in-place" }, + prompts: { state: "owed" }, + restart: "pending", + }); + expect("reason" in (await read(env))).toBe(false); + // The read wrote nothing: no marker, the record as it was. + expect(await promptRefreshMarked(env.home)).toBe(false); + expect(await readFile(promptInputsPath(env.home), "utf8")).toBe(recorded); + expect(env.repairs()).toEqual([]); + expect(env.calls(restarts)).toBe(0); + // Busy: the apply records the owed refresh, clears nothing and waits. + env.world.work = "busy"; + expect(await apply(env)).toMatchObject({ + state: "waiting", + files: { soul: "in-place", actingSender: "in-place" }, + prompts: { state: "owed" }, + restart: "pending", + }); + expect(await promptRefreshMarked(env.home)).toBe(true); + expect(await readFile(promptInputsPath(env.home), "utf8")).toBe(recorded); + expect(env.repairs()).toEqual([]); + expect(env.calls(restarts)).toBe(0); + // Idle: the stored prompts cleared, one restart, the layer recorded. + env.world.work = "idle"; + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "applied", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.repairs()).toEqual(bothRepaired); + expect(env.calls(restarts)).toBe(1); + expect(await recordedPromptInputs(env.home)).toBe( + (await readPromptInputs(env.folder)).layerDigest, + ); + expect(await env.owed()).toBe(false); + // Nothing more: the next apply clears nothing and restarts nothing. + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "applied", + restart: "none", + }); + expect(env.repairs()).toEqual([]); + expect(env.calls(restarts)).toBe(0); + expect(await read(env)).toMatchObject({ state: "in-place" }); + }), +); + +posix( + "no record of what his prompts were built from, as on a Buddy before this release, owes the refresh once: one apply clears, restarts and records, and then nothing is owed for good; a record that is none counts as none", + () => + within(async (env) => { + expect(await apply(env)).toMatchObject({ state: "applied" }); + turns(env); + const path = promptInputsPath(env.home); + await rm(path); + expect(await env.owed()).toBe(true); + expect(await read(env)).toMatchObject({ + state: "outdated", + prompts: { state: "owed" }, + restart: "pending", + }); + await expect(lstat(path)).rejects.toThrow(); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "applied", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.calls(restarts)).toBe(1); + // The record: 0600 in 0700, this schema and the layer's digest only. + const digest = (await readPromptInputs(env.folder)).layerDigest; + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect((await stat(dirname(path))).mode & 0o777).toBe(0o700); + expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ + schema_version: "lazurio.buddy-prompt-inputs.v1", + layer_digest: digest, + }); + expect( + (await readdir(dirname(path))).filter((name) => + name.endsWith(".lazurio"), + ), + ).toEqual([]); + // Settled for good. + for (let round = 0; round < 2; round += 1) { + turns(env); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "applied", + restart: "none", + }); + expect(env.repairs()).toEqual([]); + expect(env.calls(restarts)).toBe(0); + } + expect(await read(env)).toMatchObject({ state: "in-place" }); + // Another schema, a digest that is none, or a record through a link + // records nothing: owed again. + const good = await readFile(path, "utf8"); + const elsewhere = join(env.home, "prompt-inputs-elsewhere.json"); + await writeFile(elsewhere, good); + for (const put of [ + () => + writeFile( + path, + JSON.stringify({ + schema_version: "lazurio.buddy-prompt-inputs.v2", + layer_digest: digest, + }), + ), + () => + writeFile( + path, + JSON.stringify({ + schema_version: "lazurio.buddy-prompt-inputs.v1", + layer_digest: digest.toUpperCase(), + }), + ), + () => writeFile(path, "{"), + async () => { + await rm(path); + await symlink(elsewhere, path); + }, + ]) { + await put(); + expect(await env.owed()).toBe(true); + await rm(path); + await writeFile(path, good, { mode: 0o600 }); + expect(await env.owed()).toBe(false); + } + }), +); + +posix( + "the record is the layer as the apply found it when it began: rules rewritten while it runs stay owed, the apply answers waiting, and the next one clears again and records the new layer", + () => + within(async (env) => { + expect(await apply(env)).toMatchObject({ state: "applied" }); + const rules = join(env.folder, buddyRulesPath); + await writeFile(rules, "Buddy's rules of the next release\n"); + const began = (await readPromptInputs(env.folder)).layerDigest; + // Rewritten again while the apply runs: after it read the layer and + // wrote its files, when it asks systemd about the gateway. + env.world.during = async () => { + env.world.during = undefined; + await writeFile(rules, "Buddy's rules of the release after it\n"); + }; + turns(env); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "waiting", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.calls(restarts)).toBe(1); + expect(await recordedPromptInputs(env.home)).toBe(began); + expect(await promptRefreshMarked(env.home)).toBe(false); + expect(await env.owed()).toBe(true); + expect(await read(env)).toMatchObject({ + state: "outdated", + prompts: { state: "owed" }, + }); + turns(env); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "applied", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.repairs()).toEqual(bothRepaired); + expect(env.calls(restarts)).toBe(1); + expect(await recordedPromptInputs(env.home)).toBe( + (await readPromptInputs(env.folder)).layerDigest, + ); + expect(await env.owed()).toBe(false); + }), +); + +posix( + "a file of the layer that is no regular file of its own (a link, never followed) is unreadable: it owes the refresh when it becomes so and is named layer-unreadable; the refresh records it, so it is not refreshed again; a regular file again owes the next refresh", + () => + within(async (env) => { + expect(await apply(env)).toMatchObject({ state: "applied" }); + const rules = join(env.folder, buddyRulesPath); + const bytes = await readFile(rules); + // The same bytes, through a link. + const elsewhere = join(env.home, "rules-elsewhere.md"); + await writeFile(elsewhere, bytes); + await rm(rules); + await symlink(elsewhere, rules); + expect(await read(env)).toMatchObject({ + state: "outdated", + reason: "layer-unreadable", + prompts: { state: "owed" }, + }); + turns(env); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "failed", + reason: "layer-unreadable", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.calls(restarts)).toBe(1); + expect(await env.owed()).toBe(false); + // While it stays so, nothing is refreshed again, and it is named. + turns(env); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "failed", + reason: "layer-unreadable", + restart: "none", + }); + expect(env.repairs()).toEqual([]); + expect(env.calls(restarts)).toBe(0); + expect(await read(env)).toMatchObject({ + state: "in-place", + reason: "layer-unreadable", + }); + // A regular file again, as the Folder's refresh writes it once the + // link is gone: the next refresh is owed. + await rm(rules); + await writeFile(rules, bytes, { mode: 0o600 }); + expect(await env.owed()).toBe(true); + env.world.calls.length = 0; + expect(await apply(env)).toMatchObject({ + state: "applied", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.calls(restarts)).toBe(1); + expect(await env.owed()).toBe(false); + }), +); + +posix( + "the layer's digest: each file of buddyLayerPaths by its path, then its bytes, in order, a token of its own for absent and for unreadable, the same wherever the Folder is", + async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), "prompt-inputs-")), + ); + try { + const layer = (folder: string, texts: readonly (string | null)[]) => + Promise.all( + buddyLayerPaths.map(async (path, index) => { + const file = join(folder, path); + await rm(file, { recursive: true, force: true }); + const text = texts[index] ?? null; + if (text === null) return; + await mkdir(dirname(file), { recursive: true, mode: 0o700 }); + await writeFile(file, text, { mode: 0o600 }); + }), + ); + const digestOf = async (folder: string) => readPromptInputs(folder); + const but = ( + all: readonly (string | null)[], + changes: Readonly>, + ): (string | null)[] => + all.map((text, index) => + index in changes ? (changes[index] ?? null) : text, + ); + const texts = buddyLayerPaths.map((path) => `Text of ${path}\n`); + const one = join(root, "one", "Lazurio"); + const other = join(root, "elsewhere", "Lazurio"); + await layer(one, texts); + await layer(other, texts); + const base = await digestOf(one); + expect(base.unreadable).toBe(false); + expect(base.layerDigest).toMatch(/^[0-9a-f]{64}$/); + // The same layer in another place: the same digest. + expect(await digestOf(other)).toEqual(base); + const seen = new Set([base.layerDigest]); + const differs = async (folder: string, unreadable: boolean) => { + const now = await digestOf(folder); + expect(seen.has(now.layerDigest)).toBe(false); + expect(now.unreadable).toBe(unreadable); + seen.add(now.layerDigest); + }; + // One byte of a skill changed, its length the same; and one more byte. + await layer(one, but(texts, { 3: (texts[3] ?? "").replace("\n", "!") })); + await differs(one, false); + await layer(one, but(texts, { 3: `${texts[3]}.` })); + await differs(one, false); + // Two files' bytes swapped: which file holds them counts. + await layer(one, but(texts, { 1: texts[2] ?? "", 2: texts[1] ?? "" })); + await differs(one, false); + // An empty file, and none at all. + await layer(one, but(texts, { 0: "" })); + await differs(one, false); + await layer(one, but(texts, { 0: null })); + await differs(one, false); + // A link to the same bytes is unreadable, and reads the same twice. + await layer(one, texts); + const rules = join(one, buddyRulesPath); + await rm(rules); + await writeFile(join(root, "rules.md"), texts[0] ?? ""); + await symlink(join(root, "rules.md"), rules); + await differs(one, true); + expect((await digestOf(one)).layerDigest).toBe( + (await digestOf(one)).layerDigest, + ); + await rm(rules); + await mkdir(rules); + const directory = await digestOf(one); + // A link and a directory both read as unreadable: the same token. + expect(directory.unreadable).toBe(true); + expect(seen.has(directory.layerDigest)).toBe(true); + // No Folder at all: every file absent. + const none = await digestOf(join(root, "none")); + expect(none.unreadable).toBe(false); + expect(seen.has(none.layerDigest)).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, +); + posix( "each file is replaced on its own, no transaction across them: a drop-in that cannot be written fails the apply after SOUL.md was, the refresh SOUL.md calls for is owed already, and the next apply finishes it", () => @@ -2382,7 +2747,7 @@ posix( async () => { const directory = await realpath(await mkdtemp(join(tmpdir(), "live-"))); try { - expect(await liveConversations(directory)).toBe("absent"); + expect(await resumableConversations(directory)).toBe("absent"); sessionStore(directory, [ ["a", "telegram", null, 1, null, "ha"], ["b", "acp", null, 3, null, "hb"], @@ -2401,7 +2766,7 @@ posix( .run(); writable.close(); const before = await readFile(join(directory, "state.db")); - expect(await liveConversations(directory)).toEqual({ + expect(await resumableConversations(directory)).toEqual({ held: [ { id: "b", prompt: "hash:hb" }, { id: "e", prompt: "hash:he" }, @@ -2410,7 +2775,7 @@ posix( ], capped: false, }); - expect(await liveConversations(directory, 2)).toEqual({ + expect(await resumableConversations(directory, 2)).toEqual({ held: [ { id: "b", prompt: "hash:hb" }, { id: "e", prompt: "hash:he" }, @@ -2426,6 +2791,198 @@ posix( }, ); +/** A row of Hermes's session store with what says how a session ended and + * whose it is (v0.22.4-lazurio.3): `end_reason`, `message_count` and + * `model_config`, whose `_delegate_from` marks a subagent's run. */ +type EndedRow = readonly [ + id: string, + source: string | null, + parent: string | null, + startedAt: number, + endedAt: number | null, + endReason: string | null, + messages: number, + modelConfig: string | null, + promptHash: string | null, +]; + +function endedStore(hermesHome: string, rows: readonly EndedRow[]) { + const store = new Database(join(hermesHome, "state.db")); + store.exec( + "create table sessions (id text primary key, source text, parent_session_id text, started_at real, ended_at real, end_reason text, message_count integer, model_config text, system_prompt text, system_prompt_hash text)", + ); + const insert = store.prepare( + "insert into sessions values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)", + ); + for (const row of rows) insert.run(...row); + store.close(); +} + +posix( + "conversations that can still take a turn: the live ones and the ended ones Hermes resumes (the web chat's ACP session after a disconnect, a compression's or a reset's continuation, a closed CLI), with messages; never one ended by a compression or a boundary, an empty one, a subagent's run or a tool's session", + async () => { + const directory = await realpath(await mkdtemp(join(tmpdir(), "ended-"))); + try { + endedStore(directory, [ + ["live", "acp", null, 14, null, null, 4, null, "h-live"], + // The web chat after its viewer left: T3 Code's next message + // reopens it with this prompt. + ["web", "acp", null, 13, 20, "acp_disconnect", 12, null, "h-web"], + // The empty ACP sessions that come every 15 minutes hold none. + ["empty", "acp", null, 12, 12.5, "acp_disconnect", 0, null, null], + ["quiet", "acp", null, 11, 11.5, "acp_disconnect", 3, null, null], + ["zero", "acp", null, 10, 10.5, "acp_disconnect", 0, null, "h-zero"], + // A compression split goes on in its continuation. + ["split", "acp", null, 9, 15, "compression", 40, null, "h-split"], + ["tip", "acp", "split", 15, 21, "acp_disconnect", 5, "{}", "h-tip"], + // A daily reset goes on in a new session that keeps the lineage. + ["day", "telegram", null, 8, 16, "daily", 7, null, "h-day"], + [ + "next", + "telegram", + "day", + 16, + null, + null, + 2, + '{"_reset_from":"day"}', + "h-next", + ], + ["closed", "cli", null, 7, 17, "cli_close", 3, null, "h-closed"], + ["new", "cli", null, 6, 18, "new_session", 4, null, "h-new"], + [ + "sub", + "acp", + "live", + 5, + 19, + "agent_close", + 2, + '{"_delegate_from":"live"}', + "h-sub", + ], + ["tool", "tool", null, 4, null, null, 1, null, "h-tool"], + // A model_config that is no JSON names no subagent. + ["broken", "acp", null, 3, null, null, 1, "{", "h-broken"], + ]); + const before = await readFile(join(directory, "state.db")); + expect(await resumableConversations(directory)).toEqual({ + held: [ + { id: "next", prompt: "hash:h-next" }, + { id: "tip", prompt: "hash:h-tip" }, + { id: "live", prompt: "hash:h-live" }, + { id: "web", prompt: "hash:h-web" }, + { id: "closed", prompt: "hash:h-closed" }, + { id: "broken", prompt: "hash:h-broken" }, + ], + capped: false, + }); + // The cap counts them all. + expect(await resumableConversations(directory, 3)).toEqual({ + held: [ + { id: "next", prompt: "hash:h-next" }, + { id: "tip", prompt: "hash:h-tip" }, + { id: "live", prompt: "hash:h-live" }, + ], + capped: true, + }); + expect((await readFile(join(directory, "state.db"))).equals(before)).toBe( + true, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }, +); + +posix( + "an apply clears the stored prompt of the web chat's ended ACP session too, counted in refreshed, and leaves an ended session without a prompt untouched", + () => + within(async (env) => { + await rm(join(env.hermesHome, "state.db")); + endedStore(env.hermesHome, [ + [ + "20261025_080000_abc123", + "telegram", + null, + 1, + null, + null, + 6, + null, + "h-telegram", + ], + [ + "2b7c1e0a-1111-4222-8333-444455556666", + "acp", + null, + 2, + 3, + "acp_disconnect", + 12, + null, + "h-web", + ], + [ + "20261025_081500_empty1", + "acp", + null, + 4, + 4.5, + "acp_disconnect", + 0, + null, + null, + ], + ]); + expect(await apply(env)).toMatchObject({ + state: "applied", + prompts: { state: "refreshed", refreshed: 2, live: 2 }, + restart: "done", + }); + expect(env.repairs()).toEqual([ + "2b7c1e0a-1111-4222-8333-444455556666", + "20261025_080000_abc123", + ]); + const store = new Database(join(env.hermesHome, "state.db"), { + readonly: true, + }); + try { + expect( + store + .query( + "select id, ended_at as ended, end_reason as reason, system_prompt_hash as hash from sessions order by id", + ) + .all(), + ).toEqual([ + { + id: "20261025_080000_abc123", + ended: null, + reason: null, + hash: null, + }, + { + id: "20261025_081500_empty1", + ended: 4.5, + reason: "acp_disconnect", + hash: null, + }, + // Cleared and still ended: T3 Code's next message reopens it and + // its turn builds the prompt anew. + { + id: "2b7c1e0a-1111-4222-8333-444455556666", + ended: 3, + reason: "acp_disconnect", + hash: null, + }, + ]); + } finally { + store.close(); + } + expect(await env.owed()).toBe(false); + }), +); + posix( "a store that cannot say which prompt a conversation holds lists every live one and is judged by the commands' exits", async () => { diff --git a/tests/launchpad-buddy.test.ts b/tests/launchpad-buddy.test.ts index 2df69b1d..6229e5a0 100644 --- a/tests/launchpad-buddy.test.ts +++ b/tests/launchpad-buddy.test.ts @@ -1,14 +1,27 @@ import { expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { promptRefreshOwed } from "../src/buddy/gateway-refresh"; +import { + promptRefreshMarked, + readPromptInputs, + recordPromptInputs, +} from "../src/buddy/gateway-refresh"; import { personaPath, soulPath, writePersona } from "../src/buddy/persona"; import { initializeFolder, initializeHandoverFolder, } from "../src/folder/initialize-folder"; +import { buddyRulesPath } from "../src/folder/outputs"; import { executionOs } from "../src/folder/platform"; import { presetProfile } from "../src/folder/presets"; import { buddyRoutePaths } from "../src/launchpad/buddy-routes"; @@ -40,6 +53,9 @@ type Json = Record; async function hosted( kind: "buddy" | "personal" | "organization" = "buddy", options: Parameters[0] = {}, + /** What happens to the account's home and the Folder before the + * Launchpad starts, after Buddy's last refresh was recorded. */ + before?: (home: string, folder: string) => Promise, ) { const fake = await fakeHermesGateway(options); const parent = await realpath( @@ -69,6 +85,10 @@ async function hosted( locale: "cs", }), }); + // Buddy's prompts were last refreshed from the Folder's Lazurio layer + // (F64's addendum of 2026-10-25), as an installed Buddy's. + await recordPromptInputs(fake.userHome, await readPromptInputs(folder)); + await before?.(fake.userHome, folder); const fetcher: AuthFetcher = async (_url, init) => new Headers(init.headers).get("cookie") === "__Secure-lazurio-workspace=valid" @@ -120,6 +140,8 @@ async function hosted( body: JSON.stringify(body), }), ), + /** The reads the Launchpad began on its own at its start ended. */ + settled: () => app.settled(), async close() { await app.close(); await fake.close(); @@ -712,7 +734,7 @@ posix( expect(await readFile(soulPath(app.fake.userHome), "utf8")).toContain( "Mattyčus", ); - expect(await promptRefreshOwed(app.fake.userHome)).toBe(true); + expect(await promptRefreshMarked(app.fake.userHome)).toBe(true); expect((await app.get(buddyRoutePaths.read)).body.persona).toEqual(shown); const personaLines = lines.lines .filter((line) => line.includes('"buddy-persona"')) @@ -825,3 +847,72 @@ posix( } }, ); + +// ---- His stored prompts after a release (F64's addendum of 2026-10-25) ---- + +/** The journal lines of Buddy's persona this process wrote. */ +const personaLinesOf = (lines: readonly string[]) => + lines + .filter((line) => line.includes('"buddy-persona"')) + .map((line) => JSON.parse(line) as Json); + +posix( + "the Launchpad reads Buddy's persona once at its start: a refresh his changed Lazurio layer owes starts F64's apply without any request; nothing owed, or no Buddy in the entry, starts none", + async () => { + const lines = journal(); + // A Platform update wrote his new rules while no Launchpad ran. + const rulesChanged = async (home: string, folder: string) => { + await withPersona(home); + await writeFile( + join(folder, buddyRulesPath), + "Buddy's rules of the previous release\n", + ); + }; + try { + const app = await hosted("buddy", {}, rulesChanged); + try { + await app.settled(); + // One apply, F64's: SOUL.md derived, and the refresh it could not + // finish here (Hermes is not installed) owed and retried. + expect(personaLinesOf(lines.lines)).toEqual([ + { + scope: "buddy-persona", + operation: "apply", + outcome: "failed", + reason: "prompts-incomplete", + }, + ]); + expect(await readFile(soulPath(app.fake.userHome), "utf8")).toContain( + "Rex", + ); + expect(await promptRefreshMarked(app.fake.userHome)).toBe(true); + } finally { + await app.close(); + } + lines.lines.length = 0; + // Nothing owed: the start applies nothing. + const quiet = await hosted("buddy", {}, async (home) => { + await withPersona(home); + }); + try { + await quiet.settled(); + expect(personaLinesOf(lines.lines)).toEqual([]); + await expect(lstat(soulPath(quiet.fake.userHome))).rejects.toThrow(); + } finally { + await quiet.close(); + } + // No Buddy in the entry: nothing of his is read, whatever changed. + const personal = await hosted("personal", {}, rulesChanged); + try { + await personal.settled(); + expect(personaLinesOf(lines.lines)).toEqual([]); + await expect(lstat(soulPath(personal.fake.userHome))).rejects.toThrow(); + expect(await promptRefreshMarked(personal.fake.userHome)).toBe(false); + } finally { + await personal.close(); + } + } finally { + lines.restore(); + } + }, +);