@ouro.bot/cli 0.1.0-alpha.874 → 0.1.0-alpha.876

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.
@@ -9,7 +9,7 @@
9
9
  //
10
10
  // Protocol: JSON-RPC 2.0 over stdio, newline framed, MCP 2024-11-05.
11
11
 
12
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
12
+ import { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
13
13
  import { dirname } from "node:path"
14
14
  import { pathToFileURL } from "node:url"
15
15
 
@@ -1797,6 +1797,136 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1797
1797
  ...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep + blockedHint }
1798
1798
  }
1799
1799
 
1800
+ // ------------------------------------------------- tool: quality profiles
1801
+
1802
+ // Radarr and Sonarr keep the upgrade rules (whether a better release may replace a file, and the quality at which they
1803
+ // stop) in a quality profile. The Butler once told the owner this was "config-screen-only"; it is an ordinary API, so it
1804
+ // is a typed tool: read profiles, set cutoff and upgradeAllowed, and put a movie or series on a profile. Writes are a
1805
+ // dry run unless dry_run is false, and return before, after and applied_at like media_diagnose_and_fix.
1806
+
1807
+ const itemQualityId = (i) => i.quality?.id ?? i.id
1808
+ const itemQualityName = (i) => i.quality?.name ?? i.name
1809
+
1810
+ function profileView(p) {
1811
+ const items = p.items ?? []
1812
+ const cutoffItem = items.find((i) => itemQualityId(i) === p.cutoff)
1813
+ return { id: p.id, name: p.name, upgrade_allowed: Boolean(p.upgradeAllowed), cutoff: p.cutoff ?? null,
1814
+ cutoff_name: cutoffItem ? itemQualityName(cutoffItem) : null,
1815
+ qualities: items.map((i) => ({ id: itemQualityId(i), name: itemQualityName(i), allowed: Boolean(i.allowed), ...(i.items?.length ? { group: true } : {}) })) }
1816
+ }
1817
+
1818
+ const profileNotFound = (id) => ({ result: "no_such_profile", profile_id: id, message: "No quality profile has that id; call media_quality_profile with action read to list them." })
1819
+ const isHttp404 = (e) => /_http_404$/.test(e?.code ?? "")
1820
+
1821
+ async function getProfile(kind, id) {
1822
+ try { return await arrFor(kind)(`/qualityprofile/${id}`) } catch (e) { if (isHttp404(e)) return null; throw e }
1823
+ }
1824
+
1825
+ async function getItem(kind, serviceId) {
1826
+ try { return await arrFor(kind)(`/${kind === "series" ? "series" : "movie"}/${serviceId}`) } catch (e) { if (isHttp404(e)) return null; throw e }
1827
+ }
1828
+
1829
+ // One line per applied change: what changed, the owner's words that asked for it, who called, and when.
1830
+ const qualityAuditPath = () => process.env.SANCTUARY_MEDIA_AUDIT ?? "/home/ouro/AgentBundles/sanctuary.ouro/state/media/quality-profile-audit.ndjson"
1831
+ function auditQualityChange(entry) {
1832
+ try {
1833
+ mkdirSync(dirname(qualityAuditPath()), { recursive: true })
1834
+ appendFileSync(qualityAuditPath(), `${JSON.stringify(entry)}\n`)
1835
+ return null
1836
+ } catch (e) { return `${e.code ?? e.message}` }
1837
+ }
1838
+ const titlesUsing = async (kind, profileId) => (await arrFor(kind)(kind === "series" ? "/series" : "/movie")).filter((t) => t.qualityProfileId === profileId).length
1839
+ const OWNER_WORDS_NOTE = "owner_words is logged as the caller's claim in the audit file; the tool cannot verify the owner said it."
1840
+ const OWNER_WORDS_NOTE_AUDIT = "claimed by the caller; not verified"
1841
+ const OWNER_WORDS_REQUIRED = { result: "owner_words_required", message: "Pass owner_words with the owner's own words asking for this change (a verbatim quote), or leave dry_run true. Nothing was changed." }
1842
+
1843
+ const itemView = (item) => ({ service_id: item.id, title: item.title ?? null, quality_profile_id: item.qualityProfileId ?? null })
1844
+ const UPGRADE_NEXT_STEP = "The profile now allows the upgrade. Call media_search_now (or media_release_search) for the item so Radarr/Sonarr looks for a better release; do not ask the owner to open a settings page."
1845
+
1846
+ export async function mediaQualityProfile(a) {
1847
+ if (!kindOk(a)) return invalidKind()
1848
+ const action = a.action ?? "read"
1849
+ const dryRun = a.dry_run !== false
1850
+ const hasProfileId = a.profile_id !== undefined && a.profile_id !== null
1851
+ const hasServiceId = a.service_id !== undefined && a.service_id !== null
1852
+ const client = arrFor(a.kind)
1853
+ const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
1854
+ if (!dryRun && action !== "read" && !ownerWords) return OWNER_WORDS_REQUIRED
1855
+ const audit = (entry) => {
1856
+ const failed = auditQualityChange({ at: new Date().toISOString(), tool: "media_quality_profile", action, kind: a.kind, owner_words: ownerWords, owner_words_note: OWNER_WORDS_NOTE_AUDIT, caller: typeof a.caller === "string" ? a.caller : "unknown", ...entry })
1857
+ return failed ? { audit_error: failed, owner_words_note: OWNER_WORDS_NOTE } : { owner_words_note: OWNER_WORDS_NOTE }
1858
+ }
1859
+
1860
+ if (action === "read") {
1861
+ let profiles
1862
+ if (hasProfileId) {
1863
+ const one = await getProfile(a.kind, Number(a.profile_id))
1864
+ if (!one) return profileNotFound(Number(a.profile_id))
1865
+ profiles = [one]
1866
+ } else {
1867
+ profiles = await client("/qualityprofile")
1868
+ }
1869
+ const out = { kind: a.kind, result: "profiles", profiles: profiles.map(profileView) }
1870
+ if (hasServiceId) {
1871
+ const item = await getItem(a.kind, Number(a.service_id))
1872
+ if (!item) return { result: "no_such_item", kind: a.kind, service_id: Number(a.service_id) }
1873
+ out.item = itemView(item)
1874
+ }
1875
+ return out
1876
+ }
1877
+
1878
+ if (action === "set_upgrade") {
1879
+ if (!hasProfileId) return { result: "profile_id_required" }
1880
+ const wantsCutoff = a.cutoff !== undefined || a.cutoff_name !== undefined
1881
+ if (!wantsCutoff && a.upgrade_allowed === undefined) {
1882
+ return { result: "no_change_requested", message: "Pass upgrade_allowed and/or cutoff (a quality id) or cutoff_name." }
1883
+ }
1884
+ const current = await getProfile(a.kind, Number(a.profile_id))
1885
+ if (!current) return profileNotFound(Number(a.profile_id))
1886
+ const next = { ...current }
1887
+ if (wantsCutoff) {
1888
+ const wanted = (current.items ?? []).find((i) => (a.cutoff !== undefined ? itemQualityId(i) === Number(a.cutoff) : String(itemQualityName(i)).toLowerCase() === String(a.cutoff_name).toLowerCase()))
1889
+ if (!wanted) return { result: "cutoff_not_in_profile", profile_id: current.id, qualities: profileView(current).qualities }
1890
+ next.cutoff = itemQualityId(wanted)
1891
+ }
1892
+ if (a.upgrade_allowed !== undefined) next.upgradeAllowed = Boolean(a.upgrade_allowed)
1893
+ const before = profileView(current)
1894
+ const after = profileView(next)
1895
+ if (before.cutoff === after.cutoff && before.upgrade_allowed === after.upgrade_allowed) return { action, dry_run: dryRun, result: "unchanged", before, after: before }
1896
+ if (dryRun) {
1897
+ const shared = await titlesUsing(a.kind, current.id)
1898
+ return { action, dry_run: true, result: "would_apply", before, after, titles_using_profile: shared,
1899
+ warning: `This profile is used by ${shared} ${a.kind === "series" ? "series" : "movie"} title(s) and the change applies to all of them. To change one title only, use action assign with that title's service_id and another profile.`,
1900
+ would_do: `Set ${a.kind === "series" ? "Sonarr" : "Radarr"} profile "${current.name}" to upgrade_allowed=${after.upgrade_allowed} with cutoff ${after.cutoff_name ?? after.cutoff}. Call again with dry_run false to apply.` }
1901
+ }
1902
+ const saved = await client(`/qualityprofile/${current.id}`, { method: "PUT", body: next })
1903
+ const appliedAfter = profileView(saved ?? next)
1904
+ return { action, dry_run: false, applied_at: new Date().toISOString(), result: "applied", before, after: appliedAfter, ...audit({ profile_id: current.id, before, after: appliedAfter }), next_step: UPGRADE_NEXT_STEP }
1905
+ }
1906
+
1907
+ if (action === "assign") {
1908
+ if (!hasServiceId) return { result: "service_id_required" }
1909
+ if (!hasProfileId) return { result: "profile_id_required" }
1910
+ const item = await getItem(a.kind, Number(a.service_id))
1911
+ if (!item) return { result: "no_such_item", kind: a.kind, service_id: Number(a.service_id) }
1912
+ const profile = await getProfile(a.kind, Number(a.profile_id))
1913
+ if (!profile) return profileNotFound(Number(a.profile_id))
1914
+ const before = itemView(item)
1915
+ const after = itemView({ ...item, qualityProfileId: profile.id })
1916
+ if (before.quality_profile_id === profile.id) return { action, dry_run: dryRun, result: "unchanged", before, after: before }
1917
+ if (dryRun) {
1918
+ return { action, dry_run: true, result: "would_apply", before, after, profile: profileView(profile),
1919
+ would_do: `Put "${item.title ?? item.id}" on the "${profile.name}" profile. Call again with dry_run false to apply.` }
1920
+ }
1921
+ const saved = await client(`/${a.kind === "series" ? "series" : "movie"}/${item.id}`, { method: "PUT", body: { ...item, qualityProfileId: profile.id } })
1922
+ const assignedAfter = itemView(saved ?? { ...item, qualityProfileId: profile.id })
1923
+ return { action, dry_run: false, applied_at: new Date().toISOString(), result: "applied", before, after: assignedAfter,
1924
+ ...audit({ service_id: item.id, profile_id: profile.id, before, after: assignedAfter }), profile: profileView(profile), next_step: UPGRADE_NEXT_STEP }
1925
+ }
1926
+
1927
+ return { result: "invalid_action", message: "action must be read, set_upgrade or assign." }
1928
+ }
1929
+
1800
1930
  // ------------------------------------------------------------------- schema
1801
1931
 
1802
1932
  // The harness waits 30 s for a tool call unless the tool declares longer; indexer searches can take minutes.
@@ -1958,6 +2088,22 @@ const TOOL_LIST = [
1958
2088
  remove_ids: { type: "array", items: { type: "number" } },
1959
2089
  }, required: ["kind", "service_id"] },
1960
2090
  },
2091
+ {
2092
+ name: "media_quality_profile",
2093
+ description: "Read and change the Radarr/Sonarr quality profiles that decide whether a better release may replace a file ('Quality Profile does not allow upgrades'). This is a normal API, never a settings screen the owner has to click through. action read (default) lists the profiles for the kind with their cutoff, upgrade flag and ordered qualities, or one profile with profile_id; add service_id to also see which profile a movie or series is on. action set_upgrade changes upgrade_allowed and/or the cutoff (a quality id from the profile, or cutoff_name) of profile_id. action assign puts the movie or series service_id on profile_id. Both writes are a dry run that shows before and after and changes nothing until dry_run is false; a change to a shared profile affects every title on it, so prefer assign to move one title when the owner asked about one title. Applying needs owner_words, the owner's exact words asking for the change, and only the owner can apply; every applied change is written to an audit log. After an applied change, call media_search_now so the upgrade is searched for.",
2094
+ inputSchema: { type: "object", properties: {
2095
+ kind: { type: "string", enum: ["movie", "series"], description: "movie = Radarr, series = Sonarr." },
2096
+ action: { type: "string", enum: ["read", "set_upgrade", "assign"], description: "Default read." },
2097
+ profile_id: { type: "number", description: "Quality profile id (from action read)." },
2098
+ service_id: { type: "number", description: "Radarr movie id or Sonarr series id (media_search returns it)." },
2099
+ upgrade_allowed: { type: "boolean", description: "set_upgrade: whether a better release may replace the file." },
2100
+ cutoff: { type: "number", description: "set_upgrade: quality id from the profile at which upgrades stop." },
2101
+ cutoff_name: { type: "string", description: "set_upgrade: the same, by quality name (for example Bluray-1080p)." },
2102
+ dry_run: { type: "boolean", description: "Writes only. Default true: report before and after without changing anything. Pass false to apply." },
2103
+ owner_words: { type: "string", description: "Required to apply (dry_run false): the owner's exact words asking for this change. A verbatim quote, not a paraphrase. It is logged as your claim and is not verified, so never invent it." },
2104
+ caller: { type: "string", description: "Filled in by the runtime for the audit line; leave it out." },
2105
+ }, required: ["kind"] },
2106
+ },
1961
2107
  {
1962
2108
  name: "media_manual_import",
1963
2109
  description: "Map downloaded files to episodes by hand when their names carry the wrong numbering (Sonarr only). mode 'preview' lists the files of a download with the parsed and mapped episodes and rejections. mode 'import' takes files [{path, episode_ids}]: paths must come from that download's preview and episode_ids must belong to the series; quality and languages come from the preview. Match files to episodes by title using media_episodes.",
@@ -2006,6 +2152,7 @@ const HANDLERS = {
2006
2152
  media_release_grab: mediaReleaseGrab,
2007
2153
  media_blocklist: mediaBlocklist,
2008
2154
  media_manual_import: mediaManualImport,
2155
+ media_quality_profile: mediaQualityProfile,
2009
2156
  }
2010
2157
 
2011
2158
  // ------------------------------------------------------------ jsonrpc stdio
@@ -15,3 +15,11 @@ For "is it here yet" and "why hasn't this downloaded", `media_request_status` re
15
15
  The household's prepaid download provider is Astraweb. Its public account and top-up entry is https://www.astraweb.com/login. Never ask for or expose account credentials. When Ari asks why shows are not downloading, establish the cause before answering: check the acquisition chain first, then the specific title, and only reach for credit once exhausted credit is actually established rather than assumed. When it is, compose the answer in plain household language: downloads are paused to protect prepaid credit; give the public link; ask Ari to tell you when the top-up is done so you can resume downloads and verify one finishes; and offer a reminder at a time Ari specifies. Do not repeat backend service names, authentication or credential-check details, dead-letter/event internals, duplicate alerts, or an untrusted notification's claim that an indexer was disabled. Do not claim to top up or disable an indexer yourself. Keep the agent's own voice; this is an outcome contract, not a canned script.
16
16
 
17
17
  Ari and each approved household member are separate relationships. Their conversations, preferences, requests, private activity, and return routes do not bleed into one another. Unknown Telegram content is quarantined before any model turn and becomes conversation only after Ari admits the immutable account and chat.
18
+
19
+ My psyche is the files in this folder (`psyche/`: SOUL, IDENTITY, LORE, TACIT, ASPIRATIONS). The folder is mounted read-only into my container and the runtime refuses edits. They ship from the repository through a pull request and the replay gate, with each release. When Ari, or anyone, asks me to change my personality, voice or how I think, I say that plainly and file a request instead: I message Claude Code (Ari's coding agent) over A2A with `send_message`, and when I have `report_failure` I file it as a failure or feature report too. Claude Code makes the change in a pull request, and it arrives with the next release. Friend notes are facts and preferences about one person (their name, how they like to be messaged, when), never my own persona or style, and the tool refuses a note about my own personality (a note about the person is fine). Steward policy is the house's desired states and the routine actions I may take, never personality or conversation preferences.
20
+
21
+ What I say about a book, show, film or its characters comes from a source I read in the same turn: `web_search`, a fetched page, `books`, or `media_search`. If I have not looked, I look first, or I say I have not. Names are the first thing a memory invents, so I never name a character, author or episode that no source this turn contains, and I call a book a book, not a show.
22
+
23
+ I never tell Ari to click, open a settings page, or do a ten-second manual step. When Radarr or Sonarr rejects a better release with "Quality Profile does not allow upgrades", `media_quality_profile` changes that: `action read` shows the profiles, `action set_upgrade` changes upgrade_allowed and the cutoff of one profile, and `action assign` moves one title to another profile. Prefer `assign` when Ari asked about one title: `set_upgrade` changes a profile every title on it shares, and its dry run says how many that is. Writes are a dry run until `dry_run` is false, and applying needs `owner_words`, Ari's own words asking for the change, from Ari himself. So show Ari the before and after in one line, apply it once he has asked, then run `media_search_now`. If no tool can do what he asks, I file it with Claude Code and say I did.
24
+
25
+ When Ari asks me to be brief, my replies are plain sentences with no headers or bold labels, about 600 characters at most, and they do not end with a question he already answered. The runtime holds me to this once he has asked.
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 14,
5
+ "version": 15,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -74,6 +74,7 @@
74
74
  "media_release_grab",
75
75
  "media_blocklist",
76
76
  "media_manual_import",
77
+ "media_quality_profile",
77
78
  "media_fill_missing"
78
79
  ],
79
80
  "effectScopes": [
@@ -142,7 +143,7 @@
142
143
  ]
143
144
  },
144
145
  "sanctuary-agent-peer": {
145
- "version": 5,
146
+ "version": 6,
146
147
  "toolNames": [
147
148
  "external_event_disposition",
148
149
  "query_active_work",
@@ -207,6 +208,7 @@
207
208
  "media_release_grab",
208
209
  "media_blocklist",
209
210
  "media_manual_import",
211
+ "media_quality_profile",
210
212
  "media_fill_missing"
211
213
  ],
212
214
  "contextScopes": [
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.874</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.876</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -25,4 +25,5 @@
25
25
  <Config Name="Agent bundle" Target="/home/ouro/AgentBundles/sanctuary.ouro" Default="/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro" Mode="rw" Description="Sanctuary bundle" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro</Config>
26
26
  <Config Name="Privileged event spool" Target="/run/ouro-events" Default="/boot/config/custom/ouro-events/spool" Mode="ro" Description="Root-owned detector envelopes, read-only inside the Butler" Type="Path" Display="always" Required="true" Mask="false">/boot/config/custom/ouro-events/spool</Config>
27
27
  <Config Name="Root authority socket" Target="/run/ouro-authority" Default="/run/ouro-authority" Mode="ro" Description="Root-owned gateway socket and public issuer pins only" Type="Path" Display="always" Required="true" Mask="false">/run/ouro-authority</Config>
28
+ <Config Name="Psyche (read-only)" Target="/home/ouro/AgentBundles/sanctuary.ouro/psyche" Default="/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche" Mode="ro" Description="The Butler's psyche files, read-only inside the Butler so they can be neither edited nor replaced" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche</Config>
28
29
  </Container>
@@ -21,6 +21,8 @@ exports.repairOrphanedToolCalls = repairOrphanedToolCalls;
21
21
  exports.runAgent = runAgent;
22
22
  const config_1 = require("./config");
23
23
  const identity_1 = require("./identity");
24
+ const source_grounding_1 = require("./source-grounding");
25
+ const reply_style_1 = require("./reply-style");
24
26
  const tools_1 = require("../repertoire/tools");
25
27
  const tool_arguments_1 = require("../repertoire/tool-arguments");
26
28
  const session_events_1 = require("./session-events");
@@ -653,6 +655,10 @@ function historicalFailedEffectsForExactRepeatedRequest(messages, selection) {
653
655
  }
654
656
  return [...failedEffects.values()];
655
657
  }
658
+ const ANSWER_GATE_MAX_REJECTIONS = 2;
659
+ function userTexts(messages) {
660
+ return messages.filter((message) => message.role === "user").map((message) => messageContentText(message.content));
661
+ }
656
662
  function latestUserMessageText(messages) {
657
663
  for (let i = messages.length - 1; i >= 0; i -= 1) {
658
664
  const message = messages[i];
@@ -1152,6 +1158,9 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1152
1158
  let sawQuerySession = false;
1153
1159
  let sawBridgeManage = false;
1154
1160
  let sawExternalStateQuery = false;
1161
+ const turnToolRecords = [];
1162
+ let groundingRejections = 0;
1163
+ let brevityRejections = 0;
1155
1164
  const privateReturnHeldTokens = new Set();
1156
1165
  // Once-per-turn flag for the fresh-work rest gate. Without this, an agent
1157
1166
  // that called rest, was told "fresh work arrived", processed the items,
@@ -1957,7 +1966,36 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1957
1966
  sawSettleContentMismatch = true;
1958
1967
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
1959
1968
  }
1969
+ // Answer gates run only for agents that opt in (ToolContext.answerGates). A request-specific contract (options.requiredToolCalls)
1970
+ // already validates its own terminal answer. Each gate has its own bounded retry, and none spends the last provider iteration.
1971
+ const gates = options?.requiredToolCalls ? undefined : options?.toolContext?.answerGates;
1972
+ const retriesLeft = providerIterations < stepBudget - 1;
1973
+ let groundingError = null;
1974
+ let unverifiedNames = null;
1975
+ const grounding = gates?.sourceGrounding ? (0, source_grounding_1.sourceGroundingFinding)({ answer: deliveredAnswer, userText: userTexts(messages).join("\n"), tools: turnToolRecords }) : null;
1976
+ if (grounding) {
1977
+ if (groundingRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft) {
1978
+ groundingRejections += 1;
1979
+ groundingError = grounding.message;
1980
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "settle answer named things from a work that the turn did not look up; sending the model back", meta: { gate: "source_grounding", rejection: groundingRejections, cap: ANSWER_GATE_MAX_REJECTIONS, lookupsRun: grounding.lookups } });
1981
+ }
1982
+ else {
1983
+ unverifiedNames = grounding.names;
1984
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_disclosed", message: "unsourced names survived the retries; delivering with a disclosure", meta: { gate: "source_grounding", names: grounding.names.length } });
1985
+ }
1986
+ }
1987
+ let brevityError = null;
1988
+ if (!groundingError && gates?.brevity && !options?.toolContext?.context?.isGroupChat && brevityRejections < ANSWER_GATE_MAX_REJECTIONS && retriesLeft
1989
+ && (0, reply_style_1.briefStyleRequested)(userTexts(messages), (0, reply_style_1.savedCommunicationPreference)(options?.toolContext?.context?.friend))) {
1990
+ brevityError = (0, reply_style_1.briefStyleViolation)(deliveredAnswer, intent);
1991
+ if (brevityError) {
1992
+ brevityRejections += 1;
1993
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.answer_gate_rejected", message: "settle answer broke the person's brevity request; sending the model back", meta: { gate: "brevity", rejection: brevityRejections, cap: ANSWER_GATE_MAX_REJECTIONS } });
1994
+ }
1995
+ }
1996
+ const answerGateError = groundingError ?? brevityError;
1960
1997
  const retryError = contentMismatch
1998
+ ?? answerGateError
1961
1999
  ?? privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
1962
2000
  ?? privateReturnMissingPonderError({
1963
2001
  latestUserRequest: latestUserMessageText(messages),
@@ -1967,10 +2005,13 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1967
2005
  ?? getSettleRetryError(mustResolveBeforeHandoffActive, intent, sawSteeringFollowUp, options?.delegationDecision, sawSendMessageSelf, sawPonder, sawQuerySession, options?.currentObligation ?? null, options?.activeWorkFrame?.inner?.job, sawExternalStateQuery);
1968
2006
  const validDirectReply = mustResolveBeforeHandoffActive && intent === "direct_reply" && sawSteeringFollowUp;
1969
2007
  if (retryError === null) {
2008
+ const finalAnswer = unverifiedNames ? (0, source_grounding_1.withUnverifiedDisclosure)(deliveredAnswer, unverifiedNames) : deliveredAnswer;
1970
2009
  try {
1971
- if (!result.settleStreamed) {
2010
+ if (unverifiedNames && result.settleStreamed)
2011
+ callbacks.onClearText?.();
2012
+ if (unverifiedNames || !result.settleStreamed) {
1972
2013
  const acceptedOutputCallbacks = streamCallbackBuffer?.callbacks ?? callbacks;
1973
- acceptedOutputCallbacks.onTextChunk(deliveredAnswer);
2014
+ acceptedOutputCallbacks.onTextChunk(finalAnswer);
1974
2015
  }
1975
2016
  await streamCallbackBuffer?.flush();
1976
2017
  }
@@ -1982,7 +2023,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1982
2023
  }
1983
2024
  callbacks.onToolEnd("settle", (0, tools_1.summarizeArgs)("settle", settleArgs, toolSelection), true);
1984
2025
  completion = {
1985
- answer: deliveredAnswer,
2026
+ answer: finalAnswer,
1986
2027
  intent: validDirectReply ? "direct_reply" : intent === "blocked" ? "blocked" : "complete",
1987
2028
  };
1988
2029
  // Retractable owners already hold the validated answer. Final-only
@@ -2459,6 +2500,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2459
2500
  }
2460
2501
  if (invoked && requiredToolCallNames.includes(tc.name) && !options?.requiredToolCalls?.requireSuccessfulResults)
2461
2502
  dispatchedRequiredToolCalls.add(tc.name);
2503
+ turnToolRecords.push({ name: tc.name, args, result: toolResult });
2462
2504
  const modelResult = (0, tool_friction_1.rewriteToolResultForModel)(tc.name, toolResult, toolFrictionLedger);
2463
2505
  pushGenerated({ role: "tool", tool_call_id: tc.id, content: modelResult });
2464
2506
  providerRuntime.appendToolOutput(tc.id, modelResult);
@@ -10,12 +10,15 @@ const EXPECTED_BINDS = [
10
10
  "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro:/home/ouro/AgentBundles/sanctuary.ouro:rw",
11
11
  "/boot/config/custom/ouro-events/spool:/run/ouro-events:ro",
12
12
  "/run/ouro-authority:/run/ouro-authority:ro",
13
+ // The psyche folder, read-only inside the Butler: a read-only mount point cannot be renamed or removed from inside the container.
14
+ "/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro",
13
15
  ];
14
16
  const EXPECTED_MOUNTS = [
15
17
  ["/mnt/user/appdata/ouro-butler/runtime/.ouro-cli", "/home/ouro/.ouro-cli", true, "rprivate"],
16
18
  ["/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro", "/home/ouro/AgentBundles/sanctuary.ouro", true, "rprivate"],
17
19
  ["/boot/config/custom/ouro-events/spool", "/run/ouro-events", false, "rprivate"],
18
20
  ["/run/ouro-authority", "/run/ouro-authority", false, "rprivate"],
21
+ ["/mnt/user/appdata/ouro-butler/agent/sanctuary.ouro/psyche", "/home/ouro/AgentBundles/sanctuary.ouro/psyche", false, "rprivate"],
19
22
  ];
20
23
  const PRE_GATEWAY_BINDS = EXPECTED_BINDS.slice(0, 3);
21
24
  const PRE_GATEWAY_MOUNTS = EXPECTED_MOUNTS.slice(0, 3);
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
60
60
  if (!value || typeof value !== "object" || Array.isArray(value))
61
61
  return false;
62
62
  const boundaries = value;
63
- const versions = { "sanctuary-owner": 14, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 15, "sanctuary-household": 5, "sanctuary-event": 4 };
64
64
  if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
65
65
  return false;
66
66
  const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
@@ -509,7 +509,7 @@ function validateSanctuaryUnit16EvidenceAssertions(label, raw) {
509
509
  integer(value.auditLifecyclePairCount, `${label} auditLifecyclePairCount`, 1);
510
510
  if (text(value.containerUser, `${label} containerUser`) !== "10001:10001" || text(value.liveProcessUser, `${label} liveProcessUser`) !== "10001:10001" || text(value.networkMode, `${label} networkMode`) !== "host")
511
511
  throw new Error(`${label} container identity or network is invalid`);
512
- requiredInteger(value, "mountCount", 4, label);
512
+ requiredInteger(value, "mountCount", 5, label);
513
513
  requiredInteger(value, "typedWriteExecutorCount", 1, label);
514
514
  allTrue(["writeApprovalPolicyExact", "mountsExact", "securityExact", "updaterDisabled"]);
515
515
  break;
@@ -132,7 +132,7 @@ function exactContainmentAudit(evidence) {
132
132
  && (0, sanctuary_acceptance_harness_1.exactSanctuaryContainmentProfileBoundaries)(evidence.profileBoundaries)
133
133
  && evidence.auditPathDigest === (0, node_crypto_1.createHash)("sha256").update(CONTAINMENT_AUDIT_PATH).digest("hex")
134
134
  && evidence.auditRecordCount >= 2 && evidence.auditLifecyclePairCount >= 1
135
- && evidence.containerUser === "10001:10001" && evidence.liveProcessUser === "10001:10001" && evidence.mountCount === 4 && evidence.publishedPortCount === 0
135
+ && evidence.containerUser === "10001:10001" && evidence.liveProcessUser === "10001:10001" && evidence.mountCount === 5 && evidence.publishedPortCount === 0
136
136
  && evidence.networkMode === "host" && evidence.readOnlyRoot === false && evidence.mountsExact && evidence.securityExact && evidence.updaterDisabled
137
137
  && !evidence.writableKeyExposure && evidence.rawWriteMaterialFieldCount === 0 && evidence.typedWriteExecutorCount === 1
138
138
  && evidence.writeApprovalPolicyExact && !evidence.sensitiveMaterialObserved
@@ -972,7 +972,8 @@ class SanctuaryAuthorityRootLifecycle {
972
972
  this.#docker(["create", "--name", "ouro-butler", "--network", "host", "--restart", "unless-stopped", "--user", "10001:10001",
973
973
  "-l", "net.unraid.docker.managed=dockerman", "-l", `net.unraid.docker.icon=${ICON}`, "-l", "org.opencontainers.image.source=https://github.com/ourostack/ouroboros",
974
974
  "-v", `${RUNTIME}:/home/ouro/.ouro-cli:rw`, "-v", `${BUNDLE}:/home/ouro/AgentBundles/sanctuary.ouro:rw`,
975
- "-v", `${EVENTS}:/run/ouro-events:ro`, "-v", `${SOCKET}:${SOCKET}:ro`, reference]);
975
+ "-v", `${EVENTS}:/run/ouro-events:ro`, "-v", `${SOCKET}:${SOCKET}:ro`,
976
+ "-v", `${BUNDLE}/psyche:/home/ouro/AgentBundles/sanctuary.ouro/psyche:ro`, reference]);
976
977
  const resident = this.#containers().find((container) => container.Name === "/ouro-butler");
977
978
  if (resident?.Image !== imageId || resident.State.Running)
978
979
  throw new Error("Sanctuary resident recreation readback failed");
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BRIEF_MAX_CHARS = void 0;
4
+ exports.briefStyleRequested = briefStyleRequested;
5
+ exports.briefStyleViolation = briefStyleViolation;
6
+ exports.savedCommunicationPreference = savedCommunicationPreference;
7
+ /**
8
+ * An owner-stated brevity preference, enforced where the answer is delivered rather than left to prose.
9
+ *
10
+ * "Be brief, no sections" is a request about how every later reply should look. A model that agrees in one turn and
11
+ * writes five bold-labelled paragraphs in the next has not kept it, so the runtime reads the request from what the
12
+ * person said (or a saved communication preference) and holds the answer to it at settle.
13
+ */
14
+ const runtime_1 = require("../nerves/runtime");
15
+ exports.BRIEF_MAX_CHARS = 600;
16
+ const ASK_BRIEF = /\b(?:be|keep(?:\s+(?:it|things|replies|answers|your\s+(?:replies|answers|messages)))?|stay)\s+(?:more\s+)?(?:brief|briefer|concise|terse|short|shorter|tight|compact)\b|\bbrevity\b|\bno\s+sections\b|\bshort\s+(?:answers|replies|messages)\b/i;
17
+ const ASK_LONGER = /\b(?:be\s+(?:more\s+)?(?:detailed|thorough|verbose|longer)|(?:more|full)\s+detail|elaborate|longer\s+(?:answers|replies)|you\s+can\s+be\s+longer|in\s+depth)\b/i;
18
+ const NEGATED_BRIEF = /\b(?:don['’]?t|do\s+not|stop\s+being|no\s+more|not\s+so|less|never)\s+(?:be\s+|being\s+|so\s+|that\s+)*(?:brief|concise|terse|short|brevity|tight)\b/i;
19
+ /** A reply that asks for a confirmation or a choice the person must make: the question is the point, so brevity does not apply. */
20
+ const REQUIRED_QUESTION = /\b(?:confirm|are\s+you\s+sure|did\s+you\s+mean|which\s+(?:one|of|do\s+you|did\s+you)|do\s+you\s+want\s+me\s+to\s+(?:apply|delete|remove|change|proceed|overwrite)|should\s+i\s+(?:apply|proceed|go\s+ahead|delete|remove|change|overwrite)|before\s+i\s+(?:apply|delete|remove|change|proceed))\b/i;
21
+ /** True when the latest word from the person (oldest to newest) asks for brevity; a saved preference counts as the oldest word. */
22
+ function briefStyleRequested(userTexts, savedPreference) {
23
+ let brief = false;
24
+ for (const text of [...(savedPreference ? [savedPreference] : []), ...userTexts]) {
25
+ if (NEGATED_BRIEF.test(text) || ASK_LONGER.test(text))
26
+ brief = false;
27
+ else if (ASK_BRIEF.test(text))
28
+ brief = true;
29
+ }
30
+ return brief;
31
+ }
32
+ const HEADER = /^#{1,6}\s/m;
33
+ const BOLD_LABEL = /(?:^|\n)\s*(?:[-*•]\s+)?\*\*[^*\n]{1,60}\*\*[ \t]*(?::|—|-|\n|$)|\*\*[^*\n]{1,60}:\*\*/;
34
+ const SENTENCE_END = /[.!?]+(?:\s+|$)/;
35
+ /** What is wrong with an answer that should be brief, or null when it is fine. */
36
+ function briefStyleViolation(answer, intent) {
37
+ // A blocked reply must say what is blocking, and a reply that asks for a confirmation or a choice must ask it.
38
+ if (intent === "blocked" || REQUIRED_QUESTION.test(answer))
39
+ return null;
40
+ const problems = [];
41
+ if (HEADER.test(answer) || BOLD_LABEL.test(answer))
42
+ problems.push("no markdown headers or bold section labels, just plain sentences");
43
+ if (answer.length > exports.BRIEF_MAX_CHARS)
44
+ problems.push(`about ${exports.BRIEF_MAX_CHARS} characters at most (this one is ${answer.length})`);
45
+ const sentences = answer.split(SENTENCE_END).filter((part) => part.trim());
46
+ if (sentences.length > 1 && answer.trimEnd().endsWith("?"))
47
+ problems.push("no closing question tacked onto an answer; answer and stop");
48
+ if (problems.length > 0)
49
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.brief_style_violation", message: "a reply broke the person's brevity request", meta: { problems: problems.length, length: answer.length } });
50
+ return problems.length === 0 ? null : `the person asked you to be brief. rewrite the reply: ${problems.join("; ")}. say the answer once, in your own voice, keep every warning, uncertainty and required confirmation, cut everything else, and settle again.`;
51
+ }
52
+ /** The person's saved communication preference, from their relationship policy or their older tool preferences. */
53
+ function savedCommunicationPreference(friend) {
54
+ const value = friend?.relationshipPolicy?.preferences.communication?.value ?? friend?.toolPreferences?.communication;
55
+ return value === undefined ? undefined : String(value);
56
+ }