@inetafrica/open-claudia 3.0.26 → 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,126 @@
1
+ // Track C — dynamic owner-guardrail capture. Deterministic detection tiers,
2
+ // the hard owner gate, C0 detect+confirm (Y/N pending), C1 auto-capture with
3
+ // undo, and dedupe against existing lessons. No network, redirected config dir.
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "oc-guard-"));
11
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
12
+ process.env.GUARDRAIL_CAPTURE = "on";
13
+
14
+ const G = require("./core/guardrail");
15
+ const lessons = require("./core/lessons");
16
+
17
+ const OWNER = { isOwner: true, channelId: "telegram:1" };
18
+ const EXTERNAL = { isOwner: false, channelId: "telegram:9" };
19
+
20
+ function resetLessons() {
21
+ try { fs.rmSync(lessons.LESSONS_FILE, { force: true }); } catch (e) {}
22
+ try { fs.rmSync(lessons.LESSONS_META_FILE, { force: true }); } catch (e) {}
23
+ try { fs.rmSync(G.PENDING_FILE, { force: true }); } catch (e) {}
24
+ }
25
+
26
+ // ── detection tiers ─────────────────────────────────────────────────
27
+
28
+ // high: explicit durable marker → clean imperative, marker stripped
29
+ let d = G.detect("From now on, always send the deep link after posting a Spaces comment.");
30
+ assert.strictEqual(d.isRule, true);
31
+ assert.strictEqual(d.confidence, "high");
32
+ assert.strictEqual(d.trigger, "marker");
33
+ assert.ok(/send the deep link/i.test(d.rule));
34
+ assert.ok(!/from now on/i.test(d.rule), "leading marker stripped");
35
+
36
+ // high: bare negative directive (never / stop-gerund)
37
+ assert.strictEqual(G.detect("Never send voice notes by default.").confidence, "high");
38
+ assert.strictEqual(G.detect("Stop summarizing every response.").confidence, "high");
39
+ assert.strictEqual(G.detect("Don't ever deploy on Fridays.").confidence, "high");
40
+
41
+ // medium: softer imperative → ask first
42
+ const dm = G.detect("Always double-check the staging config before a deploy.");
43
+ assert.strictEqual(dm.confidence, "medium");
44
+ assert.strictEqual(G.detect("Please don't touch prod without asking me first.").confidence, "medium");
45
+
46
+ // silence: questions, past-tense "never", one-off "stop <noun>", junk
47
+ assert.strictEqual(G.detect("What colour are the fish?").isRule, false);
48
+ assert.strictEqual(G.detect("I never got the email from Emmanuel.").isRule, false);
49
+ assert.strictEqual(G.detect("Stop the server.").isRule, false, "stop + non-gerund is a one-off action");
50
+ assert.strictEqual(G.detect("Never mind.").isRule, false, "below the word floor");
51
+ assert.strictEqual(G.detect("Make it a rule?").isRule, false, "a question is not a directive");
52
+
53
+ // ── owner gate (hard) ───────────────────────────────────────────────
54
+ (async () => {
55
+ resetLessons();
56
+ const ext = await G.consider({ text: "From now on always run the linter first.", speaker: EXTERNAL, announce: null });
57
+ assert.strictEqual(ext.action, "external-skip", "external speaker never captures");
58
+ assert.strictEqual(lessons.readLessons().length, 0, "nothing written for an external");
59
+
60
+ // ── C1: high-confidence auto-capture + announce with Undo button ──
61
+ resetLessons();
62
+ const calls = [];
63
+ const announce = (text, opts) => { calls.push({ text, opts }); };
64
+ const hi = await G.consider({ text: "From now on, always send the deep link after posting a comment.", speaker: OWNER, announce });
65
+ assert.strictEqual(hi.action, "captured");
66
+ assert.strictEqual(calls.length, 1, "announced once");
67
+ assert.ok(calls[0].text.includes("standing rule"));
68
+ const undoBtn = calls[0].opts.keyboard.inline_keyboard[0][0];
69
+ assert.strictEqual(undoBtn.callback_data, `grd:undo:${hi.id}`, "undo button carries the lesson id");
70
+ const stored = lessons.readLessons();
71
+ assert.strictEqual(stored.length, 1, "one lesson written");
72
+ assert.ok(/send the deep link/i.test(stored[0].text));
73
+ // provenance: origin recorded as guardrail
74
+ assert.strictEqual((lessons.readMeta()[hi.id] || {}).origin, "guardrail");
75
+
76
+ // one-tap undo removes it
77
+ const un = G.undo(hi.id);
78
+ assert.strictEqual(un.ok, true);
79
+ assert.strictEqual(lessons.readLessons().length, 0, "undo removed the rule");
80
+
81
+ // ── dedupe: same rule twice reinforces, never duplicates ──
82
+ resetLessons();
83
+ const a = await G.consider({ text: "Never commit the .env file.", speaker: OWNER, announce: null });
84
+ assert.strictEqual(a.action, "captured");
85
+ const b = await G.consider({ text: "Never commit the .env file.", speaker: OWNER, announce: null });
86
+ assert.strictEqual(b.action, "reinforced", "second identical rule reinforces");
87
+ assert.strictEqual(lessons.readLessons().length, 1, "no duplicate lesson");
88
+
89
+ // ── C0: medium-confidence asks first (pending), then confirm captures ──
90
+ resetLessons();
91
+ const calls2 = [];
92
+ const announce2 = (text, opts) => { calls2.push({ text, opts }); };
93
+ const med = await G.consider({ text: "Always run the full test suite before pushing.", speaker: OWNER, announce: announce2 });
94
+ assert.strictEqual(med.action, "asked");
95
+ assert.ok(med.token, "a pending token is issued");
96
+ assert.strictEqual(lessons.readLessons().length, 0, "nothing captured until confirmed");
97
+ const kb = calls2[0].opts.keyboard.inline_keyboard[0];
98
+ assert.strictEqual(kb[0].callback_data, `grd:save:${med.token}`);
99
+ assert.strictEqual(kb[1].callback_data, `grd:no:${med.token}`);
100
+
101
+ // confirm → capture; token is one-shot (second confirm expired)
102
+ const ok = G.confirmPending(med.token);
103
+ assert.strictEqual(ok.ok, true);
104
+ assert.strictEqual(ok.added, true);
105
+ assert.strictEqual(lessons.readLessons().length, 1, "confirm captured the rule");
106
+ assert.strictEqual(G.confirmPending(med.token).ok, false, "token is single-use");
107
+
108
+ // drop → discards the pending without capturing
109
+ resetLessons();
110
+ const med2 = await G.consider({ text: "Prefer GitOps over direct kubectl for cluster changes.", speaker: OWNER, announce: null });
111
+ assert.strictEqual(med2.action, "asked");
112
+ assert.strictEqual(G.dropPending(med2.token).ok, true);
113
+ assert.strictEqual(lessons.readLessons().length, 0, "drop captured nothing");
114
+ assert.strictEqual(G.confirmPending(med2.token).ok, false, "dropped token is gone");
115
+
116
+ // ── enabled gate ──
117
+ resetLessons();
118
+ process.env.GUARDRAIL_CAPTURE = "off";
119
+ const off = await G.consider({ text: "From now on always foo the bar.", speaker: OWNER, announce: null });
120
+ assert.strictEqual(off.action, "disabled");
121
+ assert.strictEqual(lessons.readLessons().length, 0, "disabled captures nothing");
122
+ process.env.GUARDRAIL_CAPTURE = "on";
123
+
124
+ try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
125
+ console.log("test-guardrail: OK");
126
+ })();
@@ -0,0 +1,78 @@
1
+ // Track X0 — always-on prompt budget instrumentation.
2
+ // Verifies the measure/record/summary contract without touching the real
3
+ // config dir (redirected to a temp OPEN_CLAUDIA_CONFIG_DIR before require).
4
+
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "oc-budget-"));
11
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
12
+ process.env.PROMPT_BUDGET = "on";
13
+
14
+ const budget = require("./core/prompt-budget");
15
+ budget._resetForTest();
16
+
17
+ // --- estimateTokens: ~chars/4, never negative, tolerant of non-strings ---
18
+ assert.strictEqual(budget.estimateTokens(""), 0);
19
+ assert.strictEqual(budget.estimateTokens("abcd"), 1);
20
+ assert.strictEqual(budget.estimateTokens("abcde"), 2); // ceil(5/4)
21
+ assert.strictEqual(budget.estimateTokens(null), 0);
22
+ assert.strictEqual(budget.estimateTokens(undefined), 0);
23
+
24
+ // --- measure: always-on vs recall vs userPrompt split ---
25
+ const m = budget.measure({
26
+ core: "x".repeat(400), // 100 tok, always-on
27
+ runtime: "y".repeat(40), // 10 tok, always-on
28
+ speaker: "z".repeat(40), // 10 tok, always-on
29
+ recall: "r".repeat(200), // 50 tok, VARIABLE
30
+ transcript: "t".repeat(40), // 10 tok, VARIABLE
31
+ userPrompt: "u".repeat(20), // 5 tok, user
32
+ });
33
+ assert.strictEqual(m.alwaysOn.tokens, 120, "always-on = core+runtime+speaker");
34
+ assert.strictEqual(m.recall.tokens, 50, "recall isolated");
35
+ assert.strictEqual(m.variable.tokens, 60, "variable = recall + transcript");
36
+ assert.strictEqual(m.userPrompt.tokens, 5);
37
+ assert.strictEqual(m.total.tokens, 120 + 60 + 5, "total = alwaysOn + variable + userPrompt");
38
+ assert.strictEqual(m.components.core.tokens, 100);
39
+
40
+ // recall must NOT be counted in the always-on floor
41
+ const m2 = budget.measure({ core: "a".repeat(4), recall: "b".repeat(4000) });
42
+ assert.strictEqual(m2.alwaysOn.tokens, 1, "recall excluded from always-on floor");
43
+ assert.strictEqual(m2.recall.tokens, 1000);
44
+
45
+ // --- record + summary: rolling averages, max, latest snapshot ---
46
+ budget.record(budget.measure({ core: "x".repeat(400) }), { provider: "claude", runId: "r1" }); // 100
47
+ budget.record(budget.measure({ core: "x".repeat(800) }), { provider: "claude", runId: "r2" }); // 200
48
+ const s = budget.summary();
49
+ assert.strictEqual(s.turns, 2);
50
+ assert.strictEqual(s.avgAlwaysOnTokens, 150, "avg of 100 & 200");
51
+ assert.strictEqual(s.maxAlwaysOnTokens, 200);
52
+ assert.ok(s.latest && s.latest.alwaysOnTokens === 200, "latest snapshot is the last record");
53
+ assert.strictEqual(s.componentAvgTokens.core, 150);
54
+
55
+ // jsonl written with one line per record
56
+ assert.ok(fs.existsSync(budget.LOG_FILE), "jsonl log written");
57
+ const lines = fs.readFileSync(budget.LOG_FILE, "utf8").trim().split("\n");
58
+ assert.strictEqual(lines.length, 2);
59
+ const first = JSON.parse(lines[0]);
60
+ assert.strictEqual(first.alwaysOnTokens, 100);
61
+ assert.strictEqual(first.provider, "claude");
62
+
63
+ // --- record is best-effort: junk input never throws, never records ---
64
+ const before = budget.summary().turns;
65
+ budget.record(null);
66
+ budget.record({});
67
+ budget.record({ total: null });
68
+ assert.strictEqual(budget.summary().turns, before, "invalid measurements are ignored");
69
+
70
+ // --- disabled gate ---
71
+ budget._resetForTest();
72
+ process.env.PROMPT_BUDGET = "off";
73
+ budget.record(budget.measure({ core: "x".repeat(400) }), { provider: "claude" });
74
+ assert.strictEqual(budget.summary().turns, 0, "PROMPT_BUDGET=off disables recording");
75
+ process.env.PROMPT_BUDGET = "on";
76
+
77
+ try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
78
+ console.log("test-prompt-budget: OK");
@@ -32,16 +32,18 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.26 approved by Sumeet 2026-07-15: fix Spaces reply posting. (1) An engaged
36
- // reply no longer 400s send() only threads under an explicit "comment:"-prefixed
37
- // id (the triggering reply/comment-mention) and otherwise posts a top-level task
38
- // comment, with a top-level retry if a stale parent 400s. (2) The bot threads its
39
- // reply under the exact triggering comment when the notification names one
40
- // (replyId / comment-target). (3) The transient "Queued." ack is suppressed on
41
- // Spaces a stacked turn stays silent and just posts the real reply when it runs,
42
- // instead of dropping a throwaway "Queued." comment on the task. Builds on 3.0.25's
43
- // comment:typing emit.
44
- assert.strictEqual(pkg.version, "3.0.26", "release version must remain unchanged without explicit approval");
35
+ // 3.0.27 approved by Sumeet 2026-07-15 ("work through till it's done and push it
36
+ // out"): the non-gated architecture batch. (1) X0 always-on prompt-budget
37
+ // instrumentation: measure the fixed floor vs per-turn recall payload, telemetry
38
+ // only (no prompt change, no trim). (2) B0 /agenda read-only work ledger
39
+ // (tasks + Spaces + notifications). (3) B1 /next advisory deliberation that
40
+ // proposes a pick and acts on nothing. (4) C0/C1 owner-guardrail capture:
41
+ // "from now on / never / stop" statements become always-loaded lessons (asked or
42
+ // auto-captured with announce + one-tap undo), owner-only, never from externals.
43
+ // (5) Turn-aware reboot: the Telegram 10-min poll-wedge backstop defers its
44
+ // self-exit while any turn is running/queued so a restart never interrupts
45
+ // in-flight work. B2+ autonomy and Track A stay gated.
46
+ assert.strictEqual(pkg.version, "3.0.27", "release version must remain unchanged without explicit approval");
45
47
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
46
48
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
47
49
  }
@@ -1,6 +1,16 @@
1
1
  "use strict";
2
2
 
3
3
  const assert = require("assert");
4
+ const fs = require("fs");
5
+ const os = require("os");
6
+ const path = require("path");
7
+
8
+ // Redirect config before loading the adapter so the idle wedge-exit test's
9
+ // recordExitReason writes to a throwaway dir, never the real
10
+ // ~/.open-claudia/last-exit.json (which would make the next real boot report a
11
+ // phantom "polling wedged" restart reason).
12
+ process.env.OPEN_CLAUDIA_CONFIG_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "oc-poll-"));
13
+
4
14
  const { TelegramAdapter } = require("./channels/telegram/adapter");
5
15
 
6
16
  async function testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog() {
@@ -165,11 +175,82 @@ async function test409ConflictSeversGhostSocketAndRestartsIdle() {
165
175
  }
166
176
  }
167
177
 
178
+ // The 10-min wedge backstop must NEVER reboot mid-turn: a restart there kills
179
+ // in-flight work and breaks the seamless resume. While a turn is active the heal
180
+ // defers the exit and falls through to in-place recovery (stop/destroy/start)
181
+ // instead — which may itself clear the wedge with no restart at all.
182
+ async function testWedgeExitDefersWhileTurnInFlight() {
183
+ const calls = [];
184
+ let exited = false;
185
+ const adapter = Object.create(TelegramAdapter.prototype);
186
+ adapter._healTimer = null;
187
+ adapter._healthyTimer = null;
188
+ adapter._conflictSince = 0;
189
+ adapter._wedgedSince = Date.now() - 11 * 60 * 1000; // wedged >10min
190
+ adapter._deferredExitLogAt = 0;
191
+ adapter._healBackoff = 0;
192
+ adapter._anyTurnActive = () => true; // a turn is running/queued
193
+ adapter._agent = { destroy() { calls.push("destroy"); } };
194
+ adapter.bot = {
195
+ async stopPolling() { calls.push("stop"); },
196
+ async startPolling() { calls.push("start"); },
197
+ };
198
+
199
+ const realExit = process.exit;
200
+ process.exit = () => { exited = true; throw new Error("process.exit must not be called mid-turn"); };
201
+ const realSetTimeout = global.setTimeout;
202
+ const realClearTimeout = global.clearTimeout;
203
+ global.setTimeout = (fn, ms) => { if (ms === 500) fn(); return { ms }; };
204
+ global.clearTimeout = () => {};
205
+
206
+ try {
207
+ await adapter._heal();
208
+ } finally {
209
+ process.exit = realExit;
210
+ global.setTimeout = realSetTimeout;
211
+ global.clearTimeout = realClearTimeout;
212
+ }
213
+
214
+ assert.strictEqual(exited, false, "a wedge backstop must NOT exit while a turn is in flight");
215
+ assert.deepStrictEqual(calls, ["stop", "destroy", "start"],
216
+ "a deferred wedge must still attempt in-place recovery instead of rebooting");
217
+ assert.ok(adapter._deferredExitLogAt > 0, "the deferral must be logged (throttle stamp set)");
218
+ }
219
+
220
+ // Once the process is idle (no turn running/queued), the same backstop exits for
221
+ // a clean restart exactly as before — the deferral only postpones, never cancels.
222
+ async function testWedgeExitFiresWhenIdle() {
223
+ let exitCode;
224
+ const adapter = Object.create(TelegramAdapter.prototype);
225
+ adapter._healTimer = null;
226
+ adapter._conflictSince = 0;
227
+ adapter._wedgedSince = Date.now() - 11 * 60 * 1000; // wedged >10min
228
+ adapter._deferredExitLogAt = 0;
229
+ adapter._anyTurnActive = () => false; // idle
230
+ adapter._agent = { destroy() {} };
231
+ adapter.bot = { async stopPolling() {}, async startPolling() {} };
232
+
233
+ const realExit = process.exit;
234
+ process.exit = (code) => { exitCode = code; throw new Error("__exit__"); };
235
+ try {
236
+ await adapter._heal();
237
+ assert.fail("the idle wedge backstop must exit");
238
+ } catch (e) {
239
+ if (e.message !== "__exit__") throw e;
240
+ } finally {
241
+ process.exit = realExit;
242
+ }
243
+
244
+ assert.strictEqual(exitCode, 1, "the idle wedge backstop must exit(1) for a clean restart");
245
+ }
246
+
168
247
  (async () => {
169
248
  await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
170
249
  await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
171
250
  await testStopPollingCleanDoesNotHangOnAWedgedStop();
172
251
  await test409ConflictSeversGhostSocketAndRestartsIdle();
252
+ await testWedgeExitDefersWhileTurnInFlight();
253
+ await testWedgeExitFiresWhenIdle();
173
254
  console.log("telegram poll recovery OK");
174
255
  })().catch((err) => {
175
256
  console.error(err.stack || err);