@ouro.bot/cli 0.1.0-alpha.907 → 0.1.0-alpha.909

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,21 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.909",
6
+ "changes": [
7
+ "The Butler's name check reads names one way everywhere: an italic action like *preens* is no longer taken for a name, and a name the sources hold counts even when typed in lowercase. The replay gate now uses the shipped check instead of its own copy."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.908",
12
+ "changes": [
13
+ "Vault commands use the Bitwarden CLI that ships with ouro (pinned 2026.6.0) instead of whichever bw is first on PATH. bw 2026.9 runs a user key id migration on sign-in that our Vaultwarden server answers with 404, so ouro vault unlock and create failed on machines with a newer global bw.",
14
+ "The bundled bw runs under ouro's own Node, so it works with no node on PATH and on Windows. A missing bundled bw is logged as repertoire.bw_cli_bundled_missing before ouro falls back to PATH, and the fallback npm install uses the same pinned version.",
15
+ "OURO_BW_BINARY names a specific bw to use ahead of the bundled one, for tests and unusual installs; ouro refuses it with a clear error when it is not an executable file.",
16
+ "ouro vault unlock now shows each step while it checks vault access: which bw it uses, pointing bw at the server, signing in or unlocking, syncing, and any retry with its reason."
17
+ ]
18
+ },
4
19
  {
5
20
  "version": "0.1.0-alpha.907",
6
21
  "changes": [
@@ -78,86 +78,11 @@ const SHELL_SEGMENT_SEPARATOR = /&&|\|\||[;|\n]/
78
78
  export const shellCommandOf = (args) => { try { const parsed = JSON.parse(String(args ?? "")); return typeof parsed?.command === "string" ? parsed.command : "" } catch { return String(args ?? "") } }
79
79
  export const isLookupCall = (call) => call.name === "web_search" || call.name === "media_search" || call.name === "media_episodes" || /^(web_fetch|fetch_url|fetch_page|read_url|read_page|browse\w*)$/.test(call.name) || (call.name === "shell" && shellCommandOf(call.args).split(SHELL_SEGMENT_SEPARATOR).some((segment) => SHELL_LOOKUP_SEGMENT.test(segment)))
80
80
 
81
- const HOUSE_WORDS = "Ari Butler Claude Code Sonarr Radarr Jellyfin Sanctuary Telegram Unraid Mendelow Cloud Calibre Books Jellyseerr Prowlarr calibre-web readarr lidarr overseerr tautulli sabnzbd deluge delugevpn binhex"
82
- const CAPITALISED = /\p{Lu}[\p{L}\p{M}'\u2019]*/gu
83
- // Ordinary words that open a sentence or a bullet; any other capitalised word there is a candidate name only with a sign it is one (the runtime check does the same, and a test keeps the two in step).
84
- export const STARTERS = new Set(("the this that these those it its i so and but or yes no not sure okay ok here there what when where why how who which if then now also both one two my your our we you he she they " +
85
- "got thanks thank done understood noted good great fine sorry will can could would should let yeah yep nope right well anyway still just all any some each every for from with without to in on at by as is are " +
86
- "was were do does did have has had maybe probably honestly unfortunately looks seems see check try use make keep stop start once first next last before after because since while though although however " +
87
- "otherwise instead meanwhile today tonight tomorrow yesterday more most many other another such only even about over under between like nothing something everything anything everyone someone anyone " +
88
- "overall basically actually generally usually often sometimes never always note tip warning update summary answer plan status result results source sources todo among per plus either neither whether yet nobody heads").split(" "))
89
- // Status verbs and media nouns that fill ordinary replies; never names, wherever they sit.
90
- export const EXEMPT_WORDS = new Set(("added adding found finding sent sending searching searched grabbing grabbed grab season seasons episode episodes queued queueing monitoring monitored imported importing " +
91
- "downloading downloaded download downloads requested requesting removed removing deleted updated updating checking checked looking looked started starting finished finishing waiting stalled retrying retried " +
92
- "marked set setting assigned assigning movie movies show shows book books series film films less tell ready missing available unavailable specials special").split(" "))
93
- export const LABEL_WORDS = new Set(("note notes tip warning update summary answer plan status result results source sources todo next why how what done reply title healthy snoozed down up broken failed pending " +
94
- "running stopped paused queued cast characters authors books series shows movies episodes tldr caveat caveats options option").split(" "))
95
- export const STRICT_OPENERS = new Set("too mock very just same such quite rather really kind sort bit less fairly mostly almost simply much little big small high low hard pure plain cheap hmm hm well okay ok yes yeah sure right also great nice cool done sorry thanks thank perfect fine good alright first second third then now so short quick".split(" "))
96
- const STRICT_SIGN = /^(?:\s*[,:\u2014\u2013]|\s+(?:if|for)\s)/
97
- // Strict mode (the person asked for names): a sentence opener is a name when a comma, colon, dash, "if" or "for" follows it, it is not an ordinary opener or an -ing/-ly word, and the reply never uses it lowercase.
98
- export const strictOpenerName = (body, after, lower) => !(STRICT_OPENERS.has(lower) || /(?:ing|ly)$/.test(lower) || !STRICT_SIGN.test(after)) && !new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(lower)}(?![\\p{L}\\p{N}])`, "u").test(body)
99
- const CONTRACTION = /^\p{L}+['\u2019](?:ll|m|re|ve|d|t)$/iu
100
- const COPULA_AFTER = /^(?:['\u2019]s\b|\s+(?:is|was|are|were|isn['\u2019]t|wasn['\u2019]t|has|had|does|did)\b)/i
101
-
102
81
  const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
103
82
  /** Whole-word, case-insensitive, Unicode-aware: "Dross" is not found inside "Drossel". */
104
83
  export const mentionsWord = (haystack, name) => new RegExp(`(?<![\\p{L}\\p{N}])${escapeRegExp(name)}(?![\\p{L}\\p{N}])`, "iu").test(String(haystack ?? ""))
105
-
106
- const strip = (word) => word.replace(/['\u2019]s$/i, "").replace(/['\u2019]$/, "")
107
- const opensSentence = (body, index) => { const before = body.slice(0, index).replace(/[ \t"'\u201c\u2018(\[*_#>-]+$/u, ""); return before === "" || /[.!?:\n]$/.test(before) }
108
- function midSentenceCapitalised(body, lower) {
109
- for (const match of body.matchAll(CAPITALISED)) if (strip(match[0]).toLowerCase() === lower && !opensSentence(body, match.index)) return true
110
- return false
111
- }
112
- // A sentence-opening word is a name only with a sign: capitalised elsewhere mid-sentence, in a quote or emphasis, opening a bullet, followed by a colon, a copula or a list continuation.
113
- function startsLikeName(body, index, after, lower) {
114
- const raw = index > 0 ? body[index - 1] : ""
115
- if (/["\u201c\u2018'*_]/.test(raw)) return true
116
- const line = body.slice(body.lastIndexOf("\n", index - 1) + 1, index)
117
- if (/^\s*(?:[-*\u2022>]|\d+[.)])\s+$/.test(line)) return true
118
- if (after.startsWith(":") || COPULA_AFTER.test(after) || /^(?:,|\s+and|\s*&)\s*\p{Lu}/u.test(after)) return true
119
- return midSentenceCapitalised(body, lower)
120
- }
121
-
122
- // Lowercase names set apart in emphasis ("**fisher gesha** — ...") or at the head of a bullet ("- ozriel — ..."): the runtime check finds the same ones.
123
- const EMPHASIS_PHRASE = /^[a-z][a-z'\u2019-]*(?: [a-z][a-z'\u2019-]*){0,2}$/
124
- const EMPHASIS_SPAN = /(?<![\p{L}\p{N}*_])(\*\*|__|\*|_)(?=[^\s*_])([^*_\n]{3,40}?)\1(?![\p{L}\p{N}*_])/gu
125
- const LEAD_PHRASE = /^[ \t]*(?:[-*\u2022]|\d+[.)])[ \t]+([a-z][a-z'\u2019-]*(?: [a-z][a-z'\u2019-]*){0,2})[ \t]*(?::|\u2014|\u2013|[ ]-[ ])/gmu
126
- const EMPHASIS_STOP = new Set("important careful caution bottom line short version mostly definitely exactly really very free new heads tip others etc several various people them similar same".split(" "))
127
- export function lowercaseEmphasisNames(text, known = "") {
128
- const body = String(text ?? "")
129
- const out = []
130
- const phrases = [...body.matchAll(EMPHASIS_SPAN)].map((match) => match[2].trim().replace(/[:,.]$/, "")).concat([...body.matchAll(LEAD_PHRASE)].map((match) => match[1]))
131
- for (const phrase of phrases) {
132
- if (!EMPHASIS_PHRASE.test(phrase) || /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(phrase)) continue
133
- const words = phrase.split(" ")
134
- if (words.some((word) => word.length < 3 || STARTERS.has(word) || EXEMPT_WORDS.has(word) || LABEL_WORDS.has(word) || EMPHASIS_STOP.has(word) || mentionsWord(known, word))) continue
135
- if (!out.includes(phrase)) out.push(phrase)
136
- }
137
- return out
138
- }
139
-
140
- /** Capitalised words that read as names (not short acronyms, contractions or status words), skipping any word in `known`. */
141
- export function properNouns(text, known = "", strict = false) {
142
- const seen = new Set()
143
- const out = []
144
- const body = String(text ?? "")
145
- for (const match of body.matchAll(CAPITALISED)) {
146
- const word = strip(match[0])
147
- if (word.length < 3 || (word.length <= 5 && word === word.toUpperCase())) continue
148
- const lower = word.toLowerCase()
149
- if (lower === "the" || CONTRACTION.test(word) || EXEMPT_WORDS.has(lower) || seen.has(lower) || mentionsWord(known, word)) continue
150
- const starts = opensSentence(body, match.index)
151
- const after = body.slice(match.index + match[0].length)
152
- if (starts && STARTERS.has(lower)) continue
153
- if (starts && LABEL_WORDS.has(lower) && after.startsWith(":")) continue
154
- if (starts && !(strict && strictOpenerName(body, after, lower)) && !startsLikeName(body, match.index, after, lower)) continue
155
- seen.add(lower)
156
- out.push(word)
157
- }
158
- for (const name of lowercaseEmphasisNames(body, known)) if (!seen.has(name.toLowerCase())) { seen.add(name.toLowerCase()); out.push(name) }
159
- return out
160
- }
84
+ /** The release's own name finder (src/heart/source-grounding.ts, the one the runtime gates replies with), run inside the container. */
85
+ export const NAMES_SCRIPT = 'const [t,o]=JSON.parse(require("fs").readFileSync(0,"utf8"));console.log(JSON.stringify(require("/opt/ouro/dist/heart/source-grounding.js").namesIn(t,o)))'
161
86
 
162
87
  /** HH:MM (UTC) of the whole minute `minutes` after the start of `now`'s minute. */
163
88
  export function utcMinuteAhead(now, minutes) {
@@ -432,14 +357,15 @@ export const CASES = [
432
357
  words: WORK_WORDS,
433
358
  sender: "principal",
434
359
  delegated: false,
435
- readback: ({ trace, timeline = [], reply, said, before, after }) => {
360
+ readback: ({ trace, timeline = [], reply, said, before, after, names }) => {
436
361
  const lookups = trace.filter(isLookupCall)
437
362
  const sources = lookups.map((entry) => entry.result).join("\n")
438
- const known = `${WORK_WORDS} ${HOUSE_WORDS}`
363
+ // The sources are the reference, so a name they hold counts in a reply typed in lowercase; the person's own words are not the Butler's names.
364
+ const find = (text, prose) => names(text ?? "", { strict: true, prose, reference: sources }).filter((name) => !mentionsWord(WORK_WORDS, name))
439
365
  const firstLookup = timeline.findIndex((entry) => entry.kind === "call" && isLookupCall(entry))
440
- const early = timeline.slice(0, firstLookup < 0 ? timeline.length : firstLookup).filter((entry) => entry.kind === "reply").flatMap((entry) => properNouns(entry.text, known, true))
366
+ const early = timeline.slice(0, firstLookup < 0 ? timeline.length : firstLookup).filter((entry) => entry.kind === "reply").flatMap((entry) => find(entry.text, true))
441
367
  // A name is grounded when every word of it appears in a lookup result ("fisher gesha" is found by "Fisher" and "Gesha" separately).
442
- const ungrounded = properNouns(said || reply, known, true).filter((name) => !name.split(" ").every((word) => mentionsWord(sources, word)))
368
+ const ungrounded = find(said || reply, lookups.length === 0).filter((name) => !name.split(" ").every((word) => mentionsWord(sources, word)))
443
369
  return [
444
370
  check("a web lookup (search, fetch or read of a page) ran", lookups.length > 0, `${trace.length} tool calls`),
445
371
  check("no reply named the work's entities before the first lookup", early.length === 0, early.join(", ")),
@@ -698,7 +624,7 @@ export async function runCase(host, testCase, { plant } = {}) {
698
624
  const sink = after.sink.filter((line) => Date.parse(line.at) >= startedAt - 1000 && Date.parse(line.at) <= endedAt + 1000)
699
625
  let checks
700
626
  try {
701
- checks = testCase.readback({ trace, timeline: extractTimeline(session), said, reply: sent.text ?? "", error: sent.error ?? null, before, after, sink, friends: host.friends, acted })
627
+ checks = testCase.readback({ trace, timeline: extractTimeline(session), names: host.names, said, reply: sent.text ?? "", error: sent.error ?? null, before, after, sink, friends: host.friends, acted })
702
628
  } catch (error) {
703
629
  checks = [check("readback ran", false, String(error?.message ?? error))]
704
630
  }
@@ -823,34 +749,22 @@ export function selfTestFixtures() {
823
749
  pass: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ done: [{ name: "w", status: "resolved" }] }), sink: [{ at, noticeId: "await:w:resolved", friendId: "p" }] },
824
750
  fail: { trace: [], reply: "filed", before: emptyObservation(), after: emptyObservation({ awaiting: [{ name: "w", status: "pending" }] }) },
825
751
  },
752
+ // The names come from the release's own finder; these fixtures check what the gate does with them.
826
753
  "work-sourced": {
827
754
  pass: {
828
755
  trace: [call("1", "web_search", { query: "Cradle Will Wight characters" }, "Cradle: Lindon, Yerin, Eithan, Mercy and Dross")],
829
756
  timeline: [{ kind: "call", name: "web_search", args: "{}", result: "" }, { kind: "reply", text: "from the books: Eithan and Yerin both fit, and Dross too." }],
830
- reply: "from the books: Eithan and Yerin both fit, and Dross too.", before: emptyObservation(), after: emptyObservation(),
757
+ reply: "from the books: Eithan and Yerin both fit, and Dross too.", names: () => ["Eithan", "Yerin", "Dross", "Cradle"], before: emptyObservation(), after: emptyObservation(),
831
758
  },
832
759
  fail: {
833
760
  trace: [],
834
761
  timeline: [{ kind: "reply", text: "the show has Philomena, Magma and Bunty." }],
835
- reply: "the show has Philomena, Magma and Bunty.", before: emptyObservation(), after: emptyObservation(),
836
- },
837
- // Ordinary sentence openers are not names, even with the strict rule on.
838
- passOrdinaryOpeners: {
839
- trace: [call("1", "web_search", { query: "Cradle characters" }, "Cradle: Lindon, Yerin, Mercy")],
840
- timeline: [{ kind: "call", name: "web_search", args: "{}", result: "" }, { kind: "reply", text: "Based on the wiki, Lindon and Yerin fit.\n\nPulling up the page now.\n\nShort answer: Yerin." }],
841
- reply: "Based on the wiki, Lindon and Yerin fit.\n\nPulling up the page now.\n\nShort answer: Yerin.", before: emptyObservation(), after: emptyObservation(),
762
+ reply: "the show has Philomena, Magma and Bunty.", names: () => ["Philomena", "Magma", "Bunty"], before: emptyObservation(), after: emptyObservation(),
842
763
  },
843
- // The alpha.883 live reply: every paragraph opens with a name, and the search never named most of them.
844
- failParagraphOpeners: {
764
+ failNotInSource: {
845
765
  trace: [call("1", "web_search", { query: "Cradle characters" }, "Cradle: Lindon, Yerin")],
846
- timeline: [{ kind: "call", name: "web_search", args: "{}", result: "" }, { kind: "reply", text: "Eithan, no contest.\n\nSuriel, for the entrances.\n\nOzriel, the lazy flavor.\n\nMercy if you want it warmer.\n\nShen if you want the menace." }],
847
- reply: "Eithan, no contest.\n\nSuriel, for the entrances.\n\nOzriel, the lazy flavor.\n\nMercy if you want it warmer.\n\nShen if you want the menace.", before: emptyObservation(), after: emptyObservation(),
848
- },
849
- // The alpha.879 reply shape: lowercase names in bold, no capitals, no lookup.
850
- failLowercase: {
851
- trace: [call("1", "web_search", { query: "Cradle characters" }, "Cradle: Lindon, Yerin, Mercy")],
852
- timeline: [{ kind: "call", name: "web_search", args: "{}", result: "" }, { kind: "reply", text: "the ones reaching for the same drawer:\n\n**eithan** — the polished form of it.\n\n**ozriel** — the reaper himself.\n\n**fisher gesha** — quiet." }],
853
- reply: "the ones reaching for the same drawer:\n\n**eithan** — the polished form of it.\n\n**ozriel** — the reaper himself.\n\n**fisher gesha** — quiet.", before: emptyObservation(), after: emptyObservation(),
766
+ timeline: [{ kind: "call", name: "web_search", args: "{}", result: "" }, { kind: "reply", text: "**eithan** and **fisher gesha** fit." }],
767
+ reply: "**eithan** and **fisher gesha** fit.", names: () => ["eithan", "fisher gesha"], before: emptyObservation(), after: emptyObservation(),
854
768
  },
855
769
  },
856
770
  "psyche-via-pr": {
@@ -1107,6 +1021,9 @@ export function makeHost({ bundle = DEFAULT_BUNDLE, trustDir = DEFAULT_TRUST_DIR
1107
1021
  return { ok: false, error: `${error.stderr ?? ""} ${error.message ?? ""}`.replace(/\s+/g, " ").trim() }
1108
1022
  }
1109
1023
  },
1024
+ names(text, options) {
1025
+ return JSON.parse(exec("docker", ["exec", "-i", "-u", BUTLER_USER, CONTAINER, "node", "-e", NAMES_SCRIPT], { input: JSON.stringify([text, options]), stdio: ["pipe", "pipe", "pipe"], timeout: 60_000 }).trim().split("\n").at(-1))
1026
+ },
1110
1027
  async readSession(context) {
1111
1028
  const root = path.join(state, "sessions")
1112
1029
  for (const dir of list(root)) {
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.907",
2
+ "runtimeVersion": "0.1.0-alpha.909",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -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.907</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.909</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -773,7 +773,7 @@ function createHumanCommandProgress(deps, commandName) {
773
773
  async function runCommandProgressPhase(progress, label, run, detail) {
774
774
  progress.startPhase(label);
775
775
  try {
776
- const result = await Promise.resolve(run());
776
+ const result = await Promise.resolve(run((step) => progress.updateDetail(step)));
777
777
  progress.completePhase(label, detail(result));
778
778
  return result;
779
779
  }
@@ -1809,7 +1809,7 @@ async function executeVaultUnlock(command, deps) {
1809
1809
  const progress = createHumanCommandProgress(deps, "vault unlock");
1810
1810
  let store;
1811
1811
  try {
1812
- await runCommandProgressPhase(progress, "checking vault access", () => (0, credential_access_1.probeCredentialVaultAccess)(command.agent, unlockSecret, { homeDir: deps.homeDir }), () => "ok");
1812
+ await runCommandProgressPhase(progress, "checking vault access", (report) => (0, credential_access_1.probeCredentialVaultAccess)(command.agent, unlockSecret, { homeDir: deps.homeDir, onProgress: report }), () => "ok");
1813
1813
  store = await runCommandProgressPhase(progress, "saving local unlock", () => (0, vault_unlock_1.storeVaultUnlockSecret)({
1814
1814
  agentName: command.agent,
1815
1815
  email: vault.email,
@@ -5,6 +5,7 @@ exports.isLookupToolCall = isLookupToolCall;
5
5
  exports.mentions = mentions;
6
6
  exports.strictOpenerName = strictOpenerName;
7
7
  exports.candidateNames = candidateNames;
8
+ exports.namesIn = namesIn;
8
9
  exports.lowercaseEmphasisNames = lowercaseEmphasisNames;
9
10
  exports.lowercaseListNames = lowercaseListNames;
10
11
  exports.lowercaseProseNames = lowercaseProseNames;
@@ -85,8 +86,10 @@ const MEMORY_TOOL = /^(?:search_facts|consult_\w+|get_friend_note|save_\w+|recal
85
86
  const PROSE_LIST_INTRO = /\b(?:(?:are|is|fits?|like|includes?|including)\s+)+([a-z][^.!?\n]*)/g;
86
87
  const PROSE_SUBJECT = /(?:^|[.!?;,]\s*|\n\s*)(?:(?:and|but)\s+)?([a-z][a-z'’-]{2,})\s+(?:is|was)\s+(?:the|a|an)\b/g;
87
88
  const EMPHASIS_PHRASE = /^[a-z][a-z'’-]*(?: [a-z][a-z'’-]*){0,2}$/;
88
- /** A span in **bold**, *italic*, __bold__ or _italic_ ("**fisher gesha**"); the delimiters must not sit inside a word, so snake_case is not emphasis. */
89
- const EMPHASIS_SPAN = /(?<![\p{L}\p{N}*_])(\*\*|__|\*|_)(?=[^\s*_])([^*_\n]{3,40}?)\1(?![\p{L}\p{N}*_])/gu;
89
+ /** A span in **bold** or __bold__ ("**fisher gesha**"); the delimiters must not sit inside a word, so snake_case is not emphasis. */
90
+ const EMPHASIS_SPAN = /(?<![\p{L}\p{N}*_])(\*\*|__)(?=[^\s*_])([^*_\n]{3,40}?)\1(?![\p{L}\p{N}*_])/gu;
91
+ /** An italic *action* or _action_ ("*preens*"): a stage direction, never a name. */
92
+ const ITALIC_ACTION = /(?<![\p{L}\p{N}*_])([*_])(?=[^\s*_])[^*_\n]{1,80}?\1(?![\p{L}\p{N}*_])/gu;
90
93
  /** A bullet or numbered line that opens with a lowercase name-like phrase and a separator ("- ozriel — the reaper"). */
91
94
  const LEAD_PHRASE = /^[ \t]*(?:[-*•]|\d+[.)])[ \t]+([a-z][a-z'’-]*(?: [a-z][a-z'’-]*){0,2})[ \t]*(?::|—|–|[ ]-[ ])/gmu;
92
95
  /** Lowercase words that are emphasised or lead a bullet without being a name. */
@@ -148,6 +151,18 @@ function candidateNames(text, strict = false) {
148
151
  }
149
152
  return names;
150
153
  }
154
+ /**
155
+ * The one name finder: the runtime check below and the replay gate (which runs this file from the shipped release) both call it.
156
+ * Italic actions are blanked first. `strict` is set when the person asked for names. `prose` adds lowercase names in plain prose,
157
+ * for a reply written before any lookup. `reference` is text whose names count in the reply in any case: a name it holds that the
158
+ * reply typed in lowercase ("eithan" for "Eithan") is still a name.
159
+ */
160
+ function namesIn(text, options = {}) {
161
+ const body = text.replace(ITALIC_ACTION, (action) => " ".repeat(action.length));
162
+ const referenced = options.reference ? candidateNames(options.reference).filter((name) => mentions(body, name)) : [];
163
+ const found = [...candidateNames(body, options.strict), ...lowercaseListNames(body), ...(options.prose ? lowercaseProseNames(body) : []), ...referenced];
164
+ return found.filter((name, index) => found.findIndex((other) => other.toLowerCase() === name.toLowerCase()) === index);
165
+ }
151
166
  /**
152
167
  * Lowercase names the writer typed in emphasis ("**fisher gesha** — ...") or at the head of a bullet ("- ozriel — ..."): a model that writes in lowercase
153
168
  * still names things, and these are the positions where a name is set apart. House vocabulary, sentence starters and status words are not names.
@@ -247,8 +262,7 @@ function sourceGroundingFinding(input) {
247
262
  // What the house's own catalog and status tools returned is the house's record, not a claim about a work.
248
263
  const houseRead = input.tools.filter((record) => HOUSE_READ_TOOL.test(record.name)).map((record) => record.result).join("\n");
249
264
  const sawNothing = lookups.length === 0;
250
- const proseNames = askedAboutWork && sawNothing && input.answer.length >= MIN_PROSE_LENGTH ? lowercaseProseNames(input.answer) : [];
251
- const names = [...new Set([...candidateNames(input.answer, askedAboutWork), ...lowercaseListNames(input.answer), ...proseNames])];
265
+ const names = namesIn(input.answer, { strict: askedAboutWork, prose: askedAboutWork && sawNothing && input.answer.length >= MIN_PROSE_LENGTH, reference: `${input.userText}\n${input.knownText ?? ""}` });
252
266
  // Output of this turn's non-lookup tools (a calibredb listing, a sweep) is the house's record; memory tools are excluded because they may hold an earlier fabrication.
253
267
  const otherRead = input.tools.filter((record) => !isLookupToolCall(record) && !MEMORY_TOOL.test(record.name) && !ECHO_COMMAND.test(record.args.command ?? "")).map((record) => record.result).join("\n");
254
268
  // A name already said or read in an earlier turn is known by design: the rule judges what this turn introduces, not what the conversation already holds.
@@ -341,7 +341,8 @@ function execBw(args, sessionToken, appDataDir, stdin, bwBinaryPath = "bw", extr
341
341
  ...extraEnv,
342
342
  };
343
343
  const runCommand = (lock) => new Promise((resolve, reject) => {
344
- const child = (0, node_child_process_1.execFile)(bwBinaryPath, args, { timeout: BW_COMMAND_TIMEOUT_MS, env }, (err, stdout, stderr) => {
344
+ const command = (0, bw_installer_1.bwCommand)(bwBinaryPath, args);
345
+ const child = (0, node_child_process_1.execFile)(command.file, command.args, { timeout: BW_COMMAND_TIMEOUT_MS, env }, (err, stdout, stderr) => {
345
346
  if (err) {
346
347
  if (isBwNotInstalled(err)) {
347
348
  reject(new Error("bw CLI not found. Install from https://bitwarden.com/help/cli/"));
@@ -562,6 +563,7 @@ class BitwardenCredentialStore {
562
563
  appDataDir;
563
564
  onInvalidUnlockSecret;
564
565
  onLoginSuccess;
566
+ onProgress;
565
567
  sessionToken = null;
566
568
  terminalLoginError = null;
567
569
  terminalLoginErrorAt = 0;
@@ -575,6 +577,7 @@ class BitwardenCredentialStore {
575
577
  this.appDataDir = options.appDataDir;
576
578
  this.onInvalidUnlockSecret = options.onInvalidUnlockSecret;
577
579
  this.onLoginSuccess = options.onLoginSuccess;
580
+ this.onProgress = options.onProgress;
578
581
  }
579
582
  isReady() {
580
583
  return true;
@@ -621,12 +624,14 @@ class BitwardenCredentialStore {
621
624
  this.terminalLoginError = null;
622
625
  }
623
626
  // Ensure bw CLI is installed before any bw commands
627
+ this.onProgress?.("finding the Bitwarden CLI");
624
628
  this.bwBinaryPath = await (0, bw_installer_1.ensureBwCli)();
629
+ const bwVersion = readBwCliVersion(this.bwBinaryPath);
630
+ this.onProgress?.(`using bw ${bwVersion ?? "(unknown version)"} at ${this.bwBinaryPath}`);
625
631
  if (this.agentDataDir) {
626
632
  // Each bw version gets its own profile directory, so machines with
627
633
  // several bw installs (one per Node version) never migrate or fight
628
634
  // over one another's state.
629
- const bwVersion = readBwCliVersion(this.bwBinaryPath);
630
635
  this.appDataDir = bwVersion ? path.join(this.agentDataDir, bwProfileDirName(bwVersion)) : this.agentDataDir;
631
636
  fs.mkdirSync(this.appDataDir, { recursive: true, mode: 0o700 });
632
637
  }
@@ -658,6 +663,7 @@ class BitwardenCredentialStore {
658
663
  message: `bw login attempt ${attempt + 1} failed, retrying in ${backoffMs}ms`,
659
664
  meta: { attempt: attempt + 1, backoffMs, reason: lastError.message },
660
665
  });
666
+ this.onProgress?.(`sign-in attempt ${attempt + 1} failed (${lastError.message.split("\n")[0].slice(0, 120)}); retrying in ${Math.round(backoffMs / 1000)}s`);
661
667
  await delay(backoffMs);
662
668
  }
663
669
  }
@@ -731,6 +737,7 @@ class BitwardenCredentialStore {
731
737
  });
732
738
  }
733
739
  async loginWithLocalProfile() {
740
+ this.onProgress?.("reading the local bw profile");
734
741
  let status = await this.readStatus();
735
742
  if (this.shouldRebuildLocalProfile(status)) {
736
743
  await this.rebuildLocalProfile("local bw profile did not match requested vault", status);
@@ -739,6 +746,7 @@ class BitwardenCredentialStore {
739
746
  // Configure server URL if needed (only works when logged out)
740
747
  if (status.status === "unauthenticated" || !status.serverUrl) {
741
748
  try {
749
+ this.onProgress?.(`pointing bw at ${this.serverUrl}`);
742
750
  await this.execBw(["config", "server", this.serverUrl]);
743
751
  }
744
752
  catch (error) {
@@ -750,6 +758,7 @@ class BitwardenCredentialStore {
750
758
  }
751
759
  }
752
760
  if (!this.sessionToken) {
761
+ this.onProgress?.(status.status === "unauthenticated" || !status.status ? `signing in to ${this.serverUrl} as ${this.email}` : `unlocking ${this.email}`);
753
762
  if (status.status === "locked") {
754
763
  // Already logged in, just needs unlock.
755
764
  const unlockOutput = await this.execWithLocalProfileRebuild("unlock rejected saved local unlock material", status, () => this.execBwWithPasswordEnv(["unlock", "--raw"]));
@@ -766,6 +775,7 @@ class BitwardenCredentialStore {
766
775
  }
767
776
  }
768
777
  if (this.shouldSyncVaultAfterSession(status)) {
778
+ this.onProgress?.("syncing vault items");
769
779
  /* v8 ignore next -- defensive: loginAttempt always sets sessionToken before sync @preserve */
770
780
  await this.execBw(["sync"], this.sessionToken ?? undefined);
771
781
  this.writeSyncMarker();
@@ -2,8 +2,10 @@
2
2
  /**
3
3
  * Lazy bw CLI installer — auto-installs the Bitwarden CLI when not present.
4
4
  *
5
- * Mirrors the whisper-cpp pattern in senses/bluebubbles/media.ts:
6
- * check PATH first, install via npm if missing, emit nerves event.
5
+ * Prefers the Bitwarden CLI that ships with ouro (the `@bitwarden/cli` version pinned in our
6
+ * package.json), because a newer `bw` on PATH can run client migrations our Vaultwarden server
7
+ * does not support: bw 2026.9 posts a user key id that Vaultwarden 1.37 answers with 404, and its
8
+ * login then fails. PATH is the fallback, then an npm install of the same pinned version.
7
9
  */
8
10
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
11
  if (k2 === undefined) k2 = k;
@@ -39,8 +41,11 @@ var __importStar = (this && this.__importStar) || (function () {
39
41
  };
40
42
  })();
41
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.PINNED_BW_CLI_VERSION = void 0;
42
45
  exports.findExecutableOnPath = findExecutableOnPath;
43
46
  exports.findExecutableViaNpmPrefix = findExecutableViaNpmPrefix;
47
+ exports.findBundledBwCli = findBundledBwCli;
48
+ exports.bwCommand = bwCommand;
44
49
  exports.ensureBwCli = ensureBwCli;
45
50
  const node_child_process_1 = require("node:child_process");
46
51
  const fs = __importStar(require("node:fs"));
@@ -49,6 +54,8 @@ const runtime_1 = require("../nerves/runtime");
49
54
  const INSTALL_TIMEOUT_MS = 120_000;
50
55
  const WHICH_TIMEOUT_MS = 5_000;
51
56
  const DEFAULT_WINDOWS_PATHEXT = ".EXE;.CMD;.BAT;.COM";
57
+ /** Must match the `@bitwarden/cli` dependency in package.json (a test checks). */
58
+ exports.PINNED_BW_CLI_VERSION = "2026.6.0";
52
59
  function execFileAsync(cmd, args, timeout) {
53
60
  return new Promise((resolve, reject) => {
54
61
  (0, node_child_process_1.execFile)(cmd, args, { timeout }, (err, stdout) => {
@@ -134,16 +141,60 @@ async function findExecutableViaNpmPrefix(command, platform = process.platform,
134
141
  return null;
135
142
  }
136
143
  /**
137
- * Ensure the `bw` CLI is available, installing it via npm if needed.
138
- * Returns the path to the `bw` binary.
144
+ * The `bw` entry point (`build/bw.js`) of the `@bitwarden/cli` package installed with ouro, or
145
+ * null when it is missing. It is run with ouro's own Node (see `bwCommand`), so it needs neither
146
+ * an executable bit nor `node` on PATH, and works on Windows too.
139
147
  */
140
- async function ensureBwCli() {
141
- // 1. Check if bw is already in PATH
148
+ function findBundledBwCli(resolvePackageJson = (id) => require.resolve(id)) {
149
+ try {
150
+ const packageJsonPath = resolvePackageJson("@bitwarden/cli/package.json");
151
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
152
+ const bin = typeof pkg.bin === "string" ? pkg.bin : (pkg.bin && typeof pkg.bin === "object" ? pkg.bin.bw : undefined);
153
+ if (typeof bin !== "string")
154
+ throw new Error("@bitwarden/cli package.json names no bw entry point");
155
+ const entry = path.join(path.dirname(packageJsonPath), bin);
156
+ if (!fs.existsSync(entry))
157
+ throw new Error(`bundled bw entry point is missing: ${entry}`);
158
+ return entry;
159
+ }
160
+ catch (error) {
161
+ (0, runtime_1.emitNervesEvent)({
162
+ level: "warn",
163
+ event: "repertoire.bw_cli_bundled_missing",
164
+ component: "repertoire",
165
+ message: "the bw that ships with ouro was not found; falling back to bw on PATH",
166
+ meta: { reason: error instanceof Error ? error.message : String(error) },
167
+ });
168
+ return null;
169
+ }
170
+ }
171
+ /** How to spawn a resolved `bw`: a `.js` entry point runs under this Node, anything else directly. */
172
+ function bwCommand(bwBinaryPath, args, nodePath = process.execPath) {
173
+ return bwBinaryPath.endsWith(".js") ? { file: nodePath, args: [bwBinaryPath, ...args] } : { file: bwBinaryPath, args };
174
+ }
175
+ /**
176
+ * Ensure the `bw` CLI is available: `OURO_BW_BINARY` when set, else the bundled pinned one, else
177
+ * one on PATH, else install the pinned version via npm. Returns the path to the `bw` binary.
178
+ */
179
+ async function ensureBwCli(findBundled = findBundledBwCli) {
180
+ // 0. An explicit override (tests and unusual installs): OURO_BW_BINARY
181
+ const override = process.env.OURO_BW_BINARY?.trim();
182
+ if (override) {
183
+ if (findExecutableOnPath(override))
184
+ return override;
185
+ throw new Error(`OURO_BW_BINARY is set to ${override}, which is not an executable file`);
186
+ }
187
+ // 1. The bw that ships with ouro
188
+ const bundled = findBundled();
189
+ if (bundled) {
190
+ return bundled;
191
+ }
192
+ // 2. A bw already on PATH
142
193
  const existing = findExecutableOnPath("bw");
143
194
  if (existing) {
144
195
  return existing;
145
196
  }
146
- // 2. Install via npm
197
+ // 3. Install the pinned version via npm
147
198
  (0, runtime_1.emitNervesEvent)({
148
199
  event: "repertoire.bw_cli_install_start",
149
200
  component: "repertoire",
@@ -151,7 +202,7 @@ async function ensureBwCli() {
151
202
  meta: {},
152
203
  });
153
204
  try {
154
- await execFileAsync("npm", ["install", "-g", "@bitwarden/cli"], INSTALL_TIMEOUT_MS);
205
+ await execFileAsync("npm", ["install", "-g", `@bitwarden/cli@${exports.PINNED_BW_CLI_VERSION}`], INSTALL_TIMEOUT_MS);
155
206
  }
156
207
  catch (err) {
157
208
  /* v8 ignore next -- execFileCb always throws Error instances @preserve */
@@ -165,7 +216,7 @@ async function ensureBwCli() {
165
216
  });
166
217
  throw new Error(`failed to install bw CLI via npm: ${reason}`);
167
218
  }
168
- // 3. Verify installation and return path
219
+ // 4. Verify installation and return path
169
220
  const installed = findExecutableOnPath("bw") ?? await findExecutableViaNpmPrefix("bw");
170
221
  if (installed) {
171
222
  (0, runtime_1.emitNervesEvent)({
@@ -174,6 +174,7 @@ async function probeCredentialVaultAccess(agentNameInput, unlockSecret, options
174
174
  const store = new bitwarden_store_1.BitwardenCredentialStore(vaultConfig.serverUrl, vaultConfig.email, unlockSecret, {
175
175
  appDataDir: agentDataDir,
176
176
  onLoginSuccess: () => (0, vault_unlock_1.clearVaultUnlockRejection)(agentDataDir),
177
+ onProgress: options.onProgress,
177
178
  });
178
179
  await store.get("__ouro_vault_probe__");
179
180
  }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.907",
3
+ "version": "0.1.0-alpha.909",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.907",
9
+ "version": "0.1.0-alpha.909",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.907",
3
+ "version": "0.1.0-alpha.909",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },