@inetafrica/open-claudia 3.0.44 → 3.1.1
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 +46 -0
- package/README.md +12 -7
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- package/core/actions.js +22 -0
- package/core/auth-flow.js +68 -0
- package/core/boot-health.js +84 -0
- package/core/doctor-memory.js +213 -0
- package/core/doctor.js +5 -0
- package/core/dream.js +11 -9
- package/core/entities.js +47 -3
- package/core/handlers.js +160 -2
- package/core/keyring.js +24 -4
- package/core/memory-mutation-queue.js +42 -11
- package/core/pack-review.js +60 -4
- package/core/packs.js +47 -3
- package/core/recall/discoverer.js +22 -6
- package/core/recall/graph.js +84 -52
- package/core/recall/tuning.js +1 -2
- package/core/router.js +18 -0
- package/core/runner.js +21 -0
- package/core/safe-mode.js +227 -0
- package/core/state.js +2 -2
- package/core/system-prompt.js +43 -3
- package/core/tool-graph.js +99 -71
- package/core/tools.js +3 -2
- package/core/turn-observer.js +23 -1
- package/package.json +3 -3
- package/test-extractive-injection.js +21 -0
- package/test-memory-mutation-queue.js +33 -3
- package/test-provider-compaction.js +5 -0
- package/test-provider-core-prompt.js +3 -0
- package/test-provider-language.js +59 -1
- package/test-provider-recall.js +33 -0
- package/test-recall-discoverer.js +8 -0
- package/test-recall-graph.js +66 -11
- package/test-tool-graph.js +67 -16
- package/test-tool-manifest.js +32 -0
- package/test-tools.js +31 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Memory-substrate health checks for /doctor. These are the tripwires that
|
|
2
|
+
// would have caught the node-20 pod outage: node:sqlite silently absent, so
|
|
3
|
+
// recall ran 542 turns over 16 days with zero seeds and 87% of reviewer runs
|
|
4
|
+
// lost their mutations — and nothing surfaced it. Every check is synchronous,
|
|
5
|
+
// fast, never throws, and never spawns a model.
|
|
6
|
+
|
|
7
|
+
const fs = require("fs");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const CONFIG_DIR = require("../config-dir");
|
|
10
|
+
|
|
11
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const METRICS_TAIL_BYTES = 256 * 1024;
|
|
13
|
+
const LOCK_HELD_WARN_MS = 5 * 60 * 1000;
|
|
14
|
+
const DISK_WARN_BYTES = 1024 * 1024 * 1024;
|
|
15
|
+
const DISK_FAIL_BYTES = 200 * 1024 * 1024;
|
|
16
|
+
|
|
17
|
+
function sqliteAvailable() {
|
|
18
|
+
try { require("node:sqlite"); return true; } catch (_) { return false; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Last ~N bytes of a JSONL file as parsed objects (partial first line dropped).
|
|
22
|
+
function tailJsonl(file, maxBytes = METRICS_TAIL_BYTES) {
|
|
23
|
+
let fd = null;
|
|
24
|
+
try {
|
|
25
|
+
const stat = fs.statSync(file);
|
|
26
|
+
fd = fs.openSync(file, "r");
|
|
27
|
+
const size = Math.min(stat.size, maxBytes);
|
|
28
|
+
const buf = Buffer.alloc(size);
|
|
29
|
+
fs.readSync(fd, buf, 0, size, stat.size - size);
|
|
30
|
+
const lines = buf.toString("utf-8").split("\n").filter(Boolean);
|
|
31
|
+
if (stat.size > maxBytes) lines.shift();
|
|
32
|
+
return lines.map((l) => { try { return JSON.parse(l); } catch (_) { return null; } }).filter(Boolean);
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return [];
|
|
35
|
+
} finally {
|
|
36
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function substrateCheck() {
|
|
41
|
+
if (sqliteAvailable()) {
|
|
42
|
+
return { ok: true, label: "Memory substrate (node:sqlite)", detail: "available — FTS matching, recall graph, episodic index active" };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
label: "Memory substrate (node:sqlite)",
|
|
47
|
+
detail: `missing on Node ${process.version} — recall graph, FTS and episodic memory DISABLED; lexical keyword fallback only`,
|
|
48
|
+
action: "Run on Node 24+ (Dockerfile.base must build FROM node:24-slim). Memory stays degraded until then.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function graphCheck() {
|
|
53
|
+
if (!sqliteAvailable()) return null; // substrate check already reports the cause
|
|
54
|
+
try {
|
|
55
|
+
const graph = require("./recall/graph");
|
|
56
|
+
if (!graph.available()) {
|
|
57
|
+
return { ok: true, warn: true, label: "Recall graph", detail: "unavailable", action: "Graph module failed to open its database — check RECALL_GRAPH_DB path/permissions." };
|
|
58
|
+
}
|
|
59
|
+
const s = graph.stats();
|
|
60
|
+
return { ok: true, label: "Recall graph", detail: `${s.nodes} nodes, ${s.edges} edges` };
|
|
61
|
+
} catch (e) {
|
|
62
|
+
return { ok: true, warn: true, label: "Recall graph", detail: `check failed: ${e.message}` };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function recallPipelineCheck(now = Date.now()) {
|
|
67
|
+
const label = "Recall pipeline (24h)";
|
|
68
|
+
try {
|
|
69
|
+
const metrics = require("./recall/metrics");
|
|
70
|
+
const cutoff = now - DAY_MS;
|
|
71
|
+
const lines = tailJsonl(metrics.LOG_FILE);
|
|
72
|
+
const turns = lines.filter((r) => r.engine && Date.parse(r.ts || "") >= cutoff);
|
|
73
|
+
const live = turns.filter((r) => !r.gated);
|
|
74
|
+
if (!live.length) {
|
|
75
|
+
return { ok: true, warn: true, label, detail: "no recall turns in the last 24h (idle, or classic engine)" };
|
|
76
|
+
}
|
|
77
|
+
const seeded = live.filter((r) => (r.seeds || []).length > 0).length;
|
|
78
|
+
const injected = live.filter((r) => (r.kept || []).length > 0).length;
|
|
79
|
+
const walkerFailed = live.filter((r) => r.walker === "failed").length;
|
|
80
|
+
const detail = `${live.length} turns, ${seeded} seeded, ${injected} injected memories, ${walkerFailed} walker failures`;
|
|
81
|
+
if (live.length >= 10 && seeded === 0) {
|
|
82
|
+
return { ok: false, label, detail: `${detail} — matching is returning NOTHING`, action: "Recall is running blind. Check the substrate above and the packs/entities indexes." };
|
|
83
|
+
}
|
|
84
|
+
if (live.length >= 10 && injected === 0) {
|
|
85
|
+
return { ok: true, warn: true, label, detail: `${detail} — nothing injected all day`, action: "Seeds exist but nothing survives to injection — check walker health and the relevance gate." };
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, label, detail };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function reviewerCheck(now = Date.now()) {
|
|
94
|
+
const label = "Memory reviewer (24h)";
|
|
95
|
+
try {
|
|
96
|
+
const metrics = require("./recall/metrics");
|
|
97
|
+
const cutoff = now - DAY_MS;
|
|
98
|
+
const runs = tailJsonl(metrics.LOG_FILE).filter((r) => r.reviewer && Date.parse(r.ts || "") >= cutoff);
|
|
99
|
+
if (!runs.length) {
|
|
100
|
+
return { ok: true, warn: true, label, detail: "no reviewer runs in the last 24h" };
|
|
101
|
+
}
|
|
102
|
+
const failed = runs.filter((r) => r.reviewer.status === "failed");
|
|
103
|
+
const deferred = runs.filter((r) => r.reviewer.status === "deferred").length;
|
|
104
|
+
const lastFail = failed.length ? failed[failed.length - 1].reviewer.code || "" : "";
|
|
105
|
+
const okRate = (runs.length - failed.length) / runs.length;
|
|
106
|
+
const detail = `${runs.length} runs, ${failed.length} failed${deferred ? `, ${deferred} deferred` : ""}${lastFail ? ` (last error: ${lastFail})` : ""}`;
|
|
107
|
+
if (okRate < 0.5) {
|
|
108
|
+
return { ok: false, label, detail, action: "Most memory captures are being lost — check the mutation lock and pending review mutations below." };
|
|
109
|
+
}
|
|
110
|
+
if (okRate < 0.9) {
|
|
111
|
+
return { ok: true, warn: true, label, detail, action: "Reviewer failure rate is elevated — check the mutation lock below." };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, label, detail };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function mutationLockCheck() {
|
|
120
|
+
const label = "Memory mutation lock";
|
|
121
|
+
try {
|
|
122
|
+
const { inspectLock } = require("./memory-mutation-queue");
|
|
123
|
+
const lock = inspectLock();
|
|
124
|
+
if (!lock.held) return { ok: true, label, detail: "free" };
|
|
125
|
+
const mins = Math.round(lock.ageMs / 60000);
|
|
126
|
+
const holder = lock.owner ? `pid ${lock.owner.pid}${lock.owner.id ? ` (${String(lock.owner.id).slice(0, 8)})` : ""}` : "unknown owner";
|
|
127
|
+
if (lock.stale) {
|
|
128
|
+
return { ok: true, warn: true, label, detail: `stale — holder gone (${holder}, ${mins}m old); breaks on next mutation` };
|
|
129
|
+
}
|
|
130
|
+
if (lock.ageMs > LOCK_HELD_WARN_MS) {
|
|
131
|
+
return { ok: true, warn: true, label, detail: `held ${mins}m by live ${holder}`, action: "A dream/review may be wedged holding the lock — if this persists, investigate that process." };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, label, detail: `held by ${holder} (active mutation)` };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function deferredMutationsCheck() {
|
|
140
|
+
const label = "Pending review mutations";
|
|
141
|
+
try {
|
|
142
|
+
const { listDeferredDecisions } = require("./pack-review");
|
|
143
|
+
const pending = listDeferredDecisions();
|
|
144
|
+
if (!pending.length) return { ok: true, label, detail: "none" };
|
|
145
|
+
return {
|
|
146
|
+
ok: true, warn: true, label,
|
|
147
|
+
detail: `${pending.length} computed decision(s) never applied (lock timeouts)`,
|
|
148
|
+
action: `Inspect ${path.join(CONFIG_DIR, "pending-review-mutations.jsonl")} — re-apply what still matters, then clear it.`,
|
|
149
|
+
};
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function corpusCheck() {
|
|
156
|
+
const label = "Memory corpus";
|
|
157
|
+
try {
|
|
158
|
+
const packs = require("./packs").listPacks().length;
|
|
159
|
+
const entities = require("./entities").listEntities().length;
|
|
160
|
+
const lessonsLib = require("./lessons");
|
|
161
|
+
const lessonCount = lessonsLib.listLessons().length;
|
|
162
|
+
const overCap = lessonCount > lessonsLib.MAX_LESSONS;
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
warn: overCap,
|
|
166
|
+
label,
|
|
167
|
+
detail: `${packs} packs, ${entities} entities, ${lessonCount}/${lessonsLib.MAX_LESSONS} lessons${overCap ? " (over cap)" : ""}`,
|
|
168
|
+
action: overCap ? "Lessons exceed the always-loaded cap — run `open-claudia dream` to consolidate." : "",
|
|
169
|
+
};
|
|
170
|
+
} catch (e) {
|
|
171
|
+
return { ok: true, warn: true, label, detail: `check failed: ${e.message}` };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function diskCheck() {
|
|
176
|
+
const label = "Disk space (config dir)";
|
|
177
|
+
if (typeof fs.statfsSync !== "function") return null;
|
|
178
|
+
try {
|
|
179
|
+
const s = fs.statfsSync(CONFIG_DIR);
|
|
180
|
+
const free = Number(s.bsize) * Number(s.bavail);
|
|
181
|
+
const gb = (free / (1024 * 1024 * 1024)).toFixed(1);
|
|
182
|
+
if (free < DISK_FAIL_BYTES) {
|
|
183
|
+
return { ok: false, label, detail: `${gb} GB free`, action: "Nearly out of disk — memory writes will start failing. Free space now." };
|
|
184
|
+
}
|
|
185
|
+
if (free < DISK_WARN_BYTES) {
|
|
186
|
+
return { ok: true, warn: true, label, detail: `${gb} GB free`, action: "Low disk — consider clearing old transcripts/artifacts." };
|
|
187
|
+
}
|
|
188
|
+
return { ok: true, label, detail: `${gb} GB free` };
|
|
189
|
+
} catch (_) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The full memory section, in report order. Never throws.
|
|
195
|
+
function memoryChecks(options = {}) {
|
|
196
|
+
const now = options.now || Date.now();
|
|
197
|
+
const checks = [];
|
|
198
|
+
for (const check of [
|
|
199
|
+
substrateCheck(),
|
|
200
|
+
graphCheck(),
|
|
201
|
+
recallPipelineCheck(now),
|
|
202
|
+
reviewerCheck(now),
|
|
203
|
+
mutationLockCheck(),
|
|
204
|
+
deferredMutationsCheck(),
|
|
205
|
+
corpusCheck(),
|
|
206
|
+
diskCheck(),
|
|
207
|
+
]) {
|
|
208
|
+
if (check) checks.push(check);
|
|
209
|
+
}
|
|
210
|
+
return checks;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
module.exports = { memoryChecks, sqliteAvailable, tailJsonl };
|
package/core/doctor.js
CHANGED
|
@@ -160,6 +160,11 @@ function runDoctorChecks(options = {}) {
|
|
|
160
160
|
|
|
161
161
|
checks.push(checkWritableDir(WORKSPACE, "Workspace writable"));
|
|
162
162
|
checks.push(checkWritableDir(CONFIG_DIR, "Config dir writable"));
|
|
163
|
+
try {
|
|
164
|
+
checks.push(...require("./doctor-memory").memoryChecks());
|
|
165
|
+
} catch (e) {
|
|
166
|
+
checks.push({ ok: true, warn: true, label: "Memory health", detail: `checks failed to run: ${redactSensitive(e.message)}` });
|
|
167
|
+
}
|
|
163
168
|
return checks;
|
|
164
169
|
}
|
|
165
170
|
|
package/core/dream.js
CHANGED
|
@@ -1221,13 +1221,14 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1221
1221
|
}
|
|
1222
1222
|
}
|
|
1223
1223
|
|
|
1224
|
-
// Recall-graph maintenance: refresh structural edges,
|
|
1225
|
-
//
|
|
1224
|
+
// Recall-graph maintenance: refresh structural edges, prune orphans.
|
|
1225
|
+
// Weight decay is read-time (graph.effectiveWeight) — nothing to rewrite
|
|
1226
|
+
// nightly. Deterministic + safe, runs every dream.
|
|
1226
1227
|
let graphNote = "";
|
|
1227
1228
|
try {
|
|
1228
|
-
const g = recallGraphLib.tend(packs, entities
|
|
1229
|
-
if (g.synced || g.
|
|
1230
|
-
graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced},
|
|
1229
|
+
const g = recallGraphLib.tend(packs, entities);
|
|
1230
|
+
if (g.synced || g.pruned) {
|
|
1231
|
+
graphNote = `🕸 Recall graph: ${g.edges} edges / ${g.nodes} nodes (synced ${g.synced}, pruned ${g.pruned}).`;
|
|
1231
1232
|
}
|
|
1232
1233
|
} catch (e) { /* graph is best-effort */ }
|
|
1233
1234
|
|
|
@@ -1241,8 +1242,9 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1241
1242
|
}
|
|
1242
1243
|
} catch (e) { /* episodic index is best-effort */ }
|
|
1243
1244
|
|
|
1244
|
-
// Tool hygiene: tend the directed tool-graph (
|
|
1245
|
-
//
|
|
1245
|
+
// Tool hygiene: tend the directed tool-graph (prune faded/orphaned
|
|
1246
|
+
// follows-edges — decay itself is read-time) and flag tools whose --pack
|
|
1247
|
+
// link now dangles.
|
|
1246
1248
|
// Read-only toward the tools themselves — a tool is never auto-deleted (that's
|
|
1247
1249
|
// destructive); we just surface the breakage for the user to re-link or remove.
|
|
1248
1250
|
let toolNote = "";
|
|
@@ -1258,8 +1260,8 @@ async function runDreamMutation({ trigger = "manual", provider = null, runUtilit
|
|
|
1258
1260
|
const capList = (items, sep = ", ", max = 5) => (items.length <= max
|
|
1259
1261
|
? items.join(sep)
|
|
1260
1262
|
: `${items.slice(0, max).join(sep)} …and ${items.length - max} more (full list in the dream log)`);
|
|
1261
|
-
if (tg.edges || tg.
|
|
1262
|
-
push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (
|
|
1263
|
+
if (tg.edges || tg.pruned) {
|
|
1264
|
+
push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (pruned ${tg.pruned}).`);
|
|
1263
1265
|
}
|
|
1264
1266
|
const allTools = toolsLib.listTools();
|
|
1265
1267
|
const dangling = allTools.filter((t) => t.pack && !packs.readPack(t.pack));
|
package/core/entities.js
CHANGED
|
@@ -264,7 +264,7 @@ let _db = null;
|
|
|
264
264
|
let _indexedAt = 0;
|
|
265
265
|
let _dirty = true;
|
|
266
266
|
|
|
267
|
-
function markIndexDirty() { _dirty = true; }
|
|
267
|
+
function markIndexDirty() { _dirty = true; _lexicalDocsAt = -1; }
|
|
268
268
|
|
|
269
269
|
function openDb() {
|
|
270
270
|
if (!DatabaseSync) return null;
|
|
@@ -350,16 +350,60 @@ function queryTerms(text) {
|
|
|
350
350
|
return terms;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
// Pure-JS floor under matchEntities for substrates without node:sqlite —
|
|
354
|
+
// same rationale and scoring as the packs floor. Strong is name+aliases
|
|
355
|
+
// only (description scores as body, mirroring the FTS strong filter).
|
|
356
|
+
function termVariants(t) {
|
|
357
|
+
for (const suffix of ["ing", "ed", "es", "s"]) {
|
|
358
|
+
if (t.length > suffix.length + 2 && t.endsWith(suffix)) return [t, t.slice(0, -suffix.length)];
|
|
359
|
+
}
|
|
360
|
+
return [t];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let _lexicalDocs = null;
|
|
364
|
+
let _lexicalDocsAt = -1;
|
|
365
|
+
|
|
366
|
+
function lexicalDocs() {
|
|
367
|
+
const mtime = latestMtime();
|
|
368
|
+
if (_lexicalDocs && _lexicalDocsAt === mtime) return _lexicalDocs;
|
|
369
|
+
_lexicalDocs = listEntities().map((e) => ({
|
|
370
|
+
slug: e.slug,
|
|
371
|
+
name: e.name,
|
|
372
|
+
strong: [e.name, e.aliases.join(" ")].join("\n").toLowerCase(),
|
|
373
|
+
body: [e.description, e.sections.Notes, e.sections.Log, e.sections.Role, e.sections.Style, e.sections.Knows]
|
|
374
|
+
.filter(Boolean).join("\n").toLowerCase(),
|
|
375
|
+
}));
|
|
376
|
+
_lexicalDocsAt = mtime;
|
|
377
|
+
return _lexicalDocs;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function matchEntitiesLexical(text, { limit = 4, threshold = null } = {}) {
|
|
381
|
+
const terms = queryTerms(text);
|
|
382
|
+
if (terms.length === 0) return [];
|
|
383
|
+
const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
|
|
384
|
+
const scored = [];
|
|
385
|
+
for (const doc of lexicalDocs()) {
|
|
386
|
+
let score = 0;
|
|
387
|
+
for (const t of terms) {
|
|
388
|
+
const variants = termVariants(t);
|
|
389
|
+
if (variants.some((v) => doc.strong.includes(v))) score += 2;
|
|
390
|
+
else if (variants.some((v) => doc.body.includes(v))) score += 1;
|
|
391
|
+
}
|
|
392
|
+
if (score >= minScore) scored.push({ slug: doc.slug, name: doc.name, score });
|
|
393
|
+
}
|
|
394
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, Math.max(1, Math.min(10, limit)));
|
|
395
|
+
}
|
|
396
|
+
|
|
353
397
|
function matchEntities(text, { limit = 4, threshold = null } = {}) {
|
|
354
398
|
const db = openDb();
|
|
355
|
-
if (!db) return
|
|
399
|
+
if (!db) return matchEntitiesLexical(text, { limit, threshold });
|
|
356
400
|
ensureIndex();
|
|
357
401
|
const terms = queryTerms(text);
|
|
358
402
|
if (terms.length === 0) return [];
|
|
359
403
|
const minScore = threshold ?? Number(process.env.ENTITY_MATCH_THRESHOLD || 2);
|
|
360
404
|
const scores = new Map();
|
|
361
405
|
let stmt;
|
|
362
|
-
try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return
|
|
406
|
+
try { stmt = db.prepare("SELECT slug FROM entities WHERE entities MATCH ?"); } catch (e) { return matchEntitiesLexical(text, { limit, threshold }); }
|
|
363
407
|
for (const t of terms) {
|
|
364
408
|
const quoted = `"${t.replace(/"/g, '""')}"`;
|
|
365
409
|
let strong = [];
|
package/core/handlers.js
CHANGED
|
@@ -48,6 +48,7 @@ const {
|
|
|
48
48
|
runClaudeAuthCommand, clearPendingClaudeAuth,
|
|
49
49
|
sendCodexAuthStatusSummary, runCodexDeviceLogin, clearPendingCodexAuth,
|
|
50
50
|
saveCodexApiKeyWithCli,
|
|
51
|
+
canLogout, providerLogout, providerAccountSummary,
|
|
51
52
|
} = require("./auth-flow");
|
|
52
53
|
const providerRegistry = require("./providers");
|
|
53
54
|
|
|
@@ -375,7 +376,7 @@ register({
|
|
|
375
376
|
"Connect: /login (pick model + sign in)",
|
|
376
377
|
"Claude auth: /auth_status /setup_token /use_oauth_token /clear_oauth_token",
|
|
377
378
|
"Codex auth: /codex_auth_status /codex_login /codex_setup_token",
|
|
378
|
-
"System: /doctor /requirements /restart /upgrade",
|
|
379
|
+
"System: /doctor /requirements /restart /upgrade /downgrade /safemode",
|
|
379
380
|
"",
|
|
380
381
|
"Send text, voice, photos, or files.",
|
|
381
382
|
"Reply to any message for context.",
|
|
@@ -693,6 +694,8 @@ register({
|
|
|
693
694
|
// can be SIGTERM'd at any moment, and an unsent ack dies with us.
|
|
694
695
|
await send("Upgrade requested — AgentSpace will pull latest image and restart, see you in a minute.");
|
|
695
696
|
recordUpgradeRequested();
|
|
697
|
+
try { require("./boot-health").recordPendingDoctor({ reason: "AgentSpace image rollout", from: CURRENT_VERSION }); } catch (e) {}
|
|
698
|
+
try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
|
|
696
699
|
try {
|
|
697
700
|
const result = await requestAgentSpaceUpgrade();
|
|
698
701
|
if (result && (result.status === 202 || result.status === 200)) {
|
|
@@ -762,6 +765,8 @@ register({
|
|
|
762
765
|
const tailNote = "Source refreshed in /app. If this release also changed the Dockerfile (apt packages, env, base image), the host still needs a docker pull + recreate for those.";
|
|
763
766
|
const msg = `Installed v${newVersion}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\n${tailNote}\n\nRestarting...`;
|
|
764
767
|
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
768
|
+
try { require("./boot-health").recordPendingDoctor({ reason: "source overlay upgrade", from: CURRENT_VERSION, to: newVersion }); } catch (e) {}
|
|
769
|
+
try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
|
|
765
770
|
try { require("./restart-reason").recordExitReason(`User /upgrade → v${newVersion} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
766
771
|
setTimeout(() => process.exit(0), 2000);
|
|
767
772
|
return;
|
|
@@ -810,6 +815,8 @@ register({
|
|
|
810
815
|
const doctorReport = formatDoctorReport(runDoctorChecks());
|
|
811
816
|
const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
|
|
812
817
|
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
818
|
+
try { require("./boot-health").recordPendingDoctor({ reason: "npm upgrade", from: CURRENT_VERSION, to: newPkg.version }); } catch (e) {}
|
|
819
|
+
try { require("./safe-mode").clearCrashLoop(); } catch (e) {}
|
|
813
820
|
try { require("./restart-reason").recordExitReason(`User /upgrade → v${newPkg.version}`, { code: 0 }); } catch (e) {}
|
|
814
821
|
} catch (e) {
|
|
815
822
|
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
@@ -820,6 +827,115 @@ register({
|
|
|
820
827
|
},
|
|
821
828
|
});
|
|
822
829
|
|
|
830
|
+
// Roll back to an earlier release. The rescue half of /upgrade: a bad release
|
|
831
|
+
// that crash-loops lands in safe mode, and this is the chat-side way back to
|
|
832
|
+
// a version that worked. No arg ⇒ the previously-running version from boot
|
|
833
|
+
// history. Deliberately skips the AgentSpace control-plane path (it can only
|
|
834
|
+
// pull :latest): on pods this overlays /app in place, which survives restarts
|
|
835
|
+
// but not a pod recreate — the permanent fix there is rolling the image.
|
|
836
|
+
register({
|
|
837
|
+
name: "downgrade", description: "Roll back to a previous version", args: "[version]", ownerOnly: true,
|
|
838
|
+
handler: async (env, { args }) => {
|
|
839
|
+
if (!ownerEnv(env)) return;
|
|
840
|
+
const safeModeLib = require("./safe-mode");
|
|
841
|
+
let target = (args[0] || "").trim().replace(/^v/i, "");
|
|
842
|
+
if (!target) {
|
|
843
|
+
target = safeModeLib.previousVersion(CURRENT_VERSION);
|
|
844
|
+
if (!target) {
|
|
845
|
+
await send("No earlier version in my boot history — tell me one explicitly, e.g. /downgrade 3.0.44");
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// The version lands in a shell command: accept strict semver only.
|
|
850
|
+
if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(target)) {
|
|
851
|
+
await send(`That doesn't look like a version: "${target}". Use e.g. /downgrade 3.0.44`);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (target === CURRENT_VERSION) {
|
|
855
|
+
await send(`I'm already on v${CURRENT_VERSION}.`);
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const execEnv = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() };
|
|
859
|
+
let resolved = null;
|
|
860
|
+
try {
|
|
861
|
+
resolved = execSync(`npm view @inetafrica/open-claudia@${target} version`, {
|
|
862
|
+
encoding: "utf-8", timeout: 15000, env: execEnv,
|
|
863
|
+
}).trim().split("\n").filter(Boolean).pop() || null;
|
|
864
|
+
} catch (e) {
|
|
865
|
+
await send(`Could not verify v${target} on npm (${(e.message || String(e)).slice(0, 200)}).`);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (!resolved) {
|
|
869
|
+
await send(`v${target} does not exist on the npm registry.`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const direction = isNewerVersion(resolved, CURRENT_VERSION) ? "Moving" : "Rolling back";
|
|
873
|
+
if (isDockerAppLayout()) {
|
|
874
|
+
try { process.chdir("/tmp"); } catch (e) {}
|
|
875
|
+
const podNote = process.env.AGENTSPACE_POD_TOKEN
|
|
876
|
+
? "\nNote: this is an in-place overlay — a pod recreate returns to the deployment image version. Roll the image for a permanent downgrade."
|
|
877
|
+
: "";
|
|
878
|
+
await send(`${direction} v${CURRENT_VERSION} → v${resolved} (in-place tarball overlay into /app)...${podNote}`);
|
|
879
|
+
try {
|
|
880
|
+
inPlaceTarballUpgrade(resolved);
|
|
881
|
+
} catch (e) {
|
|
882
|
+
const errOutput = (e.stdout || e.stderr || e.message || String(e)).slice(-700);
|
|
883
|
+
await send(`Downgrade failed:\n${errOutput}`);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
await send(`Installed v${resolved}. Restarting...`);
|
|
887
|
+
try { require("./boot-health").recordPendingDoctor({ reason: "downgrade overlay", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
|
|
888
|
+
try { safeModeLib.clearCrashLoop(); } catch (e) {}
|
|
889
|
+
try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved} (source refresh)`, { code: 0 }); } catch (e) {}
|
|
890
|
+
setTimeout(() => process.exit(0), 2000);
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
|
|
894
|
+
await send(`${direction} v${CURRENT_VERSION} → v${resolved}...`);
|
|
895
|
+
try {
|
|
896
|
+
execSync(`npm install -g @inetafrica/open-claudia@${resolved} 2>&1`, {
|
|
897
|
+
encoding: "utf-8", timeout: 120000,
|
|
898
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
899
|
+
env: execEnv,
|
|
900
|
+
});
|
|
901
|
+
await send(`Installed v${resolved}. Going offline to restart...`);
|
|
902
|
+
try { require("./boot-health").recordPendingDoctor({ reason: "downgrade", from: CURRENT_VERSION, to: resolved }); } catch (e) {}
|
|
903
|
+
try { safeModeLib.clearCrashLoop(); } catch (e) {}
|
|
904
|
+
try { require("./restart-reason").recordExitReason(`User /downgrade → v${resolved}`, { code: 0 }); } catch (e) {}
|
|
905
|
+
} catch (e) {
|
|
906
|
+
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
907
|
+
await send(`Downgrade failed:\n${errOutput}`);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
setTimeout(() => process.exit(0), 2000);
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
register({
|
|
915
|
+
name: "safemode", description: "Safe-mode status, or continue past it", args: "[continue]", ownerOnly: true,
|
|
916
|
+
handler: async (env, { args }) => {
|
|
917
|
+
if (!ownerEnv(env)) return;
|
|
918
|
+
const safeModeLib = require("./safe-mode");
|
|
919
|
+
const sub = (args[0] || "").toLowerCase();
|
|
920
|
+
if (sub === "continue") {
|
|
921
|
+
if (!safeModeLib.isSafeMode()) {
|
|
922
|
+
await send(`Running normally (v${CURRENT_VERSION}) — there's no safe mode to continue past.`);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
safeModeLib.requestContinue();
|
|
926
|
+
await send("Override armed: the next boot runs normally even if the substrate is missing (keyword-only memory) and the crash-loop counter resets. Restarting now...");
|
|
927
|
+
try { require("./restart-reason").recordExitReason("User /safemode continue override", { code: 0 }); } catch (e) {}
|
|
928
|
+
setTimeout(() => process.exit(0), 1500);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (safeModeLib.isSafeMode()) {
|
|
932
|
+
await send(safeModeLib.formatSafeModeAlert({ version: CURRENT_VERSION }));
|
|
933
|
+
} else {
|
|
934
|
+
await send(`Running normally (v${CURRENT_VERSION}) — safe mode not active.`);
|
|
935
|
+
}
|
|
936
|
+
},
|
|
937
|
+
});
|
|
938
|
+
|
|
823
939
|
register({
|
|
824
940
|
name: "spaces", description: "Control Kazee Spaces auto-replies (status/pause/resume/mode)",
|
|
825
941
|
args: "<status | pause | resume | mode advisory|autonomous|off>", ownerOnly: true,
|
|
@@ -971,13 +1087,16 @@ function buildModelPicker({ authAware = false, state = currentState(), registry
|
|
|
971
1087
|
const rows = [];
|
|
972
1088
|
for (const provider of registry.listProviders()) {
|
|
973
1089
|
let authMarker = "";
|
|
1090
|
+
let connected = false;
|
|
974
1091
|
if (authAware) {
|
|
975
|
-
let connected = false;
|
|
976
1092
|
try { connected = provider.authStatus().state === "authenticated"; } catch (_) {}
|
|
977
1093
|
authMarker = connected ? " · connected" : " · tap to connect";
|
|
978
1094
|
}
|
|
979
1095
|
const availableMarker = providerAvailability(provider) ? "" : " · unavailable";
|
|
980
1096
|
rows.push([{ text: `── ${provider.label}${availableMarker}${authMarker} ──`, callback_data: "noop" }]);
|
|
1097
|
+
if (authAware && connected && canLogout(provider.id)) {
|
|
1098
|
+
rows.push([{ text: "🔁 Switch account", callback_data: `sw:${provider.id}` }]);
|
|
1099
|
+
}
|
|
981
1100
|
const choices = provider.modelChoices();
|
|
982
1101
|
for (let index = 0; index < choices.length; index += 3) {
|
|
983
1102
|
rows.push(choices.slice(index, index + 3).map((model) => ({
|
|
@@ -1488,7 +1607,9 @@ register({
|
|
|
1488
1607
|
return `${name === "readOnlyMode" ? "read-only" : name} ${capability.support}`;
|
|
1489
1608
|
}).join(" · ")
|
|
1490
1609
|
: "none";
|
|
1610
|
+
const safeModeLib = require("./safe-mode");
|
|
1491
1611
|
send([
|
|
1612
|
+
...(safeModeLib.isSafeMode() ? [`⛔ SAFE MODE (${safeModeLib.bootInfo().reasons.join(", ")}) — /safemode for details`] : []),
|
|
1492
1613
|
`Project: ${state.currentSession?.name || "none"}`,
|
|
1493
1614
|
`Provider: ${provider?.label || "none"}`,
|
|
1494
1615
|
`Model: ${settings?.model || provider?.defaultModel() || "default"} | Effort: ${settings?.effort || "default"}`,
|
|
@@ -1811,6 +1932,43 @@ register({
|
|
|
1811
1932
|
},
|
|
1812
1933
|
});
|
|
1813
1934
|
|
|
1935
|
+
async function startProviderLogin(providerId) {
|
|
1936
|
+
if (providerId === "claude") return runClaudeAuthCommand(["auth", "login", "--claudeai"], "Claude login");
|
|
1937
|
+
if (providerId === "codex") return runCodexDeviceLogin();
|
|
1938
|
+
return send(`No login flow for provider "${providerId}".`);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
async function performProviderLogout(providerId, { thenLogin = false } = {}) {
|
|
1942
|
+
const provider = providerFromRegistry(providerId);
|
|
1943
|
+
const label = provider ? provider.label : providerId;
|
|
1944
|
+
const result = providerLogout(providerId);
|
|
1945
|
+
await send(`${label} sign-out:\n${result.steps.map((s) => `• ${s}`).join("\n")}`);
|
|
1946
|
+
if (thenLogin) return startProviderLogin(providerId);
|
|
1947
|
+
await send(`Sign in with a different account when ready — tap below or use /login.`, {
|
|
1948
|
+
keyboard: { inline_keyboard: [[{ text: `Sign in ${label}`, callback_data: `li:${providerId}` }]] },
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
module.exports.startProviderLogin = startProviderLogin;
|
|
1953
|
+
module.exports.performProviderLogout = performProviderLogout;
|
|
1954
|
+
|
|
1955
|
+
register({
|
|
1956
|
+
name: "logout", description: "Sign out of a provider (full: CLI store + bot token)", args: "[claude|codex]", ownerOnly: true,
|
|
1957
|
+
handler: async (env, { tail }) => {
|
|
1958
|
+
if (!ownerEnv(env)) return send("Owner only — provider auth is shared across users.");
|
|
1959
|
+
const raw = (tail || "").trim().toLowerCase();
|
|
1960
|
+
const target = ({ gpt: "codex", openai: "codex", chatgpt: "codex", anthropic: "claude" })[raw] || raw;
|
|
1961
|
+
if (canLogout(target)) return performProviderLogout(target);
|
|
1962
|
+
if (target) return send(`Unknown provider "${target}". Use /logout claude or /logout codex.`);
|
|
1963
|
+
return send("Sign out of which provider? This clears the CLI credential store (and Claude's bot token) so /login offers a fresh sign-in.", {
|
|
1964
|
+
keyboard: { inline_keyboard: [[
|
|
1965
|
+
{ text: "Claude", callback_data: "lo:claude" },
|
|
1966
|
+
{ text: "Codex", callback_data: "lo:codex" },
|
|
1967
|
+
]] },
|
|
1968
|
+
});
|
|
1969
|
+
},
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1814
1972
|
register({
|
|
1815
1973
|
name: "setup_token", description: "Create Claude OAuth token", ownerOnly: true,
|
|
1816
1974
|
handler: async (env) => {
|
package/core/keyring.js
CHANGED
|
@@ -21,21 +21,41 @@
|
|
|
21
21
|
const fs = require("fs");
|
|
22
22
|
const path = require("path");
|
|
23
23
|
const CONFIG_DIR = require("../config-dir");
|
|
24
|
+
const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
|
|
24
25
|
|
|
25
26
|
const KEYRING_FILE = process.env.KEYRING_FILE || path.join(CONFIG_DIR, "keyring.json");
|
|
26
27
|
|
|
28
|
+
let warnedCorrupt = false;
|
|
29
|
+
|
|
27
30
|
function load() {
|
|
31
|
+
let raw;
|
|
28
32
|
try {
|
|
29
|
-
|
|
33
|
+
raw = fs.readFileSync(KEYRING_FILE, "utf-8");
|
|
34
|
+
} catch (e) {
|
|
35
|
+
return {}; // no keyring yet — fine
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const data = JSON.parse(raw);
|
|
30
39
|
return data && typeof data === "object" ? data : {};
|
|
31
40
|
} catch (e) {
|
|
32
|
-
|
|
41
|
+
// File exists but is unparseable: fall back to the last-good .bak snapshot
|
|
42
|
+
// rather than silently running credential-less, and say so loudly — stderr
|
|
43
|
+
// reaches the bot log, and from `tool run` it lands in the agent's
|
|
44
|
+
// transcript so the failure surfaces to the owner instead of vanishing.
|
|
45
|
+
const data = readJsonWithFallback(KEYRING_FILE, null);
|
|
46
|
+
if (!warnedCorrupt) {
|
|
47
|
+
warnedCorrupt = true;
|
|
48
|
+
console.error(`[keyring] ${KEYRING_FILE} is corrupt (${e.message}); ${
|
|
49
|
+
data ? "using the last-good .bak snapshot" : "NO usable backup — tools run WITHOUT credentials until it is restored"}.`);
|
|
50
|
+
}
|
|
51
|
+
return data && typeof data === "object" ? data : {};
|
|
33
52
|
}
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
function save(data) {
|
|
37
|
-
|
|
38
|
-
|
|
56
|
+
// Atomic, 0600 from birth (no torn-file window, no chmod-after-write gap),
|
|
57
|
+
// with a last-good .bak snapshot that load() falls back to on corruption.
|
|
58
|
+
atomicWriteFileSync(KEYRING_FILE, JSON.stringify(data, null, 2) + "\n", { backup: true, mode: 0o600 });
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
// Full {key: value} map — used to inject creds into subprocess env.
|