@inetafrica/open-claudia 3.0.25 → 3.0.27

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,251 @@
1
+ // Track C — dynamic owner-guardrail capture. When the OWNER states a durable
2
+ // behavioural rule mid-chat ("from now on ...", "never ...", "stop ...ing"), a
3
+ // deterministic post-turn detector either captures it straight into lessons.md
4
+ // (always-loaded) or asks first, so the correction holds on the next turn with
5
+ // no code change and no deploy.
6
+ //
7
+ // Invariants:
8
+ // - Owner-only, HARD. Rules are NEVER captured from external speakers — the
9
+ // caller gates on relationship.guardActive(speaker) and consider() re-checks.
10
+ // - Advisory-tier. A captured rule layers ABOVE lessons but can never override
11
+ // the code-fixed hard rules (tool-first, enforcer). Safety rails stay in code.
12
+ // - No model call. Detection is phrase-based and default-silent; the ambiguous
13
+ // tier defers to the owner (a Y/N button), not to a model that could invent
14
+ // a rule. Clear "from now on / never / stop" phrasings auto-capture with an
15
+ // announce + one-tap undo; softer imperatives ask first.
16
+ // - Dedupe against existing lessons (normalized text) so the same rule
17
+ // reinforces instead of cluttering the always-on budget.
18
+
19
+ const fs = require("fs");
20
+ const path = require("path");
21
+ const crypto = require("crypto");
22
+ const { atomicWriteFileSync } = require("./fsutil");
23
+
24
+ const CONFIG_DIR = require("../config-dir");
25
+ const lessons = require("./lessons");
26
+
27
+ const PENDING_FILE = path.join(CONFIG_DIR, "guardrail-pending.json");
28
+ const PENDING_TTL_MS = Number(process.env.GUARDRAIL_PENDING_TTL_MS || 24 * 60 * 60 * 1000);
29
+ const MIN_RULE_CHARS = 10;
30
+ const MIN_RULE_WORDS = 3;
31
+ const MAX_RULE_CHARS = lessons.MAX_LESSON_CHARS || 240;
32
+
33
+ // Operability control (mirrors pack-review's PACK_REVIEW gate). Default ON —
34
+ // this is not a hidden kill-switch; it lets the capture path be disabled
35
+ // without a deploy if it ever misfires, and lets tests force it off.
36
+ function enabled() {
37
+ return String(process.env.GUARDRAIL_CAPTURE || "on").toLowerCase() !== "off";
38
+ }
39
+
40
+ // ── detection (pure, deterministic) ─────────────────────────────────
41
+
42
+ // Explicit durable markers → high confidence (auto-capture). Matched anywhere.
43
+ const HIGH_MARKERS = [
44
+ /\bfrom now on\b/i,
45
+ /\bfrom here on(?: out)?\b/i,
46
+ /\bgoing forward\b/i,
47
+ /\bin (?:the )?future\b/i,
48
+ /\bnever again\b/i,
49
+ /\bmake (?:it|this|that) a (?:standing )?rule\b/i,
50
+ /\bas a (?:standing )?rule\b/i,
51
+ /\balways remember to\b/i,
52
+ ];
53
+ // Clear negative directive at the start of a (cleaned) clause → high. "stop"
54
+ // must be followed by a gerund so "stop summarizing" captures but "stop the
55
+ // server" (a one-off action) does not.
56
+ const HIGH_NEG = /^(?:never\s+\w+|stop\s+\w+ing|(?:don'?t|do not) ever\b)/i;
57
+ // Softer imperatives → medium (ask the owner first).
58
+ const MED_OPENER = /^(?:always|don'?t|do not|make sure|be sure|remember to|prefer|avoid)\b/i;
59
+
60
+ // Junk that pattern-matches a directive but is never a rule.
61
+ const STOP_RULES = new Set(["never mind", "nevermind", "stop it", "never ever", "don't worry", "do not worry"]);
62
+
63
+ function splitSentences(text) {
64
+ return String(text || "")
65
+ .split(/(?<=[.!?])\s+|\n+/)
66
+ .map((s) => s.trim())
67
+ .filter(Boolean);
68
+ }
69
+
70
+ function endsAsQuestion(s) {
71
+ return /\?\s*$/.test(String(s || ""));
72
+ }
73
+
74
+ function stripOpener(s) {
75
+ return String(s || "").replace(/^(?:ok(?:ay)?|hey|so|and|also|well|please|now)[,:\s]+/i, "").trim();
76
+ }
77
+
78
+ // Turn a directive sentence into a clean, imperative one-line rule: drop soft
79
+ // openers, leading durable markers, and polite lead-ins; collapse whitespace;
80
+ // capitalize; cap length. Keeps "never/stop/always/make sure" intact — those
81
+ // are the rule's core and drive classification.
82
+ function cleanRule(sentence) {
83
+ let s = stripOpener(sentence);
84
+ s = s.replace(/^(?:from now on|from here on(?: out)?|going forward|in (?:the )?future)[,:\s]+/i, "");
85
+ s = s.replace(/^(?:can you|could you|i(?:'d| would)? (?:like|want) you to|i want you to)\s+/i, "");
86
+ s = s.replace(/\s+/g, " ").trim().replace(/[\s,;:]+$/g, "");
87
+ if (s) s = s[0].toUpperCase() + s.slice(1);
88
+ return s.slice(0, MAX_RULE_CHARS);
89
+ }
90
+
91
+ function ruleOk(rule) {
92
+ if (!rule || rule.length < MIN_RULE_CHARS) return false;
93
+ const low = rule.toLowerCase();
94
+ if (STOP_RULES.has(low)) return false;
95
+ if (low.split(/\s+/).length < MIN_RULE_WORDS) return false;
96
+ return true;
97
+ }
98
+
99
+ // Returns { isRule, confidence: "high"|"medium", rule, trigger } or { isRule:false }.
100
+ // High short-circuits; a medium hit is remembered as the fallback.
101
+ function detect(text) {
102
+ let medium = null;
103
+ for (const s of splitSentences(text)) {
104
+ if (endsAsQuestion(s)) continue;
105
+ const rule = cleanRule(s);
106
+ if (!ruleOk(rule)) continue;
107
+ const hasMarker = HIGH_MARKERS.some((re) => re.test(s));
108
+ if (hasMarker || HIGH_NEG.test(rule)) {
109
+ return { isRule: true, confidence: "high", rule, trigger: hasMarker ? "marker" : "directive" };
110
+ }
111
+ if (!medium && MED_OPENER.test(rule)) medium = { isRule: true, confidence: "medium", rule, trigger: "soft" };
112
+ }
113
+ return medium || { isRule: false };
114
+ }
115
+
116
+ // ── capture (dedupe against existing lessons) ───────────────────────
117
+
118
+ function findDuplicate(rule) {
119
+ const norm = lessons.normalize(rule);
120
+ return lessons.readLessons().find((l) => lessons.normalize(l.text) === norm) || null;
121
+ }
122
+
123
+ // Add the rule as a lesson, or reinforce the existing twin. origin marks
124
+ // provenance (guardrail = owner stated it in chat); lessons meta carries the date.
125
+ function capture(rule, { origin = "guardrail" } = {}) {
126
+ const dup = findDuplicate(rule);
127
+ const target = dup ? dup.text : rule;
128
+ const r = lessons.addLesson({ text: target, origin });
129
+ return {
130
+ added: !!r.added,
131
+ reinforced: !!r.reinforced,
132
+ duplicate: !!dup,
133
+ id: r.id || (dup && dup.id) || lessons.lessonId(target),
134
+ text: target,
135
+ count: r.count,
136
+ overCap: !!r.overCap,
137
+ };
138
+ }
139
+
140
+ // ── pending store (medium-confidence Y/N confirms) ──────────────────
141
+
142
+ function readPending() {
143
+ try { return JSON.parse(fs.readFileSync(PENDING_FILE, "utf-8")) || {}; }
144
+ catch (e) { return {}; }
145
+ }
146
+
147
+ function writePending(obj) {
148
+ fs.mkdirSync(path.dirname(PENDING_FILE), { recursive: true, mode: 0o700 });
149
+ atomicWriteFileSync(PENDING_FILE, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
150
+ }
151
+
152
+ function prunePending(obj, now) {
153
+ for (const [tok, rec] of Object.entries(obj)) {
154
+ if (!rec || now - (rec.createdAt || 0) > PENDING_TTL_MS) delete obj[tok];
155
+ }
156
+ return obj;
157
+ }
158
+
159
+ function putPending(rule, meta = {}, now = Date.now()) {
160
+ const obj = prunePending(readPending(), now);
161
+ const token = crypto.randomBytes(5).toString("hex");
162
+ obj[token] = { rule, channel: meta.channel || null, createdAt: now };
163
+ writePending(obj);
164
+ return token;
165
+ }
166
+
167
+ function takePending(token, now = Date.now()) {
168
+ const obj = prunePending(readPending(), now);
169
+ const rec = obj[token] || null;
170
+ if (rec) { delete obj[token]; }
171
+ writePending(obj);
172
+ return rec;
173
+ }
174
+
175
+ // ── render (pure) ───────────────────────────────────────────────────
176
+
177
+ function clip(s) {
178
+ return String(s || "").replace(/\s+/g, " ").trim().slice(0, 200);
179
+ }
180
+
181
+ function renderCaptured(res) {
182
+ if (!res.added) return `📌 Already a standing rule — reinforced: "${clip(res.text)}"`;
183
+ return `📌 Got it — I'll keep this loaded as a standing rule from now on:\n"${clip(res.text)}"` +
184
+ `${res.overCap ? "\n(Over the lessons cap — the nightly dream will tidy.)" : ""}` +
185
+ `\nTap Undo if that's not what you meant.`;
186
+ }
187
+
188
+ function renderAsk(rule) {
189
+ return `Want me to make this a standing rule (always loaded)?\n"${clip(rule)}"`;
190
+ }
191
+
192
+ // ── orchestrator ────────────────────────────────────────────────────
193
+
194
+ // Owner-gated. `announce(text, opts?)` sends to the chat (opts may carry a
195
+ // keyboard); omit it for a dry run. Returns a small result for testability.
196
+ async function consider({ text, speaker, announce, now = Date.now() } = {}) {
197
+ if (!enabled()) return { action: "disabled" };
198
+ const relationship = require("./relationship");
199
+ if (relationship.guardActive(speaker)) return { action: "external-skip" };
200
+
201
+ const d = detect(text);
202
+ if (!d.isRule) return { action: "silent" };
203
+
204
+ if (d.confidence === "high") {
205
+ const res = capture(d.rule);
206
+ if (typeof announce === "function") {
207
+ const opts = res.added
208
+ ? { keyboard: { inline_keyboard: [[{ text: "↩️ Undo", callback_data: `grd:undo:${res.id}` }]] } }
209
+ : undefined;
210
+ try { await announce(renderCaptured(res), opts); } catch (e) {}
211
+ }
212
+ return { action: res.added ? "captured" : "reinforced", id: res.id, rule: res.text, duplicate: res.duplicate };
213
+ }
214
+
215
+ const token = putPending(d.rule, { channel: speaker && speaker.channelId }, now);
216
+ if (typeof announce === "function") {
217
+ const opts = { keyboard: { inline_keyboard: [[
218
+ { text: "✅ Make it a rule", callback_data: `grd:save:${token}` },
219
+ { text: "Skip", callback_data: `grd:no:${token}` },
220
+ ]] } };
221
+ try { await announce(renderAsk(d.rule), opts); } catch (e) {}
222
+ }
223
+ return { action: "asked", token, rule: d.rule };
224
+ }
225
+
226
+ // ── callback actions (owner-gated by the caller) ────────────────────
227
+
228
+ function confirmPending(token, now = Date.now()) {
229
+ const rec = takePending(token, now);
230
+ if (!rec) return { ok: false, reason: "expired" };
231
+ const res = capture(rec.rule);
232
+ return { ok: true, ...res };
233
+ }
234
+
235
+ function dropPending(token, now = Date.now()) {
236
+ const rec = takePending(token, now);
237
+ return { ok: !!rec, rule: rec ? rec.rule : "" };
238
+ }
239
+
240
+ function undo(lessonId) {
241
+ return { ok: !!lessons.removeLesson(lessonId) };
242
+ }
243
+
244
+ module.exports = {
245
+ PENDING_FILE, PENDING_TTL_MS,
246
+ enabled, detect, cleanRule, ruleOk, splitSentences,
247
+ findDuplicate, capture,
248
+ putPending, takePending, prunePending,
249
+ consider, confirmPending, dropPending, undo,
250
+ renderCaptured, renderAsk,
251
+ };
package/core/handlers.js CHANGED
@@ -12,7 +12,8 @@ const {
12
12
  CHAT_ID, discoverProviderExecutable, SOUL_FILE,
13
13
  } = require("./config");
14
14
  const { register } = require("./commands");
15
- const { send, deleteMessage } = require("./io");
15
+ const { send, deleteMessage, typing } = require("./io");
16
+ const { currentAdapter, currentChannelId } = require("./context");
16
17
  const {
17
18
  currentState, saveState, rootSession,
18
19
  freshUsage, getActiveProvider, getProjectKey, getProviderSession, setProviderSession, clearProviderSession,
@@ -1063,6 +1064,45 @@ register({
1063
1064
  },
1064
1065
  });
1065
1066
 
1067
+ register({
1068
+ name: "agenda", aliases: ["agency"], description: "Aggregated read-only work list (tasks + Spaces + notifications)",
1069
+ handler: async (env) => {
1070
+ if (!authorized(env)) return;
1071
+ const adapter = currentAdapter();
1072
+ const channelId = currentChannelId();
1073
+ const ledger = require("./agency/ledger");
1074
+ try { typing(); } catch (_) {}
1075
+ let result;
1076
+ try {
1077
+ result = await ledger.collect({ adapter: adapter && adapter.id, channelId });
1078
+ } catch (e) {
1079
+ return send(`Couldn't build the agenda: ${e.message}`);
1080
+ }
1081
+ send(ledger.render(result, { now: result.generatedAt, limitPerSource: 8 }));
1082
+ },
1083
+ });
1084
+
1085
+ register({
1086
+ name: "next", description: "Advisory: what I'd pick to do next, and why (acts on nothing)",
1087
+ handler: async (env) => {
1088
+ if (!authorized(env)) return;
1089
+ const adapter = currentAdapter();
1090
+ const channelId = currentChannelId();
1091
+ const ledger = require("./agency/ledger");
1092
+ const deliberate = require("./agency/deliberate");
1093
+ try { typing(); } catch (_) {}
1094
+ let proposal;
1095
+ try {
1096
+ const result = await ledger.collect({ adapter: adapter && adapter.id, channelId });
1097
+ proposal = await deliberate.deliberate(result, { now: result.generatedAt });
1098
+ try { deliberate.record(proposal, { channel: adapter && adapter.id ? `${adapter.id}:${channelId}` : null }); } catch (_) {}
1099
+ } catch (e) {
1100
+ return send(`Couldn't deliberate: ${e.message}`);
1101
+ }
1102
+ send(deliberate.render(proposal));
1103
+ },
1104
+ });
1105
+
1066
1106
  register({
1067
1107
  name: "toolmode", description: "Tooling enforcement mode (strict = forced CLI, relaxed = scripting allowed)", args: "[strict|relaxed]", ownerOnly: true,
1068
1108
  handler: async (env, { tail }) => {
@@ -0,0 +1,165 @@
1
+ // Always-on prompt budget instrumentation (Track X0). One JSONL line per real
2
+ // user turn plus a rolling summary, so we can baseline the fixed prompt floor
3
+ // (soul + lessons + runtime state + slash-command list + tool headlines +
4
+ // relationship/mandate) and watch it grow as new systems are bolted on. This
5
+ // is read-only telemetry: it never changes what is sent to the model, it only
6
+ // measures it. Mirrors the storage shape of core/recall/metrics.js.
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const CONFIG_DIR = require("../config-dir");
11
+
12
+ const LOG_FILE = path.join(CONFIG_DIR, "prompt-budget.jsonl");
13
+ const SUMMARY_FILE = path.join(CONFIG_DIR, "prompt-budget-summary.json");
14
+ let VERSION = "";
15
+ try { VERSION = require("../package.json").version || ""; } catch (e) {}
16
+
17
+ const MAX_LOG_BYTES = 2 * 1024 * 1024; // truncate the jsonl past ~2MB so telemetry never eats disk
18
+
19
+ function enabled() {
20
+ return String(process.env.PROMPT_BUDGET || "on").toLowerCase() !== "off";
21
+ }
22
+
23
+ // Local, network-free token estimate. Markdown/English averages ~4 chars per
24
+ // token; this is deliberately an ESTIMATE — consistency across turns matters
25
+ // more than matching a specific tokenizer, since X0 tracks growth over time.
26
+ function estimateTokens(str) {
27
+ const chars = typeof str === "string" ? str.length : 0;
28
+ return Math.ceil(chars / 4);
29
+ }
30
+
31
+ function part(str) {
32
+ const chars = typeof str === "string" ? str.length : 0;
33
+ return { chars, tokens: estimateTokens(str) };
34
+ }
35
+
36
+ function addSize(a, b) {
37
+ return { chars: a.chars + b.chars, tokens: a.tokens + b.tokens };
38
+ }
39
+
40
+ // Which named prompt components form the fixed "always-on floor" vs the
41
+ // variable, on-demand recall payload and the turn's own user input.
42
+ const ALWAYS_ON_KEYS = [
43
+ "core", // soul + persona + lessons + skill index + tool index + fixed boilerplate
44
+ "runtime", // runtime state block
45
+ "speaker", // speaker + team roster + slash-command list
46
+ "speakerPersona",
47
+ "provider", // provider-native tools note
48
+ "voice",
49
+ "relationship", // external-mode / mandate
50
+ "spacesMode",
51
+ ];
52
+ const VARIABLE_KEYS = ["transcript", "recall"];
53
+
54
+ // Break an assembled turn's named parts into sizes. `parts` keys mirror the
55
+ // components of buildTurnPrompt(); any subset is tolerated.
56
+ function measure(parts = {}) {
57
+ const components = {};
58
+ for (const [k, v] of Object.entries(parts)) components[k] = part(v);
59
+
60
+ let alwaysOn = { chars: 0, tokens: 0 };
61
+ for (const k of ALWAYS_ON_KEYS) if (components[k]) alwaysOn = addSize(alwaysOn, components[k]);
62
+
63
+ let variable = { chars: 0, tokens: 0 };
64
+ for (const k of VARIABLE_KEYS) if (components[k]) variable = addSize(variable, components[k]);
65
+
66
+ const recall = components.recall || { chars: 0, tokens: 0 };
67
+ const userPrompt = components.userPrompt || { chars: 0, tokens: 0 };
68
+ const total = addSize(addSize(alwaysOn, variable), userPrompt);
69
+
70
+ return { components, alwaysOn, recall, variable, userPrompt, total };
71
+ }
72
+
73
+ function readSummary() {
74
+ try { return JSON.parse(fs.readFileSync(SUMMARY_FILE, "utf8")) || {}; } catch (e) { return {}; }
75
+ }
76
+ function writeSummary(s) {
77
+ try { fs.writeFileSync(SUMMARY_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
78
+ }
79
+
80
+ function bumpSummary(m, meta) {
81
+ const s = readSummary();
82
+ s.turns = (s.turns || 0) + 1;
83
+ s.alwaysOnTokensTotal = (s.alwaysOnTokensTotal || 0) + m.alwaysOn.tokens;
84
+ s.recallTokensTotal = (s.recallTokensTotal || 0) + m.recall.tokens;
85
+ s.totalTokensTotal = (s.totalTokensTotal || 0) + m.total.tokens;
86
+ s.alwaysOnTokensMax = Math.max(s.alwaysOnTokensMax || 0, m.alwaysOn.tokens);
87
+ s.componentTokensTotal = s.componentTokensTotal || {};
88
+ for (const [k, v] of Object.entries(m.components)) {
89
+ s.componentTokensTotal[k] = (s.componentTokensTotal[k] || 0) + v.tokens;
90
+ }
91
+ // Latest snapshot — the current per-component breakdown ("what is the floor
92
+ // right now"), distinct from the rolling averages above.
93
+ s.latest = {
94
+ ts: new Date().toISOString(),
95
+ v: VERSION,
96
+ provider: meta.provider || "",
97
+ alwaysOnTokens: m.alwaysOn.tokens,
98
+ recallTokens: m.recall.tokens,
99
+ totalTokens: m.total.tokens,
100
+ components: Object.fromEntries(Object.entries(m.components).map(([k, v]) => [k, v.tokens])),
101
+ };
102
+ s.updatedAt = new Date().toISOString();
103
+ writeSummary(s);
104
+ }
105
+
106
+ function appendLog(rec) {
107
+ try {
108
+ try {
109
+ const st = fs.statSync(LOG_FILE);
110
+ if (st.size > MAX_LOG_BYTES) fs.rmSync(LOG_FILE, { force: true });
111
+ } catch (e) {}
112
+ fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
113
+ } catch (e) {}
114
+ }
115
+
116
+ // Record one real turn's prompt budget. Best-effort; never throws.
117
+ function record(measurement, meta = {}) {
118
+ if (!enabled()) return;
119
+ try {
120
+ const m = measurement && measurement.total ? measurement : null;
121
+ if (!m) return;
122
+ const rec = {
123
+ ts: new Date().toISOString(),
124
+ v: VERSION,
125
+ provider: meta.provider || "",
126
+ runId: meta.runId || null,
127
+ alwaysOnTokens: m.alwaysOn.tokens,
128
+ recallTokens: m.recall.tokens,
129
+ totalTokens: m.total.tokens,
130
+ components: Object.fromEntries(Object.entries(m.components).map(([k, v]) => [k, v.tokens])),
131
+ };
132
+ appendLog(rec);
133
+ bumpSummary(m, meta);
134
+ } catch (e) {}
135
+ }
136
+
137
+ // Human-readable rolling view (rendered by `open-claudia prompt-budget`).
138
+ function summary() {
139
+ const s = readSummary();
140
+ const turns = s.turns || 0;
141
+ const avg = (total) => (turns ? Math.round((total || 0) / turns) : 0);
142
+ const componentAvg = {};
143
+ for (const [k, v] of Object.entries(s.componentTokensTotal || {})) componentAvg[k] = avg(v);
144
+ return {
145
+ turns,
146
+ avgAlwaysOnTokens: avg(s.alwaysOnTokensTotal),
147
+ maxAlwaysOnTokens: s.alwaysOnTokensMax || 0,
148
+ avgRecallTokens: avg(s.recallTokensTotal),
149
+ avgTotalTokens: avg(s.totalTokensTotal),
150
+ componentAvgTokens: componentAvg,
151
+ latest: s.latest || null,
152
+ updatedAt: s.updatedAt || "",
153
+ };
154
+ }
155
+
156
+ function _resetForTest() {
157
+ try { fs.rmSync(LOG_FILE, { force: true }); } catch (e) {}
158
+ try { fs.rmSync(SUMMARY_FILE, { force: true }); } catch (e) {}
159
+ }
160
+
161
+ module.exports = {
162
+ LOG_FILE, SUMMARY_FILE, VERSION, enabled,
163
+ ALWAYS_ON_KEYS, VARIABLE_KEYS,
164
+ estimateTokens, measure, record, summary, _resetForTest,
165
+ };
package/core/runner.js CHANGED
@@ -761,7 +761,7 @@ function createRunnerService(dependencies = {}) {
761
761
  let bundle;
762
762
  let invocation;
763
763
  try {
764
- bundle = await promptBuilder(prompt, { runContext, provider });
764
+ bundle = await promptBuilder(prompt, { runContext, provider, recordBudget: !capture });
765
765
  invocation = invocationBuilder(provider, invocationContextForRun(runContext, bundle, provider));
766
766
  if (dependencies.decorateInvocation) {
767
767
  invocation = dependencies.decorateInvocation(invocation, runContext, bundle, provider);
@@ -1860,6 +1860,23 @@ async function handoffMemoryReview(result, runContext, store, state) {
1860
1860
  });
1861
1861
  }
1862
1862
 
1863
+ // Track C — owner-guardrail capture. After a foreground turn, a deterministic
1864
+ // detector spots durable-rule statements ("from now on / never / stop") in the
1865
+ // owner's message and captures them into lessons.md (or asks first). Owner-only
1866
+ // and best-effort: it never blocks or breaks a turn.
1867
+ async function considerGuardrail(result, runContext, store) {
1868
+ try {
1869
+ if (!result.ok || runContext.purpose !== "foreground") return;
1870
+ const guardrail = require("./guardrail");
1871
+ if (!guardrail.enabled()) return;
1872
+ const relationship = require("./relationship");
1873
+ const speaker = relationship.speakerFor(store?.adapter?.type, store?.channelId, store?.userId);
1874
+ if (relationship.guardActive(speaker)) return; // external speakers never capture
1875
+ const announce = (text, opts) => chatContext.run(store, () => send(text, opts));
1876
+ await guardrail.consider({ text: runContext.userPrompt, speaker, announce });
1877
+ } catch (e) { /* best-effort — a guardrail miss must never break a turn */ }
1878
+ }
1879
+
1863
1880
  function deferredRunResult() {
1864
1881
  let resolve;
1865
1882
  let reject;
@@ -1920,6 +1937,7 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
1920
1937
  try { service.turnObserver.onTurnSettled(!!result.ok); } catch (e) { /* best-effort */ }
1921
1938
  }
1922
1939
  if (!internalCapture) await handoffMemoryReview(result, runContext, store, state);
1940
+ if (!internalCapture) await considerGuardrail(result, runContext, store);
1923
1941
  if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1924
1942
  state.settings.budget = null;
1925
1943
  try { saveState({ strict: true }); }
@@ -1982,7 +2000,13 @@ function runAgent(prompt, cwd, replyToMsgId, opts = {}) {
1982
2000
  }
1983
2001
  const deferred = deferredRunResult();
1984
2002
  state.messageQueue.push({ runContext, store, deferred, queuedAt: Date.now() });
1985
- send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId }).catch(() => {});
2003
+ // Transient queue/compaction acks are a chat-UX affordance (Telegram/Kazee).
2004
+ // On a Spaces task thread they'd post a throwaway "Queued." comment on the
2005
+ // task — noise. Spaces stays quiet and just posts the real reply when its
2006
+ // turn runs.
2007
+ if (currentAdapter()?.type !== "spaces") {
2008
+ send(state.isCompacting ? "Compacting context, will pick this up next…" : "Queued.", { replyTo: replyToMsgId }).catch(() => {});
2009
+ }
1986
2010
  return deferred.promise;
1987
2011
  }
1988
2012
  return executeAdmittedRun(runContext, state, store, false, true);
package/core/state.js CHANGED
@@ -652,6 +652,21 @@ function acquireRunLock(state = currentState()) {
652
652
  return true;
653
653
  }
654
654
 
655
+ // True when ANY user has a turn running, admitted-and-preparing, compacting, or
656
+ // waiting in its queue. Same busy predicate as acquireRunLock, but across every
657
+ // user state rather than one — the Telegram poll-wedge backstop consults this
658
+ // before a self-exit so a reboot never interrupts in-flight work. It fires on a
659
+ // timer outside any channel context (no "current" user) and a reboot kills the
660
+ // whole process, so it must scan all states, not just currentState().
661
+ function anyTurnActive() {
662
+ for (const s of userStates.values()) {
663
+ if (!s) continue;
664
+ if (s.runningProcess || s.preparingRun || s.isCompacting) return true;
665
+ if (Array.isArray(s.messageQueue) && s.messageQueue.length) return true;
666
+ }
667
+ return false;
668
+ }
669
+
655
670
  function migrationSources() {
656
671
  return defaultMigrationSources({
657
672
  configDir: CONFIG_DIR,
@@ -1363,6 +1378,7 @@ module.exports = {
1363
1378
  resetSessionUsage,
1364
1379
  resetSettings,
1365
1380
  acquireRunLock,
1381
+ anyTurnActive,
1366
1382
  saveState,
1367
1383
  loadSessions,
1368
1384
  loadSessionsDocument,
@@ -15,6 +15,7 @@ const {
15
15
  const tasksStore = require("./tasks");
16
16
  const people = require("./people");
17
17
  const commandsRegistry = require("./commands");
18
+ const promptBudget = require("./prompt-budget");
18
19
 
19
20
  function loadSoul() {
20
21
  const soul = fs.readFileSync(SOUL_FILE, "utf-8");
@@ -1068,12 +1069,40 @@ function createPromptBuilder(dependencies = {}) {
1068
1069
  spacesTaskMode,
1069
1070
  ].filter((part) => typeof part === "string" && part.trim()).join("\n\n");
1070
1071
 
1072
+ // Track X0: measure the fixed always-on floor vs the variable recall
1073
+ // payload for this turn. Pure measurement — attached to the bundle for
1074
+ // any caller; only recorded to telemetry when the caller opts in
1075
+ // (real user turns, never sub-agent/capture builds).
1076
+ let budget = null;
1077
+ try {
1078
+ budget = promptBudget.measure({
1079
+ core: coreInstructions,
1080
+ transcript: transcriptContext,
1081
+ runtime: runtimeContext,
1082
+ speaker: speakerContext,
1083
+ speakerPersona,
1084
+ provider: providerContext,
1085
+ recall: recallResult.dynamicContext,
1086
+ voice: voiceContext,
1087
+ relationship: relationshipContext,
1088
+ spacesMode: spacesTaskMode,
1089
+ userPrompt,
1090
+ });
1091
+ if (opts.recordBudget) {
1092
+ promptBudget.record(budget, {
1093
+ provider: opts.provider?.id,
1094
+ runId: opts.runContext?.runId,
1095
+ });
1096
+ }
1097
+ } catch (_) { budget = null; }
1098
+
1071
1099
  return {
1072
1100
  coreInstructions,
1073
1101
  dynamicContext,
1074
1102
  userPrompt,
1075
1103
  transcriptPaths,
1076
1104
  recallMetadata: recallResult.metadata,
1105
+ promptBudget: budget,
1077
1106
  };
1078
1107
  },
1079
1108
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.25",
3
+ "version": "3.0.27",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js",
12
+ "pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js",
13
13
  "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
14
14
  },
15
15
  "files": [
@@ -66,6 +66,10 @@
66
66
  "test-identity-prune.js",
67
67
  "test-kazee-message-id.js",
68
68
  "test-kazee-channel-health.js",
69
+ "test-prompt-budget.js",
70
+ "test-agency-ledger.js",
71
+ "test-agency-deliberate.js",
72
+ "test-guardrail.js",
69
73
  "test-delivery-contract.js",
70
74
  "test-run-lock.js",
71
75
  "test-web-sessions.js",