@inetafrica/open-claudia 3.0.43 → 3.1.0
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 +44 -0
- package/README.md +9 -5
- package/bin/tool.js +15 -5
- package/bot.js +78 -3
- 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 +118 -1
- 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/providers/claude-events.js +36 -3
- package/core/providers/events.js +11 -0
- 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/run-context.js +10 -4
- package/core/runner.js +111 -2
- package/core/safe-mode.js +227 -0
- package/core/state.js +50 -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-fixtures/fake-agent-cli.js +29 -1
- package/test-fixtures/sim-no-sqlite.js +14 -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-events.js +71 -0
- package/test-provider-language.js +61 -1
- package/test-provider-recall.js +33 -0
- package/test-provider-runner.js +50 -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
|
@@ -4,6 +4,7 @@ const {
|
|
|
4
4
|
normalizeDecoderError,
|
|
5
5
|
normalizedUsage,
|
|
6
6
|
providerErrorFlags,
|
|
7
|
+
refusalDetailsFromMessage,
|
|
7
8
|
} = require("./events");
|
|
8
9
|
|
|
9
10
|
function createClaudeParserState() {
|
|
@@ -14,6 +15,7 @@ function createClaudeParserState() {
|
|
|
14
15
|
tools: new Map(),
|
|
15
16
|
seenUsage: new Set(),
|
|
16
17
|
totalCostUsd: 0,
|
|
18
|
+
refusal: null,
|
|
17
19
|
};
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -30,6 +32,27 @@ function claudeErrorMessage(rawEvent) {
|
|
|
30
32
|
return "Claude provider failed";
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
function captureRefusal(state, stopReason, stopDetails) {
|
|
36
|
+
if (stopReason !== "refusal") return;
|
|
37
|
+
state.refusal = {
|
|
38
|
+
category: stopDetails?.category ?? null,
|
|
39
|
+
explanation: stopDetails?.explanation ?? null,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function claudeFailureEvent(message, state) {
|
|
44
|
+
const flags = providerErrorFlags(message);
|
|
45
|
+
const event = { type: "error", message, ...flags };
|
|
46
|
+
if (flags.refusal || state.refusal) {
|
|
47
|
+
const parsed = refusalDetailsFromMessage(message);
|
|
48
|
+
event.refusal = true;
|
|
49
|
+
event.refusalCategory = state.refusal?.category ?? null;
|
|
50
|
+
event.refusalExplanation = state.refusal?.explanation || parsed.explanation || null;
|
|
51
|
+
event.requestId = parsed.requestId;
|
|
52
|
+
}
|
|
53
|
+
return event;
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
34
57
|
if (!rawEvent || typeof rawEvent !== "object") return [];
|
|
35
58
|
const decoderFailure = normalizeDecoderError(rawEvent);
|
|
@@ -41,6 +64,9 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
if (rawEvent.type === "stream_event") {
|
|
67
|
+
if (rawEvent.event?.type === "message_delta") {
|
|
68
|
+
captureRefusal(state, rawEvent.event.delta?.stop_reason, rawEvent.event.delta?.stop_details ?? rawEvent.event.stop_details);
|
|
69
|
+
}
|
|
44
70
|
const delta = rawEvent.event?.type === "content_block_delta" ? rawEvent.event.delta : null;
|
|
45
71
|
if (delta?.type === "text_delta" && typeof delta.text === "string" && delta.text) {
|
|
46
72
|
state.sawText = true;
|
|
@@ -51,6 +77,7 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
51
77
|
|
|
52
78
|
if (rawEvent.type === "assistant") {
|
|
53
79
|
const events = [];
|
|
80
|
+
captureRefusal(state, rawEvent.message?.stop_reason, rawEvent.message?.stop_details);
|
|
54
81
|
for (const block of rawEvent.message?.content || []) {
|
|
55
82
|
if (block?.type === "text" && typeof block.text === "string" && block.text) {
|
|
56
83
|
state.sawText = true;
|
|
@@ -95,8 +122,7 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
95
122
|
state.terminal = true;
|
|
96
123
|
if (Number.isFinite(rawEvent.total_cost_usd)) state.totalCostUsd = rawEvent.total_cost_usd;
|
|
97
124
|
if (rawEvent.is_error || /^error/.test(String(rawEvent.subtype || ""))) {
|
|
98
|
-
|
|
99
|
-
return [{ type: "error", message, ...providerErrorFlags(message) }];
|
|
125
|
+
return [claudeFailureEvent(claudeErrorMessage(rawEvent), state)];
|
|
100
126
|
}
|
|
101
127
|
const events = [];
|
|
102
128
|
const session = sessionEvent(rawEvent.session_id, state);
|
|
@@ -104,12 +130,19 @@ function normalizeClaudeEvent(rawEvent, state = createClaudeParserState()) {
|
|
|
104
130
|
const usage = normalizedUsage(rawEvent.usage, "turn", state.seenUsage);
|
|
105
131
|
if (usage) events.push(usage);
|
|
106
132
|
const hasStructuredOutput = Object.prototype.hasOwnProperty.call(rawEvent, "structured_output");
|
|
133
|
+
const resultText = typeof rawEvent.result === "string" ? rawEvent.result : "";
|
|
134
|
+
if (state.refusal && !hasStructuredOutput && !state.sawText && !resultText.trim()) {
|
|
135
|
+
// A refusal the CLI reports as a "successful" empty turn would otherwise
|
|
136
|
+
// surface as "(no output)" — fail it with the real reason instead.
|
|
137
|
+
events.push(claudeFailureEvent('The model refused this request (stop_reason "refusal").', state));
|
|
138
|
+
return events;
|
|
139
|
+
}
|
|
107
140
|
const structuredText = hasStructuredOutput ? JSON.stringify(rawEvent.structured_output) : "";
|
|
108
141
|
events.push({
|
|
109
142
|
type: "result",
|
|
110
143
|
text: hasStructuredOutput
|
|
111
144
|
? (structuredText === undefined ? "" : structuredText)
|
|
112
|
-
: (state.sawText ? "" :
|
|
145
|
+
: (state.sawText ? "" : resultText),
|
|
113
146
|
sessionId: rawEvent.session_id || state.sessionId || null,
|
|
114
147
|
});
|
|
115
148
|
return events;
|
package/core/providers/events.js
CHANGED
|
@@ -131,9 +131,19 @@ function providerErrorFlags(message) {
|
|
|
131
131
|
return {
|
|
132
132
|
authError: /unauthori[sz]ed|not (?:logged in|authenticated)|unauthenticated|login required|authentication failed|auth failed|invalid api key|no credentials|401/.test(text),
|
|
133
133
|
usageLimit: /usage limit|spend limit|monthly cycle|quota|rate limit/.test(text),
|
|
134
|
+
refusal: /refusals-and-fallback|usage policy|safety classifier|stop_reason['":\s]*refusal/.test(text),
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
// The CLI flattens the API's stop_details into the error text; recover the
|
|
139
|
+
// human-readable pieces so the chat message can lead with the actual reason.
|
|
140
|
+
function refusalDetailsFromMessage(message) {
|
|
141
|
+
const text = String(message || "");
|
|
142
|
+
const requestId = text.match(/\breq_[A-Za-z0-9]+\b/)?.[0] || null;
|
|
143
|
+
const explanation = text.match(/This request triggered[^.]+\./i)?.[0]?.trim() || null;
|
|
144
|
+
return { requestId, explanation };
|
|
145
|
+
}
|
|
146
|
+
|
|
137
147
|
function normalizeDecoderError(rawEvent) {
|
|
138
148
|
if (!rawEvent || rawEvent.type !== "decoder.error") return null;
|
|
139
149
|
// Warning, not error: a single oversized or malformed record is skippable.
|
|
@@ -171,6 +181,7 @@ module.exports = {
|
|
|
171
181
|
normalizeDecoderError,
|
|
172
182
|
normalizedUsage,
|
|
173
183
|
providerErrorFlags,
|
|
184
|
+
refusalDetailsFromMessage,
|
|
174
185
|
selectTerminalText,
|
|
175
186
|
usageFingerprint,
|
|
176
187
|
};
|
|
@@ -150,18 +150,34 @@ function maybeSync(packsLib, entitiesLib) {
|
|
|
150
150
|
// seed — with a 147-pack corpus that is virtually every turn (measured: gated
|
|
151
151
|
// 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
|
|
152
152
|
// skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
|
|
153
|
-
// seeds — short command-like turns (≤4 words
|
|
154
|
-
// enough; inject user-origin seeds directly
|
|
155
|
-
// (the classic baseline, ~ms instead of ~13s).
|
|
153
|
+
// seeds — short command-like turns (≤4 words after any pleasantry prefix):
|
|
154
|
+
// keyword seeds are precise enough; inject user-origin seeds directly
|
|
155
|
+
// and skip graph + walker (the classic baseline, ~ms instead of ~13s).
|
|
156
156
|
// full — substantive turns: graph expansion + walker judgement.
|
|
157
157
|
const PLEASANTRY_RE = /^(ok(ay)?|k|kk|thanks?|thank you|thx|ty|yes|yep|yeah|no|nope|nah|cool|nice|great|perfect|awesome|lovely|done|sure|got it|sounds good|will do|lol|haha|hi|hey|hello|yo|morning|evening|night|good (morning|afternoon|evening|night)|bye|goodnight|cheers|please do|go ahead)\b/;
|
|
158
158
|
|
|
159
|
+
// B2: a pleasantry PREFIX must not hide a real request from the gate — "ok
|
|
160
|
+
// deploy kazee now" is a deploy command wearing an ack, yet the old ≤4-word
|
|
161
|
+
// rule skipped recall for it entirely. Strip leading pleasantry tokens
|
|
162
|
+
// (stacked ones too) plus separators and tier what remains; a pure pleasantry
|
|
163
|
+
// chain strips to "" and skips regardless of its length.
|
|
164
|
+
function stripPleasantryPrefix(t) {
|
|
165
|
+
let s = t;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const m = PLEASANTRY_RE.exec(s);
|
|
168
|
+
if (!m) return s;
|
|
169
|
+
s = s.slice(m[0].length).replace(/^[\s,.!?;:—–-]+/, "");
|
|
170
|
+
if (!s) return "";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
159
174
|
function recallTier(userText, seedCount) {
|
|
160
175
|
const t = String(userText || "").trim().toLowerCase();
|
|
161
176
|
if (!t) return "skip";
|
|
162
|
-
const words = t.split(/\s+/).filter(Boolean);
|
|
163
177
|
if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
|
|
164
|
-
|
|
178
|
+
const core = stripPleasantryPrefix(t);
|
|
179
|
+
if (!core) return "skip"; // pure pleasantry/ack chain
|
|
180
|
+
const words = core.split(/\s+/).filter(Boolean);
|
|
165
181
|
if (words.length <= 2 && seedCount === 0) return "skip";
|
|
166
182
|
if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
|
|
167
183
|
return "full";
|
|
@@ -325,7 +341,7 @@ function cokeptPairs(keptIds, scoreById, groundedIds, { minScore = 60, maxPairs
|
|
|
325
341
|
pairs.push({ a, b, strength: scoreById.get(a) + scoreById.get(b) });
|
|
326
342
|
}
|
|
327
343
|
}
|
|
328
|
-
pairs.sort((x, y) => y.strength - x.strength || (x.a + "
|
|
344
|
+
pairs.sort((x, y) => y.strength - x.strength || (x.a + "\u0000" + x.b).localeCompare(y.a + "\u0000" + y.b));
|
|
329
345
|
return pairs.slice(0, Math.max(0, maxPairs)).map((p) => [p.a, p.b]);
|
|
330
346
|
}
|
|
331
347
|
|
package/core/recall/graph.js
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// `weight` and `last_reinforced`. Structural edges (parent/related from pack
|
|
13
13
|
// metadata) are re-derived idempotently; their weight floor is kept but any
|
|
14
14
|
// extra reinforcement earned through use is preserved.
|
|
15
|
+
//
|
|
16
|
+
// Decay is computed ON READ, never rewritten in place ("decay-on-read"): the
|
|
17
|
+
// stored `weight` is the value AS OF `last_reinforced`, and every read maps it
|
|
18
|
+
// through effectiveWeight(). Writes that change a weight (reinforce, weaken)
|
|
19
|
+
// first settle the decay accrued so far, then apply their delta and restamp.
|
|
20
|
+
// The old nightly decay() rewrite reapplied the full age to an already-decayed
|
|
21
|
+
// weight every night (compounding — a 60d half-life emptied in ~10 nights);
|
|
22
|
+
// with no rewrite path, that bug class cannot exist.
|
|
15
23
|
|
|
16
24
|
const fs = require("fs");
|
|
17
25
|
const path = require("path");
|
|
@@ -29,13 +37,14 @@ const EDGE_TYPES = ["parent", "governed-by", "related"];
|
|
|
29
37
|
// Base weights when an edge is first derived from structure.
|
|
30
38
|
const BASE_WEIGHT = { parent: 3, "governed-by": 2.5, related: 1 };
|
|
31
39
|
// Spreading-activation tunables (overridable via env / dream knobs).
|
|
40
|
+
// The decay half-life lives in tuning.js (`decayHalfLifeDays`, env-pinnable),
|
|
41
|
+
// read lazily so a dream knob change applies to the very next read.
|
|
32
42
|
const DEFAULTS = {
|
|
33
43
|
hops: 2,
|
|
34
44
|
threshold: 0.18, // firing threshold as a fraction of the strongest seed
|
|
35
45
|
damping: 0.55, // fraction of activation transmitted per hop
|
|
36
46
|
perNodeTopK: 6, // cap fan-out per node (sparsity)
|
|
37
47
|
maxWeight: 6, // weight at which an edge transmits at full damping
|
|
38
|
-
halfLifeDays: 60, // Hebbian weight half-life for decay()
|
|
39
48
|
};
|
|
40
49
|
|
|
41
50
|
let _db = null;
|
|
@@ -62,6 +71,17 @@ function openDb() {
|
|
|
62
71
|
)`);
|
|
63
72
|
db.exec("CREATE INDEX IF NOT EXISTS edges_src ON edges (src)");
|
|
64
73
|
db.exec("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
|
|
74
|
+
// v1 migration: legacy DBs carry weights the old nightly decay() rewrite
|
|
75
|
+
// had already over-decayed (it reapplied the full age every night). Treat
|
|
76
|
+
// stored values as current and restart the clock — decay is read-time from
|
|
77
|
+
// here on, so this is the last time the stamp is touched in bulk.
|
|
78
|
+
try {
|
|
79
|
+
const uv = db.prepare("PRAGMA user_version").get() || {};
|
|
80
|
+
if (Number(uv.user_version || 0) < 1) {
|
|
81
|
+
db.prepare("UPDATE edges SET last_reinforced=? WHERE last_reinforced IS NOT NULL").run(now());
|
|
82
|
+
db.exec("PRAGMA user_version = 1");
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {}
|
|
65
85
|
_db = db;
|
|
66
86
|
return db;
|
|
67
87
|
} catch (e) {
|
|
@@ -76,6 +96,29 @@ function normType(t) {
|
|
|
76
96
|
return EDGE_TYPES.includes(s) ? s : "related";
|
|
77
97
|
}
|
|
78
98
|
|
|
99
|
+
// Effective decay half-life: explicit override > dream-tunable knob (env-pinnable).
|
|
100
|
+
function currentHalfLife(opts = {}) {
|
|
101
|
+
if (Number.isFinite(opts.halfLifeDays)) return Math.max(1, opts.halfLifeDays);
|
|
102
|
+
let hl;
|
|
103
|
+
try { hl = require("./tuning").get("decayHalfLifeDays"); } catch (e) {}
|
|
104
|
+
return Number.isFinite(hl) ? Math.max(1, hl) : 60;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Read-time weight of an edge row: exponential decay of the stored weight since
|
|
108
|
+
// last_reinforced, clamped at the structural floor. Pure — storage is never
|
|
109
|
+
// touched. An edge that was never reinforced (last_reinforced null) does not
|
|
110
|
+
// decay: it sits at its structural base until first use.
|
|
111
|
+
function effectiveWeight(e, nowMs = Date.now(), halfLifeDays = currentHalfLife()) {
|
|
112
|
+
if (!e || !Number.isFinite(e.weight)) return 0;
|
|
113
|
+
if (!e.last_reinforced) return e.weight;
|
|
114
|
+
const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
|
|
115
|
+
if (!(ageDays > 0)) return e.weight;
|
|
116
|
+
const floor = BASE_WEIGHT[e.type] || 0;
|
|
117
|
+
if (e.weight <= floor) return e.weight;
|
|
118
|
+
const f = Math.exp(-Math.log(2) * ageDays / Math.max(1, halfLifeDays));
|
|
119
|
+
return Math.max(floor, e.weight * f);
|
|
120
|
+
}
|
|
121
|
+
|
|
79
122
|
// Upsert an edge. By default keeps the larger of the existing/new weight so a
|
|
80
123
|
// structural re-derive never stomps reinforcement; pass `{ bump }` to add to
|
|
81
124
|
// the current weight (Hebbian) and stamp last_reinforced.
|
|
@@ -86,15 +129,19 @@ function addEdge(src, dst, type, opts = {}) {
|
|
|
86
129
|
const weight = Number.isFinite(opts.weight) ? opts.weight : (BASE_WEIGHT[t] || 1);
|
|
87
130
|
const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
|
|
88
131
|
try {
|
|
89
|
-
const existing = db.prepare("SELECT weight FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
|
|
132
|
+
const existing = db.prepare("SELECT weight, last_reinforced FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
|
|
90
133
|
if (existing) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
134
|
+
if (bump) {
|
|
135
|
+
// Settle the decay accrued since last_reinforced, then bump and restamp
|
|
136
|
+
// — keeps the invariant that the stored weight is valued at its stamp.
|
|
137
|
+
const settled = effectiveWeight({ ...existing, type: t });
|
|
94
138
|
db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?")
|
|
95
|
-
.run(
|
|
139
|
+
.run(settled + bump, now(), src, dst, t);
|
|
96
140
|
} else {
|
|
97
|
-
|
|
141
|
+
const next = Math.max(existing.weight, weight);
|
|
142
|
+
if (next !== existing.weight) {
|
|
143
|
+
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, src, dst, t);
|
|
144
|
+
}
|
|
98
145
|
}
|
|
99
146
|
} else {
|
|
100
147
|
db.prepare("INSERT INTO edges (src, dst, type, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
|
|
@@ -123,18 +170,21 @@ function allEdges() {
|
|
|
123
170
|
catch (e) { return []; }
|
|
124
171
|
}
|
|
125
172
|
|
|
126
|
-
// Undirected neighbours of a node: edges where it is src OR dst.
|
|
173
|
+
// Undirected neighbours of a node: edges where it is src OR dst. Weights are
|
|
174
|
+
// read-time effective values.
|
|
127
175
|
function neighbors(nodeId) {
|
|
128
176
|
const db = openDb();
|
|
129
177
|
if (!db || !nodeId) return [];
|
|
130
178
|
try {
|
|
131
179
|
const rows = db.prepare(
|
|
132
|
-
"SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?"
|
|
180
|
+
"SELECT src, dst, type, weight, last_reinforced FROM edges WHERE src=? OR dst=?"
|
|
133
181
|
).all(nodeId, nodeId);
|
|
182
|
+
const nowMs = Date.now();
|
|
183
|
+
const hl = currentHalfLife();
|
|
134
184
|
return rows.map((r) => ({
|
|
135
185
|
node: r.src === nodeId ? r.dst : r.src,
|
|
136
186
|
type: r.type,
|
|
137
|
-
weight: r
|
|
187
|
+
weight: effectiveWeight(r, nowMs, hl),
|
|
138
188
|
}));
|
|
139
189
|
} catch (e) { return []; }
|
|
140
190
|
}
|
|
@@ -158,24 +208,30 @@ function reinforceSet(nodeIds, amount = 1) {
|
|
|
158
208
|
|
|
159
209
|
// Negative Hebbian: weaken every edge touching a node that keeps surfacing
|
|
160
210
|
// via the graph but never gets kept or used — firing wrongly, so unwire.
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
211
|
+
// Operates on the EFFECTIVE weight (settle accrued decay first, then apply the
|
|
212
|
+
// factor and restamp — same invariant as the reinforce path). Floor contract
|
|
213
|
+
// as everywhere: never below the type's base weight. Additionally, `related`
|
|
214
|
+
// edges that fall to their structural base are deleted outright: a
|
|
215
|
+
// link-derived edge re-derives at base weight on the next sync (harmless),
|
|
216
|
+
// while a co-use edge that earned its weight from wrong firings is gone for
|
|
217
|
+
// good.
|
|
165
218
|
function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
|
|
166
219
|
const db = openDb();
|
|
167
220
|
if (!db || !nodeId) return { weakened: 0, pruned: 0 };
|
|
168
221
|
let weakened = 0, pruned = 0;
|
|
169
222
|
try {
|
|
170
|
-
const rows = db.prepare("SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
|
|
223
|
+
const rows = db.prepare("SELECT src, dst, type, weight, last_reinforced FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
|
|
224
|
+
const nowMs = Date.now();
|
|
225
|
+
const hl = currentHalfLife();
|
|
226
|
+
const stamp = now();
|
|
171
227
|
for (const e of rows) {
|
|
172
228
|
const floor = BASE_WEIGHT[e.type] || 0;
|
|
173
|
-
const next = Math.max(floor, e
|
|
229
|
+
const next = Math.max(floor, effectiveWeight(e, nowMs, hl) * factor);
|
|
174
230
|
if (prune && e.type === "related" && next <= floor + 0.05) {
|
|
175
231
|
db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(e.src, e.dst, e.type);
|
|
176
232
|
pruned++;
|
|
177
233
|
} else if (Math.abs(next - e.weight) > 1e-6) {
|
|
178
|
-
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, e.src, e.dst, e.type);
|
|
234
|
+
db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?").run(next, stamp, e.src, e.dst, e.type);
|
|
179
235
|
weakened++;
|
|
180
236
|
}
|
|
181
237
|
}
|
|
@@ -183,33 +239,6 @@ function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
|
|
|
183
239
|
return { weakened, pruned };
|
|
184
240
|
}
|
|
185
241
|
|
|
186
|
-
// Exponential time decay on reinforced weight. Structural floor is preserved:
|
|
187
|
-
// an edge never decays below its type's base weight.
|
|
188
|
-
function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
|
|
189
|
-
const db = openDb();
|
|
190
|
-
if (!db) return 0;
|
|
191
|
-
const rows = allEdges();
|
|
192
|
-
const nowMs = Date.now();
|
|
193
|
-
const ln2 = Math.log(2);
|
|
194
|
-
let changed = 0;
|
|
195
|
-
for (const e of rows) {
|
|
196
|
-
if (!e.last_reinforced) continue;
|
|
197
|
-
const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
|
|
198
|
-
if (!(ageDays > 0)) continue;
|
|
199
|
-
const factor = Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
|
|
200
|
-
const floor = BASE_WEIGHT[e.type] || 0;
|
|
201
|
-
const next = Math.max(floor, e.weight * factor);
|
|
202
|
-
if (Math.abs(next - e.weight) > 1e-6) {
|
|
203
|
-
try {
|
|
204
|
-
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?")
|
|
205
|
-
.run(next, e.src, e.dst, e.type);
|
|
206
|
-
changed++;
|
|
207
|
-
} catch (e2) {}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return changed;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
242
|
// Spreading activation. `seeds` is [{ id, score }] (score = match strength).
|
|
214
243
|
// Returns Map id -> { activation, hop, via } for nodes that fired above the
|
|
215
244
|
// threshold, EXCLUDING the original seeds (those are already in the result set
|
|
@@ -220,10 +249,13 @@ function expand(seeds, opts = {}) {
|
|
|
220
249
|
const seedList = (seeds || []).filter((s) => s && s.id);
|
|
221
250
|
if (!seedList.length || !available()) return out;
|
|
222
251
|
|
|
223
|
-
// Build adjacency once, keeping the strongest edge
|
|
252
|
+
// Build adjacency once over READ-TIME weights, keeping the strongest edge
|
|
253
|
+
// per (node, neighbour).
|
|
224
254
|
const adj = new Map();
|
|
255
|
+
const nowMs = Date.now();
|
|
256
|
+
const hl = currentHalfLife(opts);
|
|
225
257
|
for (const e of allEdges()) {
|
|
226
|
-
const w = e
|
|
258
|
+
const w = effectiveWeight(e, nowMs, hl);
|
|
227
259
|
const push = (from, to) => {
|
|
228
260
|
if (!adj.has(from)) adj.set(from, new Map());
|
|
229
261
|
const m = adj.get(from);
|
|
@@ -378,14 +410,14 @@ function pruneOrphans(packsLib, entitiesLib) {
|
|
|
378
410
|
return removed;
|
|
379
411
|
}
|
|
380
412
|
|
|
381
|
-
// Nightly maintenance: refresh structural edges,
|
|
382
|
-
//
|
|
413
|
+
// Nightly maintenance: refresh structural edges, drop orphans. There is no
|
|
414
|
+
// weight pass — decay is applied on read (effectiveWeight), so nightly has
|
|
415
|
+
// nothing to rewrite. Deterministic + safe — the high-judgment "extract shared
|
|
383
416
|
// concern node" surgery is left to the dream model proper.
|
|
384
|
-
function tend(packsLib, entitiesLib
|
|
417
|
+
function tend(packsLib, entitiesLib) {
|
|
385
418
|
const synced = syncFromCorpus(packsLib, entitiesLib);
|
|
386
|
-
const decayed = decay(opts);
|
|
387
419
|
const pruned = pruneOrphans(packsLib, entitiesLib);
|
|
388
|
-
return { synced: synced.edges,
|
|
420
|
+
return { synced: synced.edges, pruned, ...stats() };
|
|
389
421
|
}
|
|
390
422
|
|
|
391
423
|
function stats() {
|
|
@@ -404,7 +436,7 @@ module.exports = {
|
|
|
404
436
|
EDGE_TYPES, DEFAULTS, GRAPH_DB,
|
|
405
437
|
available, openDb,
|
|
406
438
|
addEdge, removeEdge, allEdges, neighbors,
|
|
407
|
-
reinforce, reinforceSet,
|
|
439
|
+
reinforce, reinforceSet, effectiveWeight, weakenNode, expand,
|
|
408
440
|
syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
|
|
409
441
|
_resetForTest,
|
|
410
442
|
};
|
package/core/recall/tuning.js
CHANGED
|
@@ -132,8 +132,7 @@ function history() { return readFile().history || []; }
|
|
|
132
132
|
|
|
133
133
|
function write(data) {
|
|
134
134
|
try {
|
|
135
|
-
|
|
136
|
-
fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2));
|
|
135
|
+
require("../fsutil").atomicWriteFileSync(TUNING_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
137
136
|
} catch (e) {}
|
|
138
137
|
_cache = null; _cacheMtime = -1;
|
|
139
138
|
}
|
package/core/router.js
CHANGED
|
@@ -27,6 +27,7 @@ const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
|
|
|
27
27
|
const keyring = require("./keyring");
|
|
28
28
|
const { classifyPastedCredential } = require("./credential-detect");
|
|
29
29
|
const channelWizard = require("./channel-wizard");
|
|
30
|
+
const safeModeLib = require("./safe-mode");
|
|
30
31
|
require("./handlers"); // side-effect: register slash commands
|
|
31
32
|
|
|
32
33
|
const USER_VISIBLE_PROVIDER_ERRORS = new Set([
|
|
@@ -96,6 +97,19 @@ async function onMessage(envelope) {
|
|
|
96
97
|
// owner's canonical bucket on first contact, before any state is read.
|
|
97
98
|
try { autoLinkOwnerChannel(envelope.adapter?.type, envelope.channelId); } catch (e) {}
|
|
98
99
|
|
|
100
|
+
// Safe mode: only the diagnosis/rescue command set runs; everything else
|
|
101
|
+
// gets a short banner pointing at /safemode. Preempts wizards, media and
|
|
102
|
+
// agent turns — nothing may write memory or spawn a provider here.
|
|
103
|
+
if (safeModeLib.isSafeMode()) {
|
|
104
|
+
const raw = envelope.text || "";
|
|
105
|
+
const isCommand = envelope.type === "command" || raw.startsWith("/");
|
|
106
|
+
if (!isCommand || !safeModeLib.commandAllowed(raw)) {
|
|
107
|
+
if (isDuplicate(envelope)) return;
|
|
108
|
+
await send(safeModeLib.banner());
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
99
113
|
// A channel wizard listening for the owner's message on a freshly-added
|
|
100
114
|
// adapter consumes that inbound message here: it arrives on a different
|
|
101
115
|
// channel than the operator's, so the isAwaiting() gate below (keyed to
|
|
@@ -151,6 +165,10 @@ async function onMessage(envelope) {
|
|
|
151
165
|
async function onAction(envelope) {
|
|
152
166
|
return scope(envelope, async () => {
|
|
153
167
|
try {
|
|
168
|
+
if (safeModeLib.isSafeMode()) {
|
|
169
|
+
try { await send(safeModeLib.banner()); } catch (e) {}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
154
172
|
if (await channelWizard.handleAction(envelope)) return;
|
|
155
173
|
await handleAction(envelope);
|
|
156
174
|
}
|
package/core/run-context.js
CHANGED
|
@@ -97,9 +97,14 @@ function captureRunContext({
|
|
|
97
97
|
}
|
|
98
98
|
const project = projectSnapshot(state, cwd, opts.project);
|
|
99
99
|
const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
|
|
100
|
-
const
|
|
100
|
+
const requestedSessionId = opts.fresh
|
|
101
101
|
? null
|
|
102
102
|
: (opts.resumeSessionId || activeSessionId || null);
|
|
103
|
+
// A quarantined session's provider-side context tripped a safety refusal;
|
|
104
|
+
// resuming would re-send the flagged context and refuse again. Force the
|
|
105
|
+
// turn fresh instead (explicit resume requests included).
|
|
106
|
+
const quarantinedResume = !!(requestedSessionId && state.quarantinedSessions?.[requestedSessionId]);
|
|
107
|
+
const admittedSessionId = quarantinedResume ? null : requestedSessionId;
|
|
103
108
|
const origin = serializableOrigin(opts.origin, store || {});
|
|
104
109
|
const resolvedChannelId = channelId != null
|
|
105
110
|
? String(channelId)
|
|
@@ -128,9 +133,10 @@ function captureRunContext({
|
|
|
128
133
|
cwd: cwd || project.dir || null,
|
|
129
134
|
replyToMsgId: replyToMsgId == null ? null : String(replyToMsgId),
|
|
130
135
|
userPrompt: prompt,
|
|
131
|
-
fresh: !!opts.fresh,
|
|
132
|
-
continueSession: !!opts.continueSession,
|
|
133
|
-
resumeSessionId: opts.resumeSessionId || null,
|
|
136
|
+
fresh: !!opts.fresh || quarantinedResume,
|
|
137
|
+
continueSession: quarantinedResume ? false : !!opts.continueSession,
|
|
138
|
+
resumeSessionId: quarantinedResume ? null : (opts.resumeSessionId || null),
|
|
139
|
+
quarantinedResume,
|
|
134
140
|
skipAutoCompact: !!opts.skipAutoCompact,
|
|
135
141
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
|
|
136
142
|
maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,
|