@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 +15 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +17 -100
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/cli-exec.js +2 -2
- package/dist/heart/source-grounding.js +18 -4
- package/dist/repertoire/bitwarden-store.js +12 -2
- package/dist/repertoire/bw-installer.js +60 -9
- package/dist/repertoire/credential-access.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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) =>
|
|
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 =
|
|
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
|
-
|
|
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: "
|
|
847
|
-
reply: "
|
|
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,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.
|
|
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
|
|
89
|
-
const EMPHASIS_SPAN = /(?<![\p{L}\p{N}*_])(\*\*|__
|
|
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
|
|
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
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
//
|
|
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",
|
|
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
|
-
//
|
|
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
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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",
|