@ouro.bot/cli 0.1.0-alpha.976 → 0.1.0-alpha.978

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,18 @@
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.978",
6
+ "changes": [
7
+ "Psyche edits now take effect on the next turn for every agent. Each prompt build compares the content of the psyche and house files with the cached copy and rereads on any change, so an edit by the agent, its owner or a sync pull no longer waits for a restart. Content is compared rather than mtimes, which FUSE shares such as Unraid's do not always update."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.977",
12
+ "changes": [
13
+ "The replay gate now reads the Butler's settle answer as his reply. A chef-question run that answered correctly through settle after a narration line was failed for not naming the show, which rolled back alpha.976."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.976",
6
18
  "changes": [
@@ -78,7 +78,11 @@ export function extractTimeline(session) {
78
78
  for (const event of events) {
79
79
  if (event.role !== "assistant") continue
80
80
  if (typeof event.content === "string" && event.content.length > 0) timeline.push({ kind: "reply", text: event.content })
81
- for (const call of event.toolCalls ?? []) timeline.push({ kind: "call", name: call.function?.name ?? "", args: call.function?.arguments ?? "", result: results.get(call.id) ?? "" })
81
+ for (const call of event.toolCalls ?? []) {
82
+ timeline.push({ kind: "call", name: call.function?.name ?? "", args: call.function?.arguments ?? "", result: results.get(call.id) ?? "" })
83
+ const answer = settleAnswer(call)
84
+ if (answer) timeline.push({ kind: "reply", text: answer })
85
+ }
82
86
  }
83
87
  return timeline
84
88
  }
@@ -148,10 +152,19 @@ export function offersToStartStoppedContainer(text) {
148
152
  return new RegExp(`\\b(?:want me to|should i|shall i|do you want me to|would you like me to|i can|i could|i'll|let me)\\b[^.!?\\n]*\\b${START_VERB}\\b`, "i").test(t)
149
153
  }
150
154
 
151
- /** Everything the assistant said across a session, in order (a reply may be spread over several messages). */
155
+ /** The answer a settle call delivered, or "" for any other call. The Butler's final reply goes out through settle, not as message text. */
156
+ export function settleAnswer(call) {
157
+ if (call?.function?.name !== "settle") return ""
158
+ try { const answer = JSON.parse(call.function.arguments ?? "").answer; return typeof answer === "string" ? answer : "" } catch { return "" }
159
+ }
160
+
161
+ /** What one assistant event said, in order: its own text, then any settle answers it delivered. */
162
+ const spoken = (event) => event?.role !== "assistant" ? [] : [typeof event.content === "string" ? event.content : "", ...(event.toolCalls ?? []).map(settleAnswer)].filter((text) => text.length > 0)
163
+
164
+ /** Everything the assistant said across a session, in order (a reply may be spread over several messages, and the last is often a settle answer). */
152
165
  export function extractReplies(session) {
153
166
  const events = Array.isArray(session?.events) ? session.events : []
154
- return events.filter((event) => event.role === "assistant" && typeof event.content === "string" && event.content.length > 0).map((event) => event.content).join("\n")
167
+ return events.flatMap(spoken).join("\n")
155
168
  }
156
169
 
157
170
  export const STALL_MIN_AGE_HOURS = 6
@@ -640,15 +653,16 @@ export function ownerPolicyUntouched(before, after) {
640
653
  const sessionEvents = (session) => (Array.isArray(session?.events) ? session.events : [])
641
654
  /** The last thing the assistant said in a session, or "" when it has said nothing. */
642
655
  export function lastReply(session) {
643
- const replies = sessionEvents(session).filter((event) => event.role === "assistant" && typeof event.content === "string" && event.content.length > 0)
644
- return replies.at(-1)?.content ?? ""
656
+ return sessionEvents(session).flatMap(spoken).at(-1) ?? ""
645
657
  }
646
658
 
647
- /** A turn has finished when the session has grown past what it held before the message and ends on a plain reply (no tool calls pending). */
659
+ /** A turn has finished when the session has grown past what it held before the message and ends on a plain reply (no tool calls pending), or on the delivery of a settle answer. */
648
660
  export function turnSettled(session, eventsBefore) {
649
661
  const events = sessionEvents(session)
650
662
  const last = events.at(-1)
651
- return events.length > eventsBefore && last?.role === "assistant" && typeof last.content === "string" && last.content.length > 0 && !(Array.isArray(last.toolCalls) && last.toolCalls.length > 0)
663
+ if (events.length <= eventsBefore) return false
664
+ if (last?.role === "tool") return events.some((event) => event.role === "assistant" && (event.toolCalls ?? []).some((call) => call.id === last.toolCallId && call.function?.name === "settle"))
665
+ return last?.role === "assistant" && typeof last.content === "string" && last.content.length > 0 && !(Array.isArray(last.toolCalls) && last.toolCalls.length > 0)
652
666
  }
653
667
 
654
668
  /** After a cut send, wait (bounded) until the turn has settled and the session has stopped growing between two reads. */
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.976",
2
+ "runtimeVersion": "0.1.0-alpha.978",
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.976</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.978</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.flattenSystemPrompt = flattenSystemPrompt;
37
+ exports.loadPsycheAt = loadPsycheAt;
37
38
  exports.resetPsycheCache = resetPsycheCache;
38
39
  exports.buildSessionSummary = buildSessionSummary;
39
40
  exports.bodyMapSection = bodyMapSection;
@@ -62,6 +63,7 @@ exports.rhythmStatusSection = rhythmStatusSection;
62
63
  exports.buildSystem = buildSystem;
63
64
  const fs = __importStar(require("fs"));
64
65
  const path = __importStar(require("path"));
66
+ const crypto_1 = require("crypto");
65
67
  const core_1 = require("../heart/core");
66
68
  const ouro_version_manager_1 = require("../heart/versioning/ouro-version-manager");
67
69
  const tools_1 = require("../repertoire/tools");
@@ -94,9 +96,40 @@ function flattenSystemPrompt(sp) {
94
96
  const parts = [sp.stable, sp.volatile].filter(Boolean);
95
97
  return parts.join("\n\n");
96
98
  }
97
- // Lazy-loaded psyche text cache, scoped by agent root. The daemon can host
98
- // multiple agents, so identity text must never be cached process-wide.
99
- let _psycheCache = null;
99
+ // Psyche text cache, one entry per agent root (the daemon can host several agents). An entry is good only while
100
+ // the files it was read from are unchanged: every load hashes their content, so an edit by the agent, its owner or
101
+ // a sync pull takes effect on the very next prompt build. Content, not mtime or inode, because those are unreliable
102
+ // on FUSE shares such as Unraid's /mnt/user; the files are small, so hashing them is cheap.
103
+ const _psycheCache = new Map();
104
+ const PSYCHE_SOURCE_FILES = {
105
+ soul: ["psyche", "SOUL.md"],
106
+ identity: ["psyche", "IDENTITY.md"],
107
+ lore: ["psyche", "LORE.md"],
108
+ tacitKnowledge: ["psyche", "TACIT.md"],
109
+ aspirations: ["psyche", "ASPIRATIONS.md"],
110
+ house: ["house", "HOUSE.md"],
111
+ houseNames: ["house", "house.json"],
112
+ };
113
+ /** Every file the psyche is built from, read once; null when a file is absent or unreadable. */
114
+ function readPsycheSources(agentRoot) {
115
+ const sources = {};
116
+ for (const [key, parts] of Object.entries(PSYCHE_SOURCE_FILES)) {
117
+ try {
118
+ sources[key] = String(fs.readFileSync(path.join(agentRoot, ...parts), "utf-8"));
119
+ }
120
+ catch {
121
+ sources[key] = null;
122
+ }
123
+ }
124
+ return sources;
125
+ }
126
+ /** A content hash of the sources; an absent file hashes differently from an empty one. */
127
+ function psycheFingerprint(sources) {
128
+ const hash = (0, crypto_1.createHash)("sha256");
129
+ for (const text of Object.values(sources))
130
+ hash.update(text === null ? "-" : `+${text.length}:${text}`);
131
+ return hash.digest("hex");
132
+ }
100
133
  // A household install names its owner, its Butler and its home in house/house.json,
101
134
  // a file the package never overwrites. The packaged psyche writes {{owner}}, {{butler}}
102
135
  // and {{household}} where those names go, so one psyche serves every household.
@@ -105,59 +138,48 @@ const HOUSE_NAME_DEFAULTS = {
105
138
  butler: "the Butler",
106
139
  household: "the household",
107
140
  };
108
- function loadHouseNames(agentRoot) {
141
+ function parseHouseNames(text) {
142
+ const names = { ...HOUSE_NAME_DEFAULTS };
109
143
  try {
110
- const parsed = JSON.parse(fs.readFileSync(path.join(agentRoot, "house", "house.json"), "utf-8"));
111
- const names = { ...HOUSE_NAME_DEFAULTS };
144
+ const parsed = JSON.parse(text ?? "");
112
145
  for (const [key, field] of [["owner", "ownerName"], ["butler", "butlerName"], ["household", "householdName"]]) {
113
146
  const value = parsed[field];
114
147
  if (typeof value === "string" && value.trim())
115
148
  names[key] = value.trim();
116
149
  }
117
- return names;
118
150
  }
119
151
  catch {
120
- return { ...HOUSE_NAME_DEFAULTS };
152
+ // no house.json, or not JSON: the defaults stand
121
153
  }
154
+ return names;
122
155
  }
123
156
  function fillHouseNames(text, names) {
124
- return text.replace(/\{\{(owner|butler|household)\}\}/gu, (_match, key) => names[key]);
125
- }
126
- function loadPsycheFile(agentRoot, name, names) {
127
- try {
128
- const psycheDir = path.join(agentRoot, "psyche");
129
- return fillHouseNames(fs.readFileSync(path.join(psycheDir, name), "utf-8").trim(), names);
130
- }
131
- catch {
132
- return "";
133
- }
134
- }
135
- function loadHouseFile(agentRoot, names) {
136
- try {
137
- return fillHouseNames(fs.readFileSync(path.join(agentRoot, "house", "HOUSE.md"), "utf-8").trim(), names);
138
- }
139
- catch {
140
- return "";
141
- }
142
- }
143
- function loadPsyche() {
144
- const agentRoot = (0, identity_1.getAgentRoot)();
145
- if (_psycheCache?.agentRoot === agentRoot)
146
- return _psycheCache.psyche;
147
- const names = loadHouseNames(agentRoot);
157
+ return (text ?? "").trim().replace(/\{\{(owner|butler|household)\}\}/gu, (_match, key) => names[key]);
158
+ }
159
+ /** The psyche for one agent root. Each call reads its files once and rebuilds only when their content changed. */
160
+ function loadPsycheAt(agentRoot) {
161
+ const sources = readPsycheSources(agentRoot);
162
+ const fingerprint = psycheFingerprint(sources);
163
+ const cached = _psycheCache.get(agentRoot);
164
+ if (cached?.fingerprint === fingerprint)
165
+ return cached.psyche;
166
+ const names = parseHouseNames(sources.houseNames);
148
167
  const psyche = {
149
- soul: loadPsycheFile(agentRoot, "SOUL.md", names),
150
- identity: loadPsycheFile(agentRoot, "IDENTITY.md", names),
151
- lore: loadPsycheFile(agentRoot, "LORE.md", names),
152
- tacitKnowledge: loadPsycheFile(agentRoot, "TACIT.md", names),
153
- aspirations: loadPsycheFile(agentRoot, "ASPIRATIONS.md", names),
154
- house: loadHouseFile(agentRoot, names),
168
+ soul: fillHouseNames(sources.soul, names),
169
+ identity: fillHouseNames(sources.identity, names),
170
+ lore: fillHouseNames(sources.lore, names),
171
+ tacitKnowledge: fillHouseNames(sources.tacitKnowledge, names),
172
+ aspirations: fillHouseNames(sources.aspirations, names),
173
+ house: fillHouseNames(sources.house, names),
155
174
  };
156
- _psycheCache = { agentRoot, psyche };
175
+ _psycheCache.set(agentRoot, { fingerprint, psyche });
157
176
  return psyche;
158
177
  }
178
+ function loadPsyche() {
179
+ return loadPsycheAt((0, identity_1.getAgentRoot)());
180
+ }
159
181
  function resetPsycheCache() {
160
- _psycheCache = null;
182
+ _psycheCache.clear();
161
183
  }
162
184
  const DEFAULT_ACTIVE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
163
185
  function buildSessionSummary(options) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.976",
3
+ "version": "0.1.0-alpha.978",
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.976",
9
+ "version": "0.1.0-alpha.978",
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.976",
3
+ "version": "0.1.0-alpha.978",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },