@ouro.bot/cli 0.1.0-alpha.903 → 0.1.0-alpha.905

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.
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.UPDATER_INTERVAL_SECONDS = void 0;
37
+ exports.updateStatePath = updateStatePath;
38
+ exports.readUpdateState = readUpdateState;
39
+ exports.writeUpdateState = writeUpdateState;
40
+ exports.formatUpdateStatusLine = formatUpdateStatusLine;
41
+ const fs = __importStar(require("fs"));
42
+ const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
44
+ const runtime_1 = require("../../nerves/runtime");
45
+ exports.UPDATER_INTERVAL_SECONDS = 60 * 60;
46
+ function updateStatePath(homeDir = os.homedir()) {
47
+ return path.join(homeDir, ".ouro-cli", "update-state.json");
48
+ }
49
+ function readUpdateState(deps = {}) {
50
+ const readFileSync = deps.readFileSync ?? fs.readFileSync;
51
+ try {
52
+ const parsed = JSON.parse(readFileSync(updateStatePath(deps.homeDir), "utf8"));
53
+ if (parsed?.schemaVersion !== 1 || typeof parsed.lastRunAt !== "string" || typeof parsed.outcome !== "string")
54
+ return null;
55
+ return {
56
+ ...parsed,
57
+ failedVersions: Array.isArray(parsed.failedVersions) ? parsed.failedVersions.filter((v) => typeof v === "string") : [],
58
+ consecutiveFailures: typeof parsed.consecutiveFailures === "number" ? parsed.consecutiveFailures : 0,
59
+ };
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ function writeUpdateState(state, deps = {}) {
66
+ const writeFileSync = deps.writeFileSync ?? fs.writeFileSync;
67
+ const renameSync = deps.renameSync ?? fs.renameSync;
68
+ const mkdirSync = deps.mkdirSync ?? fs.mkdirSync;
69
+ const destination = updateStatePath(deps.homeDir);
70
+ mkdirSync(path.dirname(destination), { recursive: true });
71
+ const temporary = `${destination}.${process.pid}.tmp`;
72
+ writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
73
+ renameSync(temporary, destination);
74
+ (0, runtime_1.emitNervesEvent)({
75
+ level: ["updated", "current", "pinned"].includes(state.outcome) ? "info" : "warn",
76
+ component: "daemon",
77
+ event: "daemon.self_update_state_written",
78
+ message: "recorded the unattended updater outcome",
79
+ meta: { outcome: state.outcome, currentVersion: state.currentVersion, latestVersion: state.latestVersion ?? null, detail: state.detail ?? null },
80
+ });
81
+ }
82
+ function formatAge(ms) {
83
+ const minutes = Math.max(0, Math.round(ms / 60_000));
84
+ if (minutes < 60)
85
+ return `${minutes}m ago`;
86
+ const hours = Math.round(minutes / 60);
87
+ if (hours < 48)
88
+ return `${hours}h ago`;
89
+ return `${Math.round(hours / 24)}d ago`;
90
+ }
91
+ /**
92
+ * One line for `ouro status`. Problems lead with "needs attention" so a
93
+ * stuck machine is visible at a glance.
94
+ */
95
+ function formatUpdateStatusLine(state, options) {
96
+ if (!state) {
97
+ return options.updaterInstalled === false
98
+ ? "Updates: needs attention: the unattended updater is not installed; run `ouro self-update` once"
99
+ : "Updates: no update check recorded yet; run `ouro self-update`";
100
+ }
101
+ const ageMs = options.now - Date.parse(state.lastRunAt);
102
+ const age = formatAge(ageMs);
103
+ const stale = ageMs > exports.UPDATER_INTERVAL_SECONDS * 1000 * 3;
104
+ const version = state.currentVersion ?? "unknown version";
105
+ let line;
106
+ switch (state.outcome) {
107
+ case "updated":
108
+ line = `Updates: updated to ${version} ${age}${state.source ? ` via ${state.source}` : ""}`;
109
+ break;
110
+ case "current":
111
+ line = `Updates: current at ${version} (checked ${age}${state.source ? ` via ${state.source}` : ""})`;
112
+ break;
113
+ case "pinned":
114
+ line = `Updates: pinned to ${version} (checked ${age}; ${state.detail ?? "pin honored"})`;
115
+ break;
116
+ default:
117
+ line = `Updates: needs attention: ${state.outcome} ${age} on ${version}${state.latestVersion ? ` (latest ${state.latestVersion})` : ""}: ${state.detail ?? "no detail"}`;
118
+ }
119
+ if (stale) {
120
+ line += `; needs attention: the updater has not run since ${state.lastRunAt}`;
121
+ }
122
+ if (options.updaterInstalled === false) {
123
+ line += "; needs attention: the unattended updater is not installed; run `ouro self-update` once";
124
+ }
125
+ return line;
126
+ }
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.STABLE_NODE_PATHS = exports.UPDATER_PLIST_LABEL = void 0;
37
+ exports.chooseUpdaterNodePath = chooseUpdaterNodePath;
38
+ exports.updaterPlistPath = updaterPlistPath;
39
+ exports.generateUpdaterPlist = generateUpdaterPlist;
40
+ exports.ensureUpdaterAgent = ensureUpdaterAgent;
41
+ const path = __importStar(require("path"));
42
+ const runtime_1 = require("../../nerves/runtime");
43
+ const update_state_1 = require("./update-state");
44
+ /**
45
+ * A launchd agent that runs `ouro self-update --unattended` every hour and at
46
+ * login, independent of the Ouro daemon. Updates keep flowing when the
47
+ * daemon is stopped, crashed, or was never started on this machine.
48
+ */
49
+ exports.UPDATER_PLIST_LABEL = "bot.ouro.updater";
50
+ /** Node binaries that survive nvm switches and Node upgrades, in preference order. */
51
+ exports.STABLE_NODE_PATHS = ["/opt/homebrew/bin/node", "/usr/local/bin/node"];
52
+ /**
53
+ * Pick the node binary the updater agent runs. A stable path (Homebrew) is
54
+ * preferred when it exists and runs the same major Node version as this
55
+ * process, so the plist does not churn every time another nvm node runs
56
+ * `ouro up`; otherwise the running node is used.
57
+ */
58
+ function chooseUpdaterNodePath(options) {
59
+ for (const candidate of options.candidates ?? exports.STABLE_NODE_PATHS) {
60
+ if (options.majorOf(candidate) === options.runningMajor)
61
+ return candidate;
62
+ }
63
+ return options.execPath;
64
+ }
65
+ function updaterPlistPath(homeDir) {
66
+ return path.join(homeDir, "Library", "LaunchAgents", `${exports.UPDATER_PLIST_LABEL}.plist`);
67
+ }
68
+ function xmlEscape(value) {
69
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
70
+ }
71
+ function generateUpdaterPlist(options) {
72
+ const launcher = path.join(options.homeDir, ".ouro-cli", "bin", "ouro-launcher.js");
73
+ const envPath = [path.dirname(options.nodePath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"].join(":");
74
+ return [
75
+ `<?xml version="1.0" encoding="UTF-8"?>`,
76
+ `<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">`,
77
+ `<plist version="1.0">`,
78
+ `<dict>`,
79
+ ` <key>Label</key>`,
80
+ ` <string>${exports.UPDATER_PLIST_LABEL}</string>`,
81
+ ` <key>ProgramArguments</key>`,
82
+ ` <array>`,
83
+ ` <string>${xmlEscape(options.nodePath)}</string>`,
84
+ ` <string>${xmlEscape(launcher)}</string>`,
85
+ ` <string>self-update</string>`,
86
+ ` <string>--unattended</string>`,
87
+ ` </array>`,
88
+ ` <key>RunAtLoad</key>`,
89
+ ` <true/>`,
90
+ ` <key>StartInterval</key>`,
91
+ ` <integer>${update_state_1.UPDATER_INTERVAL_SECONDS}</integer>`,
92
+ ` <key>ProcessType</key>`,
93
+ ` <string>Background</string>`,
94
+ ` <key>EnvironmentVariables</key>`,
95
+ ` <dict>`,
96
+ ` <key>PATH</key>`,
97
+ ` <string>${xmlEscape(envPath)}</string>`,
98
+ ` </dict>`,
99
+ `</dict>`,
100
+ `</plist>`,
101
+ ``,
102
+ ].join("\n");
103
+ }
104
+ /**
105
+ * Write the updater plist when it is missing or stale (for example after the
106
+ * node binary moved) and load it. When the updater agent itself is running,
107
+ * or another process holds the update lock, reloading would kill the run in
108
+ * progress, so it only rewrites the file and the next interactive
109
+ * `ouro self-update` or `ouro up` reloads it.
110
+ */
111
+ function ensureUpdaterAgent(deps) {
112
+ const plistPath = updaterPlistPath(deps.homeDir);
113
+ const desired = generateUpdaterPlist({ homeDir: deps.homeDir, nodePath: deps.nodePath });
114
+ const existing = deps.readFile(plistPath);
115
+ const domain = `gui/${deps.userUid}`;
116
+ let loaded = true;
117
+ try {
118
+ deps.exec(`launchctl print ${domain}/${exports.UPDATER_PLIST_LABEL}`);
119
+ }
120
+ catch {
121
+ loaded = false;
122
+ }
123
+ if (existing === desired && loaded)
124
+ return "unchanged";
125
+ deps.mkdirp(path.dirname(plistPath));
126
+ deps.writeFile(plistPath, desired);
127
+ if (deps.runningUnderUpdaterAgent || deps.updateInProgress) {
128
+ (0, runtime_1.emitNervesEvent)({
129
+ component: "daemon",
130
+ event: "daemon.self_update_agent_written",
131
+ message: "rewrote the updater launch agent; reload waits for the next interactive run",
132
+ meta: { plistPath, updateInProgress: deps.updateInProgress },
133
+ });
134
+ return "written-reload-pending";
135
+ }
136
+ if (loaded) {
137
+ try {
138
+ deps.exec(`launchctl bootout ${domain}/${exports.UPDATER_PLIST_LABEL}`);
139
+ }
140
+ catch { /* already gone */ }
141
+ }
142
+ deps.exec(`launchctl bootstrap ${domain} "${plistPath}"`);
143
+ (0, runtime_1.emitNervesEvent)({
144
+ component: "daemon",
145
+ event: "daemon.self_update_agent_installed",
146
+ message: "installed the unattended updater launch agent",
147
+ meta: { plistPath, intervalSeconds: update_state_1.UPDATER_INTERVAL_SECONDS },
148
+ });
149
+ return "installed";
150
+ }
@@ -5,10 +5,8 @@ const runtime_1 = require("../nerves/runtime");
5
5
  const sanctuary_media_optimization_1 = require("./sanctuary-media-optimization");
6
6
  const LEGACY_CATALOG_TOOL = "sanctuary_search_media_catalog";
7
7
  const MEDIA_SEARCH_TOOL = "media_search";
8
- const MEDIA_REQUEST_TOOL = "media_request";
9
8
  const HOUSEHOLD_JARGON = /\b(?:bounded|catalog|inventory|endpoint|backend|data shape|pars(?:e|ed|ing)|json|jellyfin|unmanic|sanctuary_search_media_catalog)\b/iu;
10
9
  const NEGATED_MEDIA_ACCESS = /\b(?:not|never|none|nothing|zero|inaccessible|invisible|unavailable|broken|failed|failing)\b|\b(?:isn[’']t|won[’']t)\b|\bno\s+access\b|\b(?:lost|lacks?|lacking|without)\s+access\b|\bonly\s+\d|\bfewer\s+than\b|\bsome\s+of\b|\b(?:part|portion|subset)\s+of\b|\bi\s+(?:can(?:not|[’']t)|do\s+not|don[’']t|have\s+no)\b|\bi[’']m\s+not\b/iu;
11
- const UNSOLICITED_PIVOT = /\b(?:what would you like|what are you in the mood for|would you like me to|do you want me to|want me to|recommend we add)\b/iu;
12
10
  const REPORT_LABEL = /(?:^|\n)\s*(?:version|house|the house|render check|status|result)\s*:/iu;
13
11
  const TITLE_LIST_GLUE = new Set(["and", "are", "catalog", "film", "films", "from", "have", "here", "in", "is", "library", "movie", "movies", "on", "shelf", "show", "shows", "the", "these", "titles", "we"]);
14
12
  const COUNT_ANSWER_GLUE = new Set(["and", "are", "currently", "episodes", "film", "films", "have", "in", "items", "library", "movie", "movies", "on", "shelf", "show", "shows", "the", "there", "tv", "we"]);
@@ -86,7 +84,6 @@ function parseCatalogEvidence(result, args) {
86
84
  totalItems: parsed.data.totalItems,
87
85
  matchedItems: parsed.data.matchedItems,
88
86
  titles,
89
- offShelfTitles: [],
90
87
  fromMediaSearch: false,
91
88
  };
92
89
  }
@@ -105,7 +102,6 @@ function parseMediaSearchEvidence(result, args) {
105
102
  if (!Number.isSafeInteger(parsed.matched) || !Array.isArray(parsed.items))
106
103
  return null;
107
104
  const titles = [];
108
- const offShelfTitles = [];
109
105
  for (const entry of parsed.items) {
110
106
  if (!entry || typeof entry !== "object" || Array.isArray(entry))
111
107
  continue;
@@ -113,15 +109,12 @@ function parseMediaSearchEvidence(result, args) {
113
109
  if (typeof item.title !== "string" || !item.title.trim())
114
110
  continue;
115
111
  titles.push(item.title.trim());
116
- if (item.on_shelf === false)
117
- offShelfTitles.push(item.title.trim());
118
112
  }
119
113
  return {
120
114
  query: (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(args.query ?? ""),
121
115
  totalItems: parsed.matched,
122
116
  matchedItems: parsed.matched,
123
117
  titles,
124
- offShelfTitles,
125
118
  fromMediaSearch: true,
126
119
  };
127
120
  }
@@ -150,14 +143,9 @@ function hasGroundedFirstPersonVisibility(answer, formattedCount) {
150
143
  return firstPersonAccess.some((pattern) => pattern.test(answer)) && groundedCount.test(answer);
151
144
  }
152
145
  function commonAnswerRejection(answer, technicalDetailRequested) {
153
- if (/\b(?:just|only|actually|merely)\s+(?:a\s+)?(?:bot|ai|assistant)\b|\bi\s+(?:can[’']?t|cannot|don[’']?t|do not)\s+(?:actually\s+)?watch\b|\bif i had to pick\b/iu.test(answer)) {
154
- return "Answer from the catalog evidence with a confident, personable choice; do not retreat into an AI or 'I cannot watch' disclaimer.";
155
- }
156
146
  if (/\b(?:you'?d|you\s+would|you\s+need\s+to|please)\b.*\b(?:check|look at|poke|nudge)\b.*\b(?:log|logs|dashboard|jellyfin|unmanic)\b/iu.test(answer)) {
157
147
  return "Do not send Ari to check Jellyfin, Unmanic, dashboards, or logs for ordinary library visibility while safe catalog tools are available. Use the catalog evidence, or if the catalog read itself fails, say exactly what I could and could not verify.";
158
148
  }
159
- if (UNSOLICITED_PIVOT.test(answer))
160
- return "Answer the media question that was asked, then stop; do not append an unsolicited question or recommendation pivot.";
161
149
  if (REPORT_LABEL.test(answer))
162
150
  return "Answer conversationally; do not use status-report labels such as version:, house:, render check:, status:, or result:.";
163
151
  if (!technicalDetailRequested && HOUSEHOLD_JARGON.test(answer))
@@ -168,7 +156,6 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
168
156
  // Prefer media_search when the media surface is installed: it grounds both what is
169
157
  // on the shelf and what is worth adding. Fall back to the legacy restricted read.
170
158
  const catalogTool = advertisedToolNames.includes(MEDIA_SEARCH_TOOL) ? MEDIA_SEARCH_TOOL : LEGACY_CATALOG_TOOL;
171
- const canRequestMedia = advertisedToolNames.includes(MEDIA_REQUEST_TOOL);
172
159
  if (!advertisedToolNames.includes(catalogTool))
173
160
  return undefined;
174
161
  const normalized = (0, sanctuary_media_optimization_1.normalizeSanctuaryMediaText)(request);
@@ -181,9 +168,12 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
181
168
  if (operationalOnly || (!mentionsMedia && !requestedTitleQuery) || !asksCatalog)
182
169
  return undefined;
183
170
  const kind = requestKind(normalized, requestedTitleQuery);
184
- const asksForAddition = /\b(?:add|missing from|must get)\b/u.test(normalized);
185
171
  const asksForCount = /\b(?:how many|count)\b/u.test(normalized);
186
172
  const listCount = requestedListCount(normalized);
173
+ // Picking something to watch is a conversation, not a catalog read. Forcing a lookup and a title into every reply threw away
174
+ // the greeting and the questions (2026-10-11, Rach handoff); the shelf-claim answer gate holds whatever a reply says is on the shelf.
175
+ if (kind === "taste_recommendation" || (kind === "other" && !listCount && !asksForCount))
176
+ return undefined;
187
177
  const technicalDetailRequested = /\b(?:technical|technically|implementation|endpoint|backend|debug|detail|internals?)\b/u.test(normalized);
188
178
  let latestEvidence;
189
179
  const names = [catalogTool];
@@ -202,11 +192,7 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
202
192
  ? `Use ${catalogTool} before answering. Lead with the direct answer from current catalog evidence, report the shelf count in ordinary household language, and stop without sampled titles or a follow-up question.`
203
193
  : kind === "title_lookup"
204
194
  ? `Use ${catalogTool} with the requested title before answering. Confirm its presence or absence directly from current catalog evidence.`
205
- : kind === "taste_recommendation"
206
- ? `Use ${catalogTool} before answering. Make one concise, decisive choice grounded in returned catalog evidence; do not volunteer an AI or 'I cannot watch' disclaimer.`
207
- : listCount
208
- ? `Use ${catalogTool} with limit ${listCount} before answering, then name exactly the returned catalog titles.`
209
- : `Use ${catalogTool} before answering and base any named titles on the returned catalog evidence.`,
195
+ : `Use ${catalogTool} with limit ${listCount} before answering, then name exactly the returned catalog titles.`,
210
196
  validateRequiredToolResult(name, result, args) {
211
197
  if (name !== catalogTool)
212
198
  return false;
@@ -224,10 +210,6 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
224
210
  return "Search for the requested title by name before answering whether it is on the shelf.";
225
211
  if (kind === "title_lookup" && query !== requestedTitleQuery)
226
212
  return `Search for the requested title ${requestedTitleQuery} before answering whether it is on the shelf.`;
227
- // media_search browses by genre, keyword and year and reports shelf state per
228
- // title, so an addition question does not need a per-candidate title query.
229
- if (asksForAddition && !query && catalogTool !== MEDIA_SEARCH_TOOL)
230
- return "Search for one candidate title by name before recommending it as an addition.";
231
213
  if (listCount && Number(args.limit) !== listCount && catalogTool !== MEDIA_SEARCH_TOOL)
232
214
  return `Set the catalog limit to ${listCount} so the answer is grounded in exactly the requested number of titles.`;
233
215
  return undefined;
@@ -264,26 +246,6 @@ function sanctuaryMediaCatalogRequiredToolCalls(request, advertisedToolNames) {
264
246
  if (!includesNormalizedPhrase(answer, requestedTitleQuery))
265
247
  return "Name the requested title in the direct answer.";
266
248
  }
267
- if (kind === "taste_recommendation" && latest) {
268
- if (asksForAddition) {
269
- if (latest.fromMediaSearch) {
270
- // media_search states shelf membership per title, so a recommendation is
271
- // grounded when the named candidate came back marked absent.
272
- if (!latest.offShelfTitles.some((title) => includesNormalizedPhrase(answer, title))) {
273
- return "Recommend only a title the current search returned as not on the shelf.";
274
- }
275
- }
276
- else if (!latest.query || latest.matchedItems !== 0 || !includesNormalizedPhrase(answer, latest.query)) {
277
- return "Recommend an addition only after an exact current catalog search shows that candidate is absent.";
278
- }
279
- if (!canRequestMedia && /\b(?:i|we)\s+(?:added|requested|submitted|queued)\b/iu.test(answer)) {
280
- return "Do not claim the title was added or requested when no media-request action was available.";
281
- }
282
- }
283
- else if (!latest.titles.some((title) => includesNormalizedPhrase(answer, title))) {
284
- return "Make the choice from a title returned by the current catalog evidence.";
285
- }
286
- }
287
249
  if (kind === "other" && latest) {
288
250
  if (!latest.fromMediaSearch)
289
251
  return catalogTitleGroundingRejection(answer, latest, listCount);
@@ -301,8 +301,8 @@ function createSanctuaryToolContext(agentName) {
301
301
  });
302
302
  return {
303
303
  agentRoot,
304
- // The Sanctuary profile opts in to the settle-time checks: replies about a work are held to what the turn looked up, and a brevity request is honored.
305
- answerGates: { sourceGrounding: true, brevity: true },
304
+ // The Sanctuary profile opts in to the settle-time checks: replies about a work are held to what the turn looked up, "on the shelf" to what a library lookup found, and a brevity request is honored.
305
+ answerGates: { sourceGrounding: true, brevity: true, shelfClaims: true },
306
306
  sanctuary: {
307
307
  // The model-facing list carries the steward policy's desired state beside docker's: a container kept stopped on purpose is not a fault. The restart path reads the plain list.
308
308
  listContainers: acceptanceRead("unraid_list_containers", async (signal) => (0, sanctuary_container_policy_1.annotateContainers)(signal ? await reads.listContainers(signal) : await reads.listContainers(), currentStewardPolicy(agentRoot), Date.now())),
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.OWNER_CHAT_HANDOFF_MS = void 0;
37
+ exports.ownerChatHandedOff = ownerChatHandedOff;
38
+ exports.readHeldNotices = readHeldNotices;
39
+ exports.writeHeldNotices = writeHeldNotices;
40
+ /**
41
+ * Ari sometimes hands his Telegram chat to someone else ("I'm going to hand you to my wife, Rachel"). For a while after that,
42
+ * the chat is that person's conversation, so house notices (an outage, a recovery) wait instead of landing in it
43
+ * (2026-10-11: two Jellyfin outage notes went into Rachel's movie chat). The window runs until the chat has been quiet for two
44
+ * hours, so a long conversation is never interrupted. Pure functions plus a small held-notice file; the Telegram sense wires them in.
45
+ */
46
+ const fs = __importStar(require("node:fs"));
47
+ const path = __importStar(require("node:path"));
48
+ const session_events_1 = require("../heart/session-events");
49
+ const runtime_1 = require("../nerves/runtime");
50
+ exports.OWNER_CHAT_HANDOFF_MS = 2 * 60 * 60 * 1000;
51
+ const HANDOFF_CUE = /\b(?:hand(?:ing)?|pass(?:ing)?)\s+you\s+(?:(?:over|off)\s+)?to\b|\bsay\s+(?:hi|hello)\s+to\s+(?:her|him|them)\b/i;
52
+ /** True when the owner's chat was handed to someone else and has had a message from them within the window. */
53
+ function ownerChatHandedOff(events, now) {
54
+ let lastMessageAt = null;
55
+ for (let index = events.length - 1; index >= 0; index -= 1) {
56
+ const event = events[index];
57
+ if (event.role !== "user")
58
+ continue;
59
+ lastMessageAt ??= Date.parse(event.time.observedAt ?? event.time.recordedAt);
60
+ if (HANDOFF_CUE.test((0, session_events_1.extractEventText)(event)))
61
+ return Number.isFinite(lastMessageAt) && now - lastMessageAt < exports.OWNER_CHAT_HANDOFF_MS;
62
+ }
63
+ return false;
64
+ }
65
+ function readHeldNotices(filePath) {
66
+ try {
67
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
68
+ return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry?.key === "string" && typeof entry?.text === "string") : [];
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ }
74
+ function writeHeldNotices(filePath, notices) {
75
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
76
+ const temporary = `${filePath}.${process.pid}.tmp`;
77
+ fs.writeFileSync(temporary, JSON.stringify(notices), { mode: 0o600 });
78
+ fs.renameSync(temporary, filePath);
79
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_held_notices_saved", message: "saved the house notices held during a handoff", meta: { held: notices.length } });
80
+ }
@@ -85,6 +85,7 @@ const sanctuary_grounding_1 = require("./sanctuary-grounding");
85
85
  const telegram_approval_runtime_1 = require("./telegram-approval-runtime");
86
86
  const telegram_audit_ledger_1 = require("./telegram-audit-ledger");
87
87
  const session_events_1 = require("../heart/session-events");
88
+ const telegram_handoff_1 = require("./telegram-handoff");
88
89
  const router_1 = require("../heart/external-events/router");
89
90
  const session_transaction_1 = require("../mind/session-transaction");
90
91
  const approval_files_1 = require("../heart/approval-files");
@@ -1128,6 +1129,45 @@ function createTelegramSenseApp(options) {
1128
1129
  ...(onMessageDelivered ? { onMessageDelivered } : {}),
1129
1130
  });
1130
1131
  };
1132
+ // House notices wait while Ari's chat is handed to someone else, then go out with the next notice, message or reconcile pass
1133
+ // (see telegram-handoff). Releases run one at a time so two callers never send the same held notice twice, and a notice leaves the
1134
+ // held file before it is sent: from then on the effect journal owns it, exactly like a notice that was never held.
1135
+ const heldNoticesPath = path.join(agentRoot, "state", "telegram", "held-owner-notices.json");
1136
+ const ownerChatIsHandedOff = () => {
1137
+ const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1138
+ return (0, telegram_handoff_1.ownerChatHandedOff)((0, session_events_1.loadSessionEnvelopeFile)(sessionPath)?.events ?? [], Date.now());
1139
+ };
1140
+ const holdNoticeDuringHandoff = (text, key) => {
1141
+ if (!ownerChatIsHandedOff())
1142
+ return false;
1143
+ const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
1144
+ if (!held.some((notice) => notice.key === key))
1145
+ (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, [...held, { key, text }]);
1146
+ (0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.telegram_notice_held_for_handoff", message: "held a house notice while the owner's chat is handed to someone else", meta: { agentName: options.agentName } });
1147
+ return true;
1148
+ };
1149
+ let heldNoticeRelease = Promise.resolve();
1150
+ const releaseHeldNotices = async (signal) => {
1151
+ // Nearly always nothing is held: return without queueing, so ordinary turns and the reconcile tick pay nothing.
1152
+ if ((0, telegram_handoff_1.readHeldNotices)(heldNoticesPath).length === 0)
1153
+ return;
1154
+ const release = heldNoticeRelease.then(async () => {
1155
+ const held = (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath);
1156
+ if (ownerChatIsHandedOff())
1157
+ return;
1158
+ const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1159
+ for (const notice of held) {
1160
+ (0, telegram_handoff_1.writeHeldNotices)(heldNoticesPath, (0, telegram_handoff_1.readHeldNotices)(heldNoticesPath).filter((entry) => entry.key !== notice.key));
1161
+ await recordAcceptedEffects(sessionPath, [await deliverButlerEffect(notice.text, notice.key, signal)]);
1162
+ }
1163
+ });
1164
+ heldNoticeRelease = release.catch(() => undefined);
1165
+ await release;
1166
+ };
1167
+ /** For callers that must not fail because a held notice failed to send; the effect journal keeps the failed send. */
1168
+ const releaseHeldNoticesQuietly = () => releaseHeldNotices().catch((error) => {
1169
+ (0, runtime_1.emitNervesEvent)({ level: "error", component: "senses", event: "senses.telegram_held_notice_release_error", message: "a held house notice failed to send; the effect journal has it", meta: { agentName: options.agentName, subject, error: transportError(error) } });
1170
+ });
1131
1171
  recordAcceptedEffects = async (sessionPath, artifacts, bootstrapInbound, causalEventIds) => {
1132
1172
  await (0, telegram_effect_adapter_1.recordTelegramEffectsInSession)({ store: getEffectJournal(), sessionPath, artifacts, ...(bootstrapInbound ? { inbound: bootstrapInbound } : {}), ...(causalEventIds ? { causalEventIds } : {}) });
1133
1173
  for (const artifact of artifacts) {
@@ -1179,6 +1219,7 @@ function createTelegramSenseApp(options) {
1179
1219
  });
1180
1220
  const failsafeScanCache = new router_1.ExternalEventScanCache();
1181
1221
  reconcileSystemFailsafes = async () => {
1222
+ await releaseHeldNoticesQuietly();
1182
1223
  if (!options.privilegedFailsafe)
1183
1224
  return;
1184
1225
  const target = configuredOwnerTarget();
@@ -1503,6 +1544,7 @@ function createTelegramSenseApp(options) {
1503
1544
  const turnEffects = [];
1504
1545
  let deliveryOrdinal = 0;
1505
1546
  await recoverEffectOutbox();
1547
+ await releaseHeldNoticesQuietly();
1506
1548
  let ingressRelations = { replyToEventId: null, threadRootEventId: null, references: [inboundReference] };
1507
1549
  if (message.replyToMessageId && /^[1-9][0-9]*$/u.test(message.replyToMessageId)) {
1508
1550
  const replyMessageId = Number(message.replyToMessageId);
@@ -1902,7 +1944,11 @@ function createTelegramSenseApp(options) {
1902
1944
  });
1903
1945
  return;
1904
1946
  }
1905
- const effect = await deliverButlerEffect(proactiveText, `proactive:${(0, node_crypto_1.randomUUID)()}`, signal);
1947
+ const key = `proactive:${(0, node_crypto_1.randomUUID)()}`;
1948
+ if (holdNoticeDuringHandoff(proactiveText, key))
1949
+ return;
1950
+ await releaseHeldNotices(signal);
1951
+ const effect = await deliverButlerEffect(proactiveText, key, signal);
1906
1952
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1907
1953
  await recordAcceptedEffects(sessionPath, [effect]);
1908
1954
  });
@@ -1914,7 +1960,11 @@ function createTelegramSenseApp(options) {
1914
1960
  throw new Error("Telegram external event identity is invalid");
1915
1961
  await runWithAcceptanceAuditOwner(async () => {
1916
1962
  const receiptIdentity = JSON.stringify([source, eventId, input.generation]);
1917
- const effect = await deliverButlerEffect(requiredText(input.text, "external event decision"), `external-event:${receiptIdentity}`, input.signal);
1963
+ const text = requiredText(input.text, "external event decision");
1964
+ if (holdNoticeDuringHandoff(text, `external-event:${receiptIdentity}`))
1965
+ return;
1966
+ await releaseHeldNotices(input.signal);
1967
+ const effect = await deliverButlerEffect(text, `external-event:${receiptIdentity}`, input.signal);
1918
1968
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1919
1969
  await recordAcceptedEffects(sessionPath, [effect]);
1920
1970
  });
@@ -1927,7 +1977,11 @@ function createTelegramSenseApp(options) {
1927
1977
  if (Buffer.byteLength(noticeId) > 512)
1928
1978
  throw new Error("Telegram owner notice id is invalid");
1929
1979
  await runWithAcceptanceAuditOwner(async () => {
1930
- const effect = await deliverButlerEffect(requiredText(input.text, "owner notice"), `owner-notice:${noticeId}`, input.signal);
1980
+ const text = requiredText(input.text, "owner notice");
1981
+ if (holdNoticeDuringHandoff(text, `owner-notice:${noticeId}`))
1982
+ return;
1983
+ await releaseHeldNotices(input.signal);
1984
+ const effect = await deliverButlerEffect(text, `owner-notice:${noticeId}`, input.signal);
1931
1985
  const sessionPath = (0, shared_turn_1.getSenseSessionPath)(options.agentName, configuredOwnerFriendId, "telegram", configuredOwnerSessionKey, agentRoot);
1932
1986
  await recordAcceptedEffects(sessionPath, [effect]);
1933
1987
  });
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.903",
3
+ "version": "0.1.0-alpha.905",
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.903",
9
+ "version": "0.1.0-alpha.905",
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.903",
3
+ "version": "0.1.0-alpha.905",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
@@ -36,6 +36,7 @@
36
36
  "test": "vitest run",
37
37
  "test:integration": "npm run build && vitest run --config vitest.integration.config.ts",
38
38
  "test:e2e:package": "npm run build && node scripts/package-e2e.cjs",
39
+ "test:e2e:self-update": "node scripts/self-update-package-e2e.cjs",
39
40
  "test:e2e:ouro-bot-package": "node scripts/ouro-bot-package-e2e.cjs",
40
41
  "test:e2e:real-smoke": "npm run build && node scripts/nightly-real-smoke.cjs",
41
42
  "typecheck:mailbox-ui": "tsc --noEmit -p packages/mailbox-ui/tsconfig.json",