@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.
- package/CHANGELOG.md +16 -0
- package/bin/agency-ledger.js +91 -0
- package/bin/cli.js +15 -0
- package/bin/prompt-budget.js +49 -0
- package/bin/recall.js +6 -0
- package/channels/spaces/adapter.js +33 -8
- package/channels/telegram/adapter.js +26 -3
- package/core/actions.js +25 -0
- package/core/agency/deliberate.js +332 -0
- package/core/agency/ledger.js +294 -0
- package/core/connected-apps.js +15 -0
- package/core/guardrail.js +251 -0
- package/core/handlers.js +41 -1
- package/core/prompt-budget.js +165 -0
- package/core/runner.js +26 -2
- package/core/state.js +16 -0
- package/core/system-prompt.js +29 -0
- package/package.json +6 -2
- package/test-agency-deliberate.js +119 -0
- package/test-agency-ledger.js +114 -0
- package/test-guardrail.js +126 -0
- package/test-prompt-budget.js +78 -0
- package/test-provider-language.js +12 -9
- package/test-telegram-poll-recovery.js +81 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Track B1 — advisory deliberation. Deterministic triage/rank, confidence floor
|
|
2
|
+
// and clear-margin behaviour, the injectable judge (including failure fallback),
|
|
3
|
+
// and best-effort shadow logging. 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-delib-"));
|
|
11
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
|
|
12
|
+
|
|
13
|
+
const D = require("./core/agency/deliberate");
|
|
14
|
+
|
|
15
|
+
const NOW = Date.parse("2026-07-15T12:00:00.000Z");
|
|
16
|
+
const DAY = 86400000;
|
|
17
|
+
|
|
18
|
+
function entry(o) {
|
|
19
|
+
return Object.assign(
|
|
20
|
+
{ source: "task", kind: "task", id: "x", title: "t", status: "open",
|
|
21
|
+
priority: null, due: null, lastTouched: NOW, staleDays: 0, blockedOn: null,
|
|
22
|
+
overdue: false, meta: {} },
|
|
23
|
+
o,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
function ledgerOf(entries) {
|
|
27
|
+
return { generatedAt: NOW, counts: { total: entries.length }, entries };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// --- triage: notifications never candidates; bare open local task isn't either
|
|
31
|
+
assert.strictEqual(D.isCandidate(entry({ source: "notification", status: "info" }), NOW), false);
|
|
32
|
+
assert.strictEqual(D.isCandidate(entry({ source: "task", status: "open", due: null }), NOW), false);
|
|
33
|
+
assert.strictEqual(D.isCandidate(entry({ source: "task", status: "in_progress" }), NOW), true);
|
|
34
|
+
assert.strictEqual(D.isCandidate(entry({ source: "spaces", staleDays: 3 }), NOW), true);
|
|
35
|
+
|
|
36
|
+
// --- scoreOf: overdue dominates, signals surface
|
|
37
|
+
const sc = D.scoreOf(entry({ overdue: true, due: NOW - 2 * DAY, priority: "high" }), NOW);
|
|
38
|
+
assert.ok(sc.score > 100, "overdue+high scores high");
|
|
39
|
+
assert.ok(sc.signals.some((s) => s.k === "overdue"));
|
|
40
|
+
assert.ok(sc.signals.some((s) => s.k === "priority"));
|
|
41
|
+
|
|
42
|
+
(async () => {
|
|
43
|
+
// --- clear winner: overdue high-priority Spaces task beats an in-progress task
|
|
44
|
+
const p1 = await D.deliberate(ledgerOf([
|
|
45
|
+
entry({ source: "spaces", id: "s1", title: "Overdue!", overdue: true, due: NOW - 2 * DAY, priority: "high", staleDays: 2 }),
|
|
46
|
+
entry({ source: "task", id: "t1", title: "WIP", status: "in_progress", staleDays: 1 }),
|
|
47
|
+
entry({ source: "notification", id: "n1", status: "info" }),
|
|
48
|
+
]), { now: NOW });
|
|
49
|
+
assert.strictEqual(p1.proposed, true);
|
|
50
|
+
assert.strictEqual(p1.pick.id, "s1", "overdue item picked");
|
|
51
|
+
assert.strictEqual(p1.confidence, "high", "clear margin → high confidence");
|
|
52
|
+
assert.strictEqual(p1.method, "deterministic");
|
|
53
|
+
assert.strictEqual(p1.actionType, "ask", "overdue Spaces assignment → nudge");
|
|
54
|
+
assert.ok(p1.why.join(" ").includes("overdue"));
|
|
55
|
+
assert.ok(D.render(p1).includes("Overdue!"));
|
|
56
|
+
|
|
57
|
+
// --- default-silence: lone far-future item scores below the floor
|
|
58
|
+
const p2 = await D.deliberate(ledgerOf([
|
|
59
|
+
entry({ source: "spaces", id: "s2", title: "Someday", due: NOW + 20 * DAY, staleDays: 1 }),
|
|
60
|
+
]), { now: NOW });
|
|
61
|
+
assert.strictEqual(p2.proposed, false, "below confidence floor → propose nothing");
|
|
62
|
+
assert.ok(D.render(p2).includes("Nothing pressing"));
|
|
63
|
+
|
|
64
|
+
// --- ambiguous + judge OFF → deterministic, medium confidence
|
|
65
|
+
const twoWip = ledgerOf([
|
|
66
|
+
entry({ source: "task", id: "t1", title: "WIP one", status: "in_progress", staleDays: 1 }),
|
|
67
|
+
entry({ source: "task", id: "t2", title: "WIP two", status: "in_progress", staleDays: 1 }),
|
|
68
|
+
]);
|
|
69
|
+
const p3 = await D.deliberate(twoWip, { now: NOW });
|
|
70
|
+
assert.strictEqual(p3.proposed, true);
|
|
71
|
+
assert.strictEqual(p3.method, "deterministic");
|
|
72
|
+
assert.strictEqual(p3.confidence, "medium", "close margin → medium");
|
|
73
|
+
|
|
74
|
+
// --- ambiguous + injected judge overrides the pick + action
|
|
75
|
+
let judgeSaw = null;
|
|
76
|
+
const p4 = await D.deliberate(twoWip, {
|
|
77
|
+
now: NOW, useJudge: true,
|
|
78
|
+
judge: async (top) => { judgeSaw = top.map((c) => c.id); return { pickId: "t2", actionType: "schedule", why: "second is riper", confidence: "high" }; },
|
|
79
|
+
});
|
|
80
|
+
assert.deepStrictEqual(judgeSaw, ["t1", "t2"], "judge sees the top-N");
|
|
81
|
+
assert.strictEqual(p4.method, "judge");
|
|
82
|
+
assert.strictEqual(p4.pick.id, "t2", "judge override applied");
|
|
83
|
+
assert.strictEqual(p4.actionType, "schedule");
|
|
84
|
+
assert.strictEqual(p4.judgeWhy, "second is riper");
|
|
85
|
+
|
|
86
|
+
// --- judge failure falls back to deterministic, never throws
|
|
87
|
+
const p5 = await D.deliberate(twoWip, { now: NOW, useJudge: true, judge: async () => { throw new Error("boom"); } });
|
|
88
|
+
assert.strictEqual(p5.method, "deterministic");
|
|
89
|
+
assert.strictEqual(p5.pick.id, "t1");
|
|
90
|
+
|
|
91
|
+
// --- judge NOT called when the winner is clear (margin ≥ CLEAR_MARGIN)
|
|
92
|
+
let called = false;
|
|
93
|
+
const p6 = await D.deliberate(ledgerOf([
|
|
94
|
+
entry({ source: "spaces", id: "s1", title: "Overdue!", overdue: true, due: NOW - 5 * DAY, priority: "high" }),
|
|
95
|
+
entry({ source: "task", id: "t1", title: "WIP", status: "in_progress" }),
|
|
96
|
+
]), { now: NOW, useJudge: true, judge: async () => { called = true; return null; } });
|
|
97
|
+
assert.strictEqual(called, false, "clear margin skips the judge");
|
|
98
|
+
assert.strictEqual(p6.pick.id, "s1");
|
|
99
|
+
|
|
100
|
+
// --- shadow record: writes a jsonl row; AGENCY_SHADOW=off suppresses it
|
|
101
|
+
process.env.AGENCY_SHADOW = "on";
|
|
102
|
+
D.record(p1, { channel: "telegram:123" });
|
|
103
|
+
assert.ok(fs.existsSync(D.LOG_FILE), "shadow log written");
|
|
104
|
+
let lines = fs.readFileSync(D.LOG_FILE, "utf8").trim().split("\n");
|
|
105
|
+
assert.strictEqual(lines.length, 1);
|
|
106
|
+
const row = JSON.parse(lines[0]);
|
|
107
|
+
assert.strictEqual(row.pickId, "s1");
|
|
108
|
+
assert.strictEqual(row.channel, "telegram:123");
|
|
109
|
+
assert.strictEqual(row.method, "deterministic");
|
|
110
|
+
|
|
111
|
+
process.env.AGENCY_SHADOW = "off";
|
|
112
|
+
D.record(p1, {});
|
|
113
|
+
lines = fs.readFileSync(D.LOG_FILE, "utf8").trim().split("\n");
|
|
114
|
+
assert.strictEqual(lines.length, 1, "AGENCY_SHADOW=off suppresses the write");
|
|
115
|
+
process.env.AGENCY_SHADOW = "on";
|
|
116
|
+
|
|
117
|
+
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
|
|
118
|
+
console.log("test-agency-deliberate: OK");
|
|
119
|
+
})();
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Track B0 — agency ledger aggregator. Exercises the pure build()/normalizers/
|
|
2
|
+
// render, plus collect() with an injected Spaces fetch (no subprocess) and a
|
|
3
|
+
// redirected config dir so no real task/connector files are touched.
|
|
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-ledger-"));
|
|
11
|
+
process.env.OPEN_CLAUDIA_CONFIG_DIR = TMP;
|
|
12
|
+
|
|
13
|
+
const ledger = require("./core/agency/ledger");
|
|
14
|
+
|
|
15
|
+
const NOW = Date.parse("2026-07-15T12:00:00.000Z");
|
|
16
|
+
const DAY = 86400000;
|
|
17
|
+
|
|
18
|
+
// --- normalizeTask: local task, never overdue, no due date ---
|
|
19
|
+
const t1 = ledger.normalizeTask(
|
|
20
|
+
{ id: "a", content: "Ship X", status: "in_progress", updatedAt: NOW - 3600000 },
|
|
21
|
+
NOW,
|
|
22
|
+
);
|
|
23
|
+
assert.strictEqual(t1.source, "task");
|
|
24
|
+
assert.strictEqual(t1.status, "in_progress");
|
|
25
|
+
assert.strictEqual(t1.overdue, false);
|
|
26
|
+
assert.strictEqual(t1.due, null);
|
|
27
|
+
|
|
28
|
+
// --- normalizeSpacesTask: overdue + status + space name + priority ---
|
|
29
|
+
const s1 = ledger.normalizeSpacesTask(
|
|
30
|
+
{
|
|
31
|
+
id: "s1", title: "Leave page", status: "open", priority: "high",
|
|
32
|
+
dueDate: "2026-07-14T00:00:00.000Z", updatedAt: NOW - 2 * DAY,
|
|
33
|
+
spaceId: { name: "HR + Product" }, subtaskCount: 2, subtaskCompletedCount: 1,
|
|
34
|
+
},
|
|
35
|
+
NOW,
|
|
36
|
+
);
|
|
37
|
+
assert.strictEqual(s1.source, "spaces");
|
|
38
|
+
assert.strictEqual(s1.overdue, true, "past dueDate + not done → overdue");
|
|
39
|
+
assert.strictEqual(s1.meta.space, "HR + Product");
|
|
40
|
+
assert.strictEqual(s1.priority, "high");
|
|
41
|
+
|
|
42
|
+
// a completed Spaces task is done and never overdue, even past its due date
|
|
43
|
+
const s2 = ledger.normalizeSpacesTask(
|
|
44
|
+
{ id: "s2", title: "done", status: "completed", dueDate: "2026-07-01T00:00:00.000Z" },
|
|
45
|
+
NOW,
|
|
46
|
+
);
|
|
47
|
+
assert.strictEqual(s2.status, "done");
|
|
48
|
+
assert.strictEqual(s2.overdue, false);
|
|
49
|
+
|
|
50
|
+
// --- normalizeInbox: actor + task title fold into a readable line ---
|
|
51
|
+
const n1 = ledger.normalizeInbox(
|
|
52
|
+
{ id: "n1", type: "comment", actorName: "Jane", taskTitle: "Fix bug", createdAt: NOW - 1800000 },
|
|
53
|
+
NOW,
|
|
54
|
+
);
|
|
55
|
+
assert.strictEqual(n1.source, "notification");
|
|
56
|
+
assert.ok(n1.title.includes("Jane") && n1.title.includes("Fix bug"));
|
|
57
|
+
assert.strictEqual(n1.status, "info");
|
|
58
|
+
|
|
59
|
+
// --- build: drops completed, ranks overdue first, counts by source/stale ---
|
|
60
|
+
const l = ledger.build({
|
|
61
|
+
tasks: [
|
|
62
|
+
{ id: "a", content: "In progress task", status: "in_progress", updatedAt: NOW - 3600000 },
|
|
63
|
+
{ id: "b", content: "Done task", status: "completed", updatedAt: NOW - DAY },
|
|
64
|
+
{ id: "c", content: "Stale open", status: "pending", updatedAt: NOW - 30 * DAY },
|
|
65
|
+
],
|
|
66
|
+
spacesMine: [
|
|
67
|
+
{ id: "s1", title: "Overdue!", status: "open", dueDate: "2026-07-13T00:00:00.000Z", updatedAt: NOW - 5 * DAY },
|
|
68
|
+
],
|
|
69
|
+
inboxes: [
|
|
70
|
+
{ id: "n1", type: "comment", actorName: "Jane", taskTitle: "T", createdAt: NOW - 1800000 },
|
|
71
|
+
],
|
|
72
|
+
now: NOW,
|
|
73
|
+
});
|
|
74
|
+
assert.strictEqual(l.counts.total, 4, "1 completed dropped from 5 inputs");
|
|
75
|
+
assert.strictEqual(l.counts.overdue, 1);
|
|
76
|
+
assert.strictEqual(l.entries[0].id, "s1", "overdue entry ranked first");
|
|
77
|
+
assert.strictEqual(l.counts.stale, 1, "30d-untouched task is stale");
|
|
78
|
+
assert.strictEqual(l.counts.bySource.task, 2);
|
|
79
|
+
assert.strictEqual(l.counts.bySource.spaces, 1);
|
|
80
|
+
assert.strictEqual(l.counts.bySource.notification, 1);
|
|
81
|
+
|
|
82
|
+
// --- render: summary line + per-source sections ---
|
|
83
|
+
const text = ledger.render(l, { now: NOW });
|
|
84
|
+
assert.ok(text.includes("Agenda"));
|
|
85
|
+
assert.ok(text.includes("4 open"));
|
|
86
|
+
assert.ok(text.includes("1 overdue"));
|
|
87
|
+
assert.ok(text.includes("Spaces (assigned to me)"));
|
|
88
|
+
assert.ok(text.includes("Overdue!"));
|
|
89
|
+
|
|
90
|
+
// empty ledger renders the all-clear
|
|
91
|
+
const empty = ledger.render(ledger.build({ now: NOW }), { now: NOW });
|
|
92
|
+
assert.ok(empty.includes("Nothing on your plate"));
|
|
93
|
+
|
|
94
|
+
// --- collect: injected Spaces fetch, no adapter (no task files), no subprocess ---
|
|
95
|
+
(async () => {
|
|
96
|
+
const r = await ledger.collect({
|
|
97
|
+
now: NOW,
|
|
98
|
+
fetchSpacesMine: async () => [{ id: "s9", title: "Injected", status: "open" }],
|
|
99
|
+
});
|
|
100
|
+
assert.ok(r.entries.some((e) => e.id === "s9"), "injected spaces task present");
|
|
101
|
+
assert.strictEqual(typeof r.counts.total, "number");
|
|
102
|
+
|
|
103
|
+
// includeSpaces:false skips the fetch entirely
|
|
104
|
+
let called = false;
|
|
105
|
+
const r2 = await ledger.collect({
|
|
106
|
+
now: NOW, includeSpaces: false,
|
|
107
|
+
fetchSpacesMine: async () => { called = true; return [{ id: "nope" }]; },
|
|
108
|
+
});
|
|
109
|
+
assert.strictEqual(called, false, "includeSpaces:false skips the Spaces fetch");
|
|
110
|
+
assert.ok(!r2.entries.some((e) => e.id === "nope"));
|
|
111
|
+
|
|
112
|
+
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (e) {}
|
|
113
|
+
console.log("test-agency-ledger: OK");
|
|
114
|
+
})();
|
|
@@ -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,15 +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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
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");
|
|
44
47
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
45
48
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
46
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);
|