@inetafrica/open-claudia 2.6.57 → 2.6.58
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 +8 -0
- package/README.md +2 -0
- package/core/pack-review.js +13 -0
- package/core/packs.js +37 -5
- package/core/recall/discoverer.js +143 -42
- package/core/recall/metrics.js +20 -7
- package/core/recall/warm-walker.js +12 -1
- package/core/runner.js +1 -1
- package/core/system-prompt.js +4 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +31 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.6.58
|
|
4
|
+
- **Tiered recall gate — stop paying 15 seconds for "thanks".** Measured over 700+ live turns, the old discoverer pre-gate returned true whenever *any* seed matched, so it gated just 0.4% of turns — a bare "ok" with an incidental keyword hit still paid the full seed → graph → walker pass (p50 ~15s). A new `recallTier()` classifies each turn into three tiers: **skip** (pleasantries/acks up to 4 words *regardless of seeds*, emoji-only, or ≤2 words with no seeds — no recall at all), **seeds** (short command-like turns ≤4 words — user-origin keyword seeds inject directly with no graph expansion, no walker, no excerpts; the classic-engine baseline at ~ms cost), and **full** (substantive turns — the whole pipeline). The tier is logged per turn and the metrics summary now tracks the seeds-only share alongside the gated share.
|
|
5
|
+
- **Walker candidate cap.** The latency deep-dive showed the walk itself dominates, not process spawn: 15–40 candidates × 600-char excerpts = 10–25KB prompts to the haiku judge on every substantive turn. Full-tier candidates are now priority-ordered — user-origin seeds by match score, then context seeds, then graph-activated nodes by activation — and capped at `RECALL_WALKER_MAX_CANDIDATES` (default 14), so the judge reads the strongest few instead of everything the graph touched.
|
|
6
|
+
- **Episodic memory — recall now remembers how it did things last time.** The discoverer gains a transcript hop: on full-tier turns it queries the project-transcript FTS index (up to `RECALL_EPISODE_LIMIT`, default 3; `RECALL_EPISODES=off` disables) and offers the hits to the walker as `episode:` candidates with their matched snippets. The walker keeps one only if that past exchange holds a decision, method, or outcome directly reusable for the current message — and the fail-open path (walker off/failed) *drops* episodes rather than injecting unjudged snippets. Kept episodes render as a "Past work that looks related" block with the snippet plus a ready-to-run `open-claudia transcript-search … --all` pointer for the full context, and surface in the `/recall` banner as 📓 lines.
|
|
7
|
+
- **Real cost + phase telemetry for recall.** The warm walker's stream-json `total_cost_usd` is cumulative per session; it now tracks the running total and reports the per-walk *delta*, which flows into `recall-metrics.jsonl` per turn (`costUsd`) instead of the flat 0 logged before. Each turn also records `seedMs` / `expandMs` / `walkerMs` and which walker path ran (`warm`/`cold`/`off`/`failed`), so the p50 can be decomposed instead of guessed at.
|
|
8
|
+
- **Weak-use signal from the post-turn reviewer.** Precision metrics only counted explicit 📖 opens as "used". Now, when the reviewer decides a turn's work updated pack X or entity Y, that adjudication is logged as use (`source: "reviewer"`) and co-updated nodes get a Hebbian co-work reinforcement — so packs that quietly shape work without being re-opened stop looking like noise.
|
|
9
|
+
- **Journal dedupe.** The reviewer could append near-identical Journal lines turn after turn (observed: 28 same-day duplicates on one busy pack). `updatePack` now token-Jaccard-compares a new journal line (dates stripped) against the last 5 entries and skips appends at ≥0.75 similarity, flagging `journalDeduped` instead. Adds `test-journal-dedupe.js`.
|
|
10
|
+
|
|
3
11
|
## v2.6.57
|
|
4
12
|
- **Move the inactivity watchdog into the live runner.** Deep review found v2.6.56 put the watchdog in the legacy `bot-agent.js` monolith, but the LaunchAgent runs `bot.js`, which delegates heavy turns through `core/runner.js`. That meant the installed live path still only had the 6-hour hard timeout and could still wedge on a silent child. This release ports the watchdog to `core/runner.js`, updates activity on child stdout/stderr, terminates the process tree after `OC_TURN_IDLE_MS` of silence, suppresses the confusing partial/no-output final message, clears stuck busy state with a failsafe, and drains any queued follow-up messages instead of stranding them. Adds a static regression test that fails if the watchdog only exists in `bot-agent.js` again.
|
|
5
13
|
|
package/README.md
CHANGED
|
@@ -436,6 +436,8 @@ All stored in `~/.open-claudia/`:
|
|
|
436
436
|
| `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
|
|
437
437
|
| `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `classic`) |
|
|
438
438
|
| `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
|
|
439
|
+
| `RECALL_WALKER_MAX_CANDIDATES` | No | Cap on candidates sent to the discoverer's walker judge per turn (default `14`) |
|
|
440
|
+
| `RECALL_EPISODES` / `RECALL_EPISODE_LIMIT` | No | `off` disables episodic transcript candidates in recall; limit caps transcript hits per turn (default `3`) |
|
|
439
441
|
| `PROJECT_TRANSCRIPTS` | No | Enable redacted project transcripts (default `true`) |
|
|
440
442
|
| `TRANSCRIPT_MAX_ENTRY_CHARS` | No | Max chars per transcript entry (default `12000`) |
|
|
441
443
|
| `TRANSCRIPTS_DIR` / `PACKS_DIR` / `ENTITIES_DIR` | No | Override storage directories |
|
package/core/pack-review.js
CHANGED
|
@@ -312,9 +312,11 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
|
312
312
|
const decision = parseDecision(text);
|
|
313
313
|
if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && (decision.lessons || []).length === 0)) return;
|
|
314
314
|
const lines = [];
|
|
315
|
+
const usedIds = []; // nodes this turn's work demonstrably touched
|
|
315
316
|
for (const a of decision.actions) {
|
|
316
317
|
try {
|
|
317
318
|
const r = applyAction(a);
|
|
319
|
+
if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
|
|
318
320
|
if (r && r.kind === "skipped") {
|
|
319
321
|
lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
|
|
320
322
|
} else if (r && r.kind === "create" && r.ability) {
|
|
@@ -332,6 +334,7 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
|
332
334
|
try {
|
|
333
335
|
const r = applyEntityAction(ea);
|
|
334
336
|
if (r) {
|
|
337
|
+
if (r.slug) usedIds.push(`entity:${r.slug}`);
|
|
335
338
|
const emoji = ENTITY_EMOJI[r.type] || ENTITY_EMOJI.thing;
|
|
336
339
|
lines.push(r.kind === "create"
|
|
337
340
|
? `${emoji} Now tracking ${r.name} (${r.type}) — ${clipWords(r.note, 160)}`
|
|
@@ -341,6 +344,16 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
|
|
|
341
344
|
console.warn(`[pack-review] entity apply failed: ${e.message}`);
|
|
342
345
|
}
|
|
343
346
|
}
|
|
347
|
+
// Weak-use signal: the reviewer deciding a pack/entity needed updating is
|
|
348
|
+
// evidence the turn genuinely worked on that node — use, observed after
|
|
349
|
+
// the fact, even when the agent never re-opened the note (it may already
|
|
350
|
+
// have been injected). Feeds the same precision metric as 📖 opens and
|
|
351
|
+
// reinforces co-use edges. This is co-WORK, never co-recall — surfacing
|
|
352
|
+
// alone still reinforces nothing.
|
|
353
|
+
if (usedIds.length) {
|
|
354
|
+
try { require("./recall/metrics").logUse(usedIds, "reviewer"); } catch (e) {}
|
|
355
|
+
try { if (usedIds.length > 1) require("./recall/graph").reinforceSet(usedIds); } catch (e) {}
|
|
356
|
+
}
|
|
344
357
|
for (const la of (decision.lessons || [])) {
|
|
345
358
|
try {
|
|
346
359
|
const r = applyLessonAction(la);
|
package/core/packs.js
CHANGED
|
@@ -343,8 +343,35 @@ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
|
|
|
343
343
|
} catch (e) {}
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
// Near-duplicate check for journal appends. The reviewer runs after EVERY
|
|
347
|
+
// substantial turn, so a long working session on one topic used to stack
|
|
348
|
+
// dozens of same-day entries that only differ in phrasing (observed: 28 in a
|
|
349
|
+
// day on one pack) — crowding real history out of the capped Journal. Token
|
|
350
|
+
// Jaccard against the most recent entries; dates stripped before comparing.
|
|
351
|
+
function journalTokens(s) {
|
|
352
|
+
return new Set(
|
|
353
|
+
String(s || "").toLowerCase()
|
|
354
|
+
.replace(/^-\s*\[[^\]]*\]\s*/, "")
|
|
355
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
356
|
+
.split(/\s+/).filter((w) => w.length > 1)
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isNearDupJournal(line, recentEntries) {
|
|
361
|
+
const a = journalTokens(line);
|
|
362
|
+
if (!a.size) return false;
|
|
363
|
+
for (const e of recentEntries) {
|
|
364
|
+
const b = journalTokens(e);
|
|
365
|
+
if (!b.size) continue;
|
|
366
|
+
let inter = 0;
|
|
367
|
+
for (const w of a) if (b.has(w)) inter++;
|
|
368
|
+
if (inter / (a.size + b.size - inter) >= 0.75) return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
346
373
|
// Apply a reviewer mutation. Only supplied fields change; journal entries
|
|
347
|
-
// append (capped); state replaces (it represents "current truth").
|
|
374
|
+
// append (capped + deduped); state replaces (it represents "current truth").
|
|
348
375
|
function updatePack(dir, { description, tags, stance, procedure, state, journal, skill, kind, learned_on } = {}, origin = "user") {
|
|
349
376
|
const pack = readPack(dir);
|
|
350
377
|
if (!pack) throw new Error(`no pack: ${dir}`);
|
|
@@ -370,9 +397,14 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
|
|
|
370
397
|
if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
|
|
371
398
|
if (typeof journal === "string" && journal.trim()) {
|
|
372
399
|
const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
400
|
+
const line = journal.trim().replace(/\n+/g, " ");
|
|
401
|
+
if (isNearDupJournal(line, entries.slice(-5))) {
|
|
402
|
+
pack.journalDeduped = true;
|
|
403
|
+
} else {
|
|
404
|
+
entries.push(`- [${today()}] ${line}`);
|
|
405
|
+
pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
|
|
406
|
+
applied.push("Journal");
|
|
407
|
+
}
|
|
376
408
|
}
|
|
377
409
|
writePack(pack);
|
|
378
410
|
if (applied.length) setProvenance(dir, applied, origin);
|
|
@@ -590,7 +622,7 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
|
|
|
590
622
|
|
|
591
623
|
module.exports = {
|
|
592
624
|
PACKS_DIR, SECTIONS, slugify,
|
|
593
|
-
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
|
|
625
|
+
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack, isNearDupJournal,
|
|
594
626
|
listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
|
|
595
627
|
touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
|
|
596
628
|
archivePack, restorePack, listArchived,
|
|
@@ -26,10 +26,21 @@ const toolsLib = require("../tools");
|
|
|
26
26
|
// entity-only). Optional — absent on old node where node:sqlite is missing.
|
|
27
27
|
let toolGraph = null;
|
|
28
28
|
try { toolGraph = require("../tool-graph"); } catch (e) { /* old node */ }
|
|
29
|
+
// Episodic memory: FTS over past transcripts (same optionality as the graphs).
|
|
30
|
+
let transcriptIndex = null;
|
|
31
|
+
try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old node */ }
|
|
29
32
|
|
|
30
33
|
const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
|
|
31
34
|
const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
|
|
32
35
|
const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
|
|
36
|
+
// Cap on pack/entity candidates handed to the walker. Uncapped, a wordy turn
|
|
37
|
+
// seeds 15-40 nodes × 600-char excerpts — the walker then reads a 10-25KB
|
|
38
|
+
// prompt, which measured as THE dominant recall latency (p50 ~13.5s even with
|
|
39
|
+
// the warm process). Capped, the prompt stays small; priority order below keeps
|
|
40
|
+
// the strongest evidence.
|
|
41
|
+
const MAX_WALKER_CANDIDATES = Number(process.env.RECALL_WALKER_MAX_CANDIDATES || 14);
|
|
42
|
+
const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
|
|
43
|
+
const EPISODE_LIMIT = Number(process.env.RECALL_EPISODE_LIMIT || 3);
|
|
33
44
|
const EXCERPT_CHARS = 600;
|
|
34
45
|
const CONTEXT_CLIP = 3000;
|
|
35
46
|
|
|
@@ -45,16 +56,30 @@ function maybeSync(packsLib, entitiesLib) {
|
|
|
45
56
|
try { graph.syncFromCorpus(packsLib, entitiesLib); } catch (e) {}
|
|
46
57
|
}
|
|
47
58
|
|
|
48
|
-
//
|
|
49
|
-
|
|
59
|
+
// Tiered pre-gate. The old boolean gate returned true whenever FTS found ANY
|
|
60
|
+
// seed — with a 147-pack corpus that is virtually every turn (measured: gated
|
|
61
|
+
// 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
|
|
62
|
+
// skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
|
|
63
|
+
// seeds — short command-like turns (≤4 words): keyword seeds are precise
|
|
64
|
+
// enough; inject user-origin seeds directly and skip graph + walker
|
|
65
|
+
// (the classic baseline, ~ms instead of ~13s).
|
|
66
|
+
// full — substantive turns: graph expansion + walker judgement.
|
|
67
|
+
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/;
|
|
68
|
+
|
|
69
|
+
function recallTier(userText, seedCount) {
|
|
50
70
|
const t = String(userText || "").trim().toLowerCase();
|
|
51
|
-
if (!t) return
|
|
52
|
-
if (seedCount > 0) return true; // FTS already found something — never gate it out
|
|
53
|
-
// No seeds: only bother with graph/walker work for a substantive message.
|
|
71
|
+
if (!t) return "skip";
|
|
54
72
|
const words = t.split(/\s+/).filter(Boolean);
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
73
|
+
if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
|
|
74
|
+
if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
|
|
75
|
+
if (words.length <= 2 && seedCount === 0) return "skip";
|
|
76
|
+
if (words.length <= 4) return "seeds";
|
|
77
|
+
return "full";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Back-compat boolean view of the tier (exported/tested API).
|
|
81
|
+
function needsRecall(userText, seedCount) {
|
|
82
|
+
return recallTier(userText, seedCount) !== "skip";
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
function idFor(m) { return m.dir ? `pack:${m.dir}` : `entity:${m.slug}`; }
|
|
@@ -95,6 +120,7 @@ const WALKER_SYSTEM = [
|
|
|
95
120
|
"Decide which nodes are GENUINELY relevant to what the user is doing now.",
|
|
96
121
|
"Keep graph-linked nodes ONLY if they actually bear on the task (e.g. a shared theme/commit-style that governs the thing being worked on). Drop incidental keyword overlaps.",
|
|
97
122
|
"Some candidates are reusable tools (id starts `tool:`). Keep a tool ONLY if running it would actually help with THIS task; drop tools merely related by keyword.",
|
|
123
|
+
"Some candidates are past-conversation episodes (id starts `episode:`). Keep one ONLY if that past exchange holds a decision, method, or outcome directly reusable for the current message — not mere topic overlap.",
|
|
98
124
|
"For each kept node write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
|
|
99
125
|
'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
|
|
100
126
|
].join("\n");
|
|
@@ -102,20 +128,25 @@ const WALKER_SYSTEM = [
|
|
|
102
128
|
// Run the walker model on the prompt. Prefer the warm (reused) process for
|
|
103
129
|
// low latency; on any warm-path error fall back to a cold spawn — identical
|
|
104
130
|
// model/prompt/contract, so quality is unchanged and recall never silently
|
|
105
|
-
// degrades to the classic engine.
|
|
131
|
+
// degrades to the classic engine. Returns { text, costUsd, source }.
|
|
106
132
|
async function runWalker(prompt) {
|
|
107
133
|
const opts = { model: WALKER_MODEL, systemPrompt: WALKER_SYSTEM, timeoutMs: WALKER_TIMEOUT_MS };
|
|
108
134
|
if (warmWalker.isEnabled()) {
|
|
109
135
|
try {
|
|
110
|
-
|
|
136
|
+
const r = await warmWalker.walkWarm(prompt, opts);
|
|
137
|
+
return { text: r.text, costUsd: r.costUsd, source: "warm" };
|
|
111
138
|
} catch (e) { /* fall back to cold spawn below */ }
|
|
112
139
|
}
|
|
113
140
|
const { text } = await spawnSubagent(prompt, opts);
|
|
114
|
-
return text;
|
|
141
|
+
return { text, costUsd: null, source: "cold" };
|
|
115
142
|
}
|
|
116
143
|
|
|
144
|
+
// Judge candidates. Returns { kept: Map|null, costUsd, source, walkerMs } —
|
|
145
|
+
// kept null means fail-open (walker off/failed/unparseable).
|
|
117
146
|
async function walk(userText, contextText, candidates) {
|
|
118
|
-
if (!WALKER_ENABLED || candidates.length === 0)
|
|
147
|
+
if (!WALKER_ENABLED || candidates.length === 0) {
|
|
148
|
+
return { kept: null, costUsd: null, source: "off", walkerMs: 0 };
|
|
149
|
+
}
|
|
119
150
|
const lines = candidates.map((c) => {
|
|
120
151
|
const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
|
|
121
152
|
return `- ${c.id}: ${c.name}${tag}${c.description ? `\n ${c.description}` : ""}${c.excerpt ? `\n excerpt: ${c.excerpt.replace(/\n/g, " ")}` : ""}`;
|
|
@@ -130,12 +161,14 @@ async function walk(userText, contextText, candidates) {
|
|
|
130
161
|
"",
|
|
131
162
|
'Reply ONLY with the JSON array of kept nodes and their why, e.g. [{"id":"' + candidates[0].id + '","why":"..."}].',
|
|
132
163
|
].join("\n");
|
|
164
|
+
const t0 = Date.now();
|
|
133
165
|
try {
|
|
134
|
-
const text = await runWalker(prompt);
|
|
166
|
+
const { text, costUsd, source } = await runWalker(prompt);
|
|
167
|
+
const walkerMs = Date.now() - t0;
|
|
135
168
|
const match = String(text || "").match(/\[[\s\S]*\]/);
|
|
136
|
-
if (!match) return null;
|
|
169
|
+
if (!match) return { kept: null, costUsd, source, walkerMs };
|
|
137
170
|
const arr = JSON.parse(match[0]);
|
|
138
|
-
if (!Array.isArray(arr)) return null;
|
|
171
|
+
if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs };
|
|
139
172
|
const known = new Set(candidates.map((c) => c.id));
|
|
140
173
|
const out = new Map();
|
|
141
174
|
for (const item of arr) {
|
|
@@ -143,9 +176,9 @@ async function walk(userText, contextText, candidates) {
|
|
|
143
176
|
out.set(item.id, String(item.why || "").slice(0, 120));
|
|
144
177
|
}
|
|
145
178
|
}
|
|
146
|
-
return out;
|
|
179
|
+
return { kept: out, costUsd, source, walkerMs };
|
|
147
180
|
} catch (e) {
|
|
148
|
-
return null;
|
|
181
|
+
return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0 };
|
|
149
182
|
}
|
|
150
183
|
}
|
|
151
184
|
|
|
@@ -154,8 +187,6 @@ async function run(ctx) {
|
|
|
154
187
|
const { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock } = helpers;
|
|
155
188
|
const started = Date.now();
|
|
156
189
|
|
|
157
|
-
maybeSync(packsLib, entitiesLib);
|
|
158
|
-
|
|
159
190
|
// 1+2: seed via FTS (user words + context), same as classic.
|
|
160
191
|
let packSeeds = [];
|
|
161
192
|
let entSeeds = [];
|
|
@@ -185,32 +216,55 @@ async function run(ctx) {
|
|
|
185
216
|
} catch (e) {}
|
|
186
217
|
|
|
187
218
|
const seedCount = packSeeds.length + entSeeds.length + toolSeedByName.size;
|
|
219
|
+
const seedMs = Date.now() - started;
|
|
188
220
|
|
|
189
|
-
// 1: pre-gate.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
221
|
+
// 1: tiered pre-gate.
|
|
222
|
+
const tier = recallTier(userText, seedCount);
|
|
223
|
+
if (tier === "skip") {
|
|
224
|
+
metrics.logTurn({ engine: "discoverer", query: userText, gated: true, tier, latencyMs: Date.now() - started });
|
|
225
|
+
return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true };
|
|
193
226
|
}
|
|
194
227
|
|
|
195
|
-
// 3: spreading activation from seeds across the graph
|
|
228
|
+
// 3: spreading activation from seeds across the graph (full tier only — the
|
|
229
|
+
// seeds tier injects keyword seeds directly, no graph, no walker).
|
|
196
230
|
const seedNodes = [
|
|
197
231
|
...packSeeds.map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
|
|
198
232
|
...entSeeds.map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
|
|
199
233
|
];
|
|
200
234
|
const seedIds = new Set(seedNodes.map((s) => s.id));
|
|
201
235
|
let activated = new Map();
|
|
202
|
-
|
|
236
|
+
const expandStarted = Date.now();
|
|
237
|
+
if (tier === "full") {
|
|
238
|
+
maybeSync(packsLib, entitiesLib);
|
|
239
|
+
try { activated = graph.expand(seedNodes, {}); } catch (e) {}
|
|
240
|
+
}
|
|
241
|
+
const expandMs = Date.now() - expandStarted;
|
|
203
242
|
|
|
204
|
-
// 4: assemble candidates (seeds + activated)
|
|
205
|
-
|
|
206
|
-
|
|
243
|
+
// 4: assemble candidates (seeds + activated) in priority order — user seeds
|
|
244
|
+
// by score, then context seeds, then graph-activated by activation — capped
|
|
245
|
+
// at MAX_WALKER_CANDIDATES so the walker prompt stays small and fast. Only
|
|
246
|
+
// the full tier pays for excerpts; the seeds tier never reads them.
|
|
247
|
+
const seedRank = new Map();
|
|
248
|
+
for (const m of packSeeds) seedRank.set(`pack:${m.dir}`, { score: m.score || 2, origin: m.origin });
|
|
249
|
+
for (const m of entSeeds) seedRank.set(`entity:${m.slug}`, { score: m.score || 2, origin: m.origin });
|
|
250
|
+
const orderedIds = [...seedIds].sort((a, b) => {
|
|
251
|
+
const ra = seedRank.get(a) || {}, rb = seedRank.get(b) || {};
|
|
252
|
+
const ua = ra.origin === "user" ? 0 : 1, ub = rb.origin === "user" ? 0 : 1;
|
|
253
|
+
return ua - ub || (rb.score || 0) - (ra.score || 0);
|
|
254
|
+
});
|
|
255
|
+
for (const [id] of [...activated.entries()].sort((x, y) => (y[1].activation || 0) - (x[1].activation || 0))) {
|
|
256
|
+
if (!seedIds.has(id)) orderedIds.push(id);
|
|
257
|
+
}
|
|
207
258
|
const candidates = [];
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
259
|
+
if (tier === "full") {
|
|
260
|
+
for (const id of orderedIds.slice(0, MAX_WALKER_CANDIDATES)) {
|
|
261
|
+
const base = excerptFor(id, packsLib, entitiesLib);
|
|
262
|
+
if (!base) continue;
|
|
263
|
+
const act = activated.get(id);
|
|
264
|
+
candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
|
|
265
|
+
}
|
|
213
266
|
}
|
|
267
|
+
const candIds = new Set(tier === "full" ? candidates.map((c) => c.id) : seedIds);
|
|
214
268
|
|
|
215
269
|
// Tool candidates = direct tool seeds + tools that belong to any candidate
|
|
216
270
|
// pack (declared via the tool's `pack:` header, or used-in-topic per the
|
|
@@ -221,7 +275,7 @@ async function run(ctx) {
|
|
|
221
275
|
for (const id of candIds) if (id.startsWith("pack:")) candPackDirs.add(id.slice(5));
|
|
222
276
|
const toolCand = new Map();
|
|
223
277
|
for (const [name, t] of toolSeedByName) toolCand.set(name, { name, description: t.description, via: null });
|
|
224
|
-
if (candPackDirs.size) {
|
|
278
|
+
if (tier === "full" && candPackDirs.size) {
|
|
225
279
|
try {
|
|
226
280
|
const byPack = new Map();
|
|
227
281
|
for (const t of toolsLib.listTools()) {
|
|
@@ -247,13 +301,40 @@ async function run(ctx) {
|
|
|
247
301
|
} catch (e) {}
|
|
248
302
|
}
|
|
249
303
|
}
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
304
|
+
if (tier === "full") {
|
|
305
|
+
for (const c of toolCand.values()) {
|
|
306
|
+
const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
|
|
307
|
+
if (!base) continue;
|
|
308
|
+
candidates.push({ ...base, activated: !!c.via, via: c.via });
|
|
309
|
+
}
|
|
254
310
|
}
|
|
255
311
|
|
|
256
|
-
|
|
312
|
+
// Episodic hop: FTS over past transcripts turns up prior sessions on this
|
|
313
|
+
// topic; the walker judges them like any node. This is the bridge from
|
|
314
|
+
// knowledge (packs) to episodes (what we actually did last time) — kept ones
|
|
315
|
+
// inject as a snippet plus a pointer to reopen the conversation window.
|
|
316
|
+
let episodeCands = [];
|
|
317
|
+
if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
|
|
318
|
+
try {
|
|
319
|
+
const { hits } = transcriptIndex.search(userText, { limit: EPISODE_LIMIT });
|
|
320
|
+
episodeCands = (hits || []).map((h) => {
|
|
321
|
+
const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
|
|
322
|
+
return {
|
|
323
|
+
id: `episode:${require("path").basename(String(h.file || ""), ".jsonl")}:${h.line}`,
|
|
324
|
+
name: `Past conversation${h.project ? ` (${h.project})` : ""}${h.ts ? ` ${String(h.ts).slice(0, 10)}` : ""}`,
|
|
325
|
+
description: "",
|
|
326
|
+
excerpt: clip(String(h.snip || "").replace(/>>|<</g, ""), EXCERPT_CHARS),
|
|
327
|
+
activated: true,
|
|
328
|
+
via: "transcript",
|
|
329
|
+
_terms: terms,
|
|
330
|
+
};
|
|
331
|
+
});
|
|
332
|
+
candidates.push(...episodeCands);
|
|
333
|
+
} catch (e) { /* episodic recall is best-effort */ }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const walkRes = await walk(userText, contextText || fullContext, candidates);
|
|
337
|
+
const whyById = walkRes.kept;
|
|
257
338
|
|
|
258
339
|
// Decide the kept set. Walker result wins; on fail-open keep the user-origin
|
|
259
340
|
// seeds only (classic baseline) and drop graph-expanded/context guesses.
|
|
@@ -320,20 +401,40 @@ async function run(ctx) {
|
|
|
320
401
|
toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — run with \`open-claudia tool run <name> [args]\`; read docs/source with \`open-claudia tool show <name>\`:\n${lines.join("\n")}\n`;
|
|
321
402
|
}
|
|
322
403
|
|
|
404
|
+
// Past-conversation episodes the walker kept (fail-open drops them — an
|
|
405
|
+
// unjudged transcript snippet is a guess, not a memory).
|
|
406
|
+
const episodeMatches = episodeCands
|
|
407
|
+
.filter((c) => keptIds.has(c.id))
|
|
408
|
+
.map((c) => ({ id: c.id, name: c.name, excerpt: c.excerpt, terms: c._terms, why: whyById ? whyById.get(c.id) || "" : "" }));
|
|
409
|
+
let episodeBlock = "";
|
|
410
|
+
if (episodeMatches.length) {
|
|
411
|
+
const lines = episodeMatches.map((m) => {
|
|
412
|
+
const q = (m.terms && m.terms.length ? m.terms.join(" ") : String(m.excerpt).split(/\s+/).slice(0, 4).join(" ")).replace(/"/g, "");
|
|
413
|
+
return `- ${m.name}: "${m.excerpt}"${m.why ? ` — ${m.why}` : ""}\n ↳ full context: open-claudia transcript-search "${q}" --all`;
|
|
414
|
+
});
|
|
415
|
+
episodeBlock = `\n\n## Past work that looks related\nFrom earlier conversations — verify before relying on it:\n${lines.join("\n")}\n`;
|
|
416
|
+
}
|
|
417
|
+
|
|
323
418
|
metrics.logTurn({
|
|
324
419
|
engine: "discoverer",
|
|
325
420
|
query: userText,
|
|
421
|
+
tier,
|
|
326
422
|
seeds: seedNodes,
|
|
327
423
|
activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
|
|
328
424
|
kept: [...keptIds].map((id) => ({ id, why: whyById ? whyById.get(id) : "" })),
|
|
329
425
|
gated: false,
|
|
330
426
|
latencyMs: Date.now() - started,
|
|
427
|
+
seedMs,
|
|
428
|
+
expandMs,
|
|
429
|
+
walkerMs: walkRes.walkerMs,
|
|
430
|
+
walker: walkRes.source,
|
|
431
|
+
costUsd: walkRes.costUsd || 0,
|
|
331
432
|
});
|
|
332
433
|
|
|
333
434
|
return {
|
|
334
|
-
packBlock, entityBlock, toolBlock, packMatches: finalPacks, entityMatches: finalEnts,
|
|
335
|
-
toolMatches, why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
|
|
435
|
+
packBlock, entityBlock, toolBlock, episodeBlock, packMatches: finalPacks, entityMatches: finalEnts,
|
|
436
|
+
toolMatches, episodeMatches, why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
|
|
336
437
|
};
|
|
337
438
|
}
|
|
338
439
|
|
|
339
|
-
module.exports = { name: "discoverer", run, needsRecall, walk };
|
|
440
|
+
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk };
|
package/core/recall/metrics.js
CHANGED
|
@@ -17,8 +17,9 @@ function enabled() {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
// Record one recall turn. `entry`:
|
|
20
|
-
// { engine, query, seeds:[{id,score}], activated:[{id,activation,hop}],
|
|
21
|
-
// kept:[{id,why}], gated (bool), latencyMs,
|
|
20
|
+
// { engine, query, tier, seeds:[{id,score}], activated:[{id,activation,hop}],
|
|
21
|
+
// kept:[{id,why}], gated (bool), latencyMs, seedMs, expandMs, walkerMs,
|
|
22
|
+
// walker ("warm"|"cold"|"off"|"failed"), costUsd }
|
|
22
23
|
function logTurn(entry) {
|
|
23
24
|
if (!enabled()) return;
|
|
24
25
|
try {
|
|
@@ -26,11 +27,16 @@ function logTurn(entry) {
|
|
|
26
27
|
ts: new Date().toISOString(),
|
|
27
28
|
engine: entry.engine || "discoverer",
|
|
28
29
|
query: String(entry.query || "").slice(0, 200),
|
|
30
|
+
tier: entry.tier || "",
|
|
29
31
|
seeds: (entry.seeds || []).map((s) => ({ id: s.id, score: s.score })),
|
|
30
32
|
activated: (entry.activated || []).map((a) => ({ id: a.id, activation: round(a.activation), hop: a.hop })),
|
|
31
33
|
kept: (entry.kept || []).map((k) => ({ id: k.id, why: String(k.why || "").slice(0, 200) })),
|
|
32
34
|
gated: !!entry.gated,
|
|
33
35
|
latencyMs: entry.latencyMs || 0,
|
|
36
|
+
seedMs: entry.seedMs || 0,
|
|
37
|
+
expandMs: entry.expandMs || 0,
|
|
38
|
+
walkerMs: entry.walkerMs || 0,
|
|
39
|
+
walker: entry.walker || "",
|
|
34
40
|
costUsd: entry.costUsd || 0,
|
|
35
41
|
};
|
|
36
42
|
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
|
|
@@ -38,15 +44,19 @@ function logTurn(entry) {
|
|
|
38
44
|
} catch (e) {}
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
// Record which nodes were actually
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
47
|
+
// Record which nodes were actually used this turn. Matched against the most
|
|
48
|
+
// recent surfaced sets to derive precision/noise without a second pass over
|
|
49
|
+
// the whole log. `source` distinguishes how use was observed: "" / undefined
|
|
50
|
+
// for 📖 opens (agent explicitly read the note), "reviewer" for post-turn
|
|
51
|
+
// reviewer adjudication (the turn's work updated that pack/entity).
|
|
52
|
+
function logUse(openedIds, source) {
|
|
45
53
|
if (!enabled()) return;
|
|
46
54
|
const ids = [...new Set((openedIds || []).filter(Boolean))];
|
|
47
55
|
if (!ids.length) return;
|
|
48
56
|
try {
|
|
49
|
-
|
|
57
|
+
const rec = { ts: new Date().toISOString(), use: ids };
|
|
58
|
+
if (source) rec.source = String(source);
|
|
59
|
+
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
|
|
50
60
|
const s = readSummary();
|
|
51
61
|
s.opens = (s.opens || 0) + ids.length;
|
|
52
62
|
writeSummary(s);
|
|
@@ -66,6 +76,7 @@ function bumpSummary(rec) {
|
|
|
66
76
|
const s = readSummary();
|
|
67
77
|
s.turns = (s.turns || 0) + 1;
|
|
68
78
|
if (rec.gated) s.gatedTurns = (s.gatedTurns || 0) + 1;
|
|
79
|
+
if (rec.tier === "seeds") s.seedsOnlyTurns = (s.seedsOnlyTurns || 0) + 1;
|
|
69
80
|
s.seedsTotal = (s.seedsTotal || 0) + rec.seeds.length;
|
|
70
81
|
s.activatedTotal = (s.activatedTotal || 0) + rec.activated.length;
|
|
71
82
|
s.keptTotal = (s.keptTotal || 0) + rec.kept.length;
|
|
@@ -88,6 +99,8 @@ function summary() {
|
|
|
88
99
|
turns,
|
|
89
100
|
gatedTurns: s.gatedTurns || 0,
|
|
90
101
|
gatedPct: fmtPct(s.gatedTurns || 0, turns),
|
|
102
|
+
seedsOnlyTurns: s.seedsOnlyTurns || 0,
|
|
103
|
+
seedsOnlyPct: fmtPct(s.seedsOnlyTurns || 0, turns),
|
|
91
104
|
avgSeeds: turns ? round((s.seedsTotal || 0) / turns) : 0,
|
|
92
105
|
avgActivated: turns ? round((s.activatedTotal || 0) / turns) : 0,
|
|
93
106
|
avgKept: turns ? round((s.keptTotal || 0) / turns) : 0,
|
|
@@ -30,6 +30,7 @@ let pending = null; // { resolve, reject, timer } for the in-flight walk
|
|
|
30
30
|
let chain = Promise.resolve(); // serialises walks (one message in flight)
|
|
31
31
|
let msgCount = 0; // messages sent to the current process
|
|
32
32
|
let charCount = 0; // prompt chars sent to the current process
|
|
33
|
+
let costTotal = 0; // cumulative total_cost_usd reported by the session
|
|
33
34
|
|
|
34
35
|
function isEnabled() {
|
|
35
36
|
return String(process.env.RECALL_WARM_WALKER || "on").toLowerCase() !== "off";
|
|
@@ -60,6 +61,7 @@ function spawnChild(cfg) {
|
|
|
60
61
|
child = proc;
|
|
61
62
|
msgCount = 0;
|
|
62
63
|
charCount = 0;
|
|
64
|
+
costTotal = 0;
|
|
63
65
|
let buf = "";
|
|
64
66
|
let asstText = "";
|
|
65
67
|
|
|
@@ -88,7 +90,14 @@ function spawnChild(cfg) {
|
|
|
88
90
|
} else if (evt.type === "result") {
|
|
89
91
|
const text = (typeof evt.result === "string" && evt.result) ? evt.result : asstText;
|
|
90
92
|
asstText = "";
|
|
91
|
-
|
|
93
|
+
// total_cost_usd is cumulative for the stream-json session; the delta
|
|
94
|
+
// from the previous result is this walk's cost.
|
|
95
|
+
let costUsd = null;
|
|
96
|
+
if (typeof evt.total_cost_usd === "number") {
|
|
97
|
+
costUsd = Math.max(0, evt.total_cost_usd - costTotal);
|
|
98
|
+
costTotal = evt.total_cost_usd;
|
|
99
|
+
}
|
|
100
|
+
if (text && !evt.is_error) settle("resolve", { text: redactSensitive(String(text).trim()), costUsd });
|
|
92
101
|
else settle("reject", new Error("warm walker: empty/error result"));
|
|
93
102
|
}
|
|
94
103
|
}
|
|
@@ -136,6 +145,8 @@ function doWalk(promptText, opts) {
|
|
|
136
145
|
|
|
137
146
|
// Serialise: one message in flight at a time. A failed walk must not poison the
|
|
138
147
|
// queue, so the chain swallows outcomes while callers still see their result.
|
|
148
|
+
// Resolves { text, costUsd } — costUsd is the per-walk delta of the session's
|
|
149
|
+
// cumulative total_cost_usd (null when the CLI doesn't report it).
|
|
139
150
|
function walkWarm(promptText, opts = {}) {
|
|
140
151
|
const run = () => doWalk(promptText, opts);
|
|
141
152
|
const p = chain.then(run, run);
|
package/core/runner.js
CHANGED
|
@@ -1035,7 +1035,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1035
1035
|
const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
|
|
1036
1036
|
// 🧠 packs/entities recall — gated by /recall.
|
|
1037
1037
|
if (settings.showRecall) {
|
|
1038
|
-
const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤")];
|
|
1038
|
+
const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤"), ...fmt(r.episodes || [], "📓")];
|
|
1039
1039
|
if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
|
|
1040
1040
|
else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
|
|
1041
1041
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -743,6 +743,7 @@ async function promptWithDynamicContext(prompt, opts = {}) {
|
|
|
743
743
|
});
|
|
744
744
|
const { packBlock, entityBlock } = result;
|
|
745
745
|
const toolBlock = result.toolBlock || "";
|
|
746
|
+
const episodeBlock = result.episodeBlock || "";
|
|
746
747
|
const why = result.why || {};
|
|
747
748
|
lastInjected.recall = {
|
|
748
749
|
engine: engine.name || recall.activeEngineName(settings),
|
|
@@ -753,12 +754,14 @@ async function promptWithDynamicContext(prompt, opts = {}) {
|
|
|
753
754
|
// Drives the /tooltrace "surfaced" banner; the toolBlock below is the
|
|
754
755
|
// context the agent actually sees.
|
|
755
756
|
tools: (result.toolMatches || []).map((m) => ({ name: m.name, why: m.why || why[`tool:${m.name}`] || "" })),
|
|
757
|
+
// Past-conversation episodes the walker kept (drives the /recall banner).
|
|
758
|
+
episodes: (result.episodeMatches || []).map((m) => ({ name: m.name, why: m.why || "" })),
|
|
756
759
|
};
|
|
757
760
|
const budgetNote = budget.omitted > 0
|
|
758
761
|
? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
|
|
759
762
|
: "";
|
|
760
763
|
const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
|
|
761
|
-
return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
|
|
764
|
+
return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${episodeBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
|
|
762
765
|
} catch (e) {
|
|
763
766
|
return prompt;
|
|
764
767
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.58",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -24,13 +24,21 @@ toolsLib.addTool((() => {
|
|
|
24
24
|
return s;
|
|
25
25
|
})(), { name: "mikrotik-audit", description: "audit the mikrotik router fleet inventory" });
|
|
26
26
|
|
|
27
|
-
// --- pre-gate ---
|
|
27
|
+
// --- tiered pre-gate ---
|
|
28
28
|
assert.strictEqual(disc.needsRecall("", 0), false, "empty → no recall");
|
|
29
29
|
assert.strictEqual(disc.needsRecall("thanks", 0), false, "pleasantry → no recall");
|
|
30
30
|
assert.strictEqual(disc.needsRecall("ok", 0), false, "terse ack → no recall");
|
|
31
31
|
assert.strictEqual(disc.needsRecall("yo", 0), false, "two words, no seeds → no recall");
|
|
32
32
|
assert.strictEqual(disc.needsRecall("how do I deploy the mobile app", 0), true, "substantive → recall");
|
|
33
|
-
|
|
33
|
+
// The old gate kept ANY turn with seeds (measured: gated 0.4% of turns, so
|
|
34
|
+
// "thanks!" paid a full walk). New contract: pleasantries gate regardless.
|
|
35
|
+
assert.strictEqual(disc.needsRecall("k", 5), false, "terse ack gates even when seeds match");
|
|
36
|
+
assert.strictEqual(disc.recallTier("thanks!", 8), "skip", "pleasantry + seeds → skip");
|
|
37
|
+
assert.strictEqual(disc.recallTier("👍", 3), "skip", "emoji-only → skip");
|
|
38
|
+
assert.strictEqual(disc.recallTier("restart bot", 3), "seeds", "short command → seeds-only tier");
|
|
39
|
+
assert.strictEqual(disc.recallTier("show fup logs", 2), "seeds", "≤4 words → seeds-only tier");
|
|
40
|
+
assert.strictEqual(disc.recallTier("yes we need to see the 100", 2), "full", "substantive yes-prefixed turn → full");
|
|
41
|
+
assert.strictEqual(disc.recallTier("how do I deploy the mobile app", 4), "full", "substantive → full");
|
|
34
42
|
|
|
35
43
|
// --- shared stub corpus + helpers ---
|
|
36
44
|
const packs = {
|
|
@@ -110,5 +118,26 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
|
|
|
110
118
|
assert.strictEqual(outNoTool.toolBlock, "", "no tool match → empty toolBlock");
|
|
111
119
|
assert.strictEqual(outNoTool.toolMatches.length, 0, "no tool match → no toolMatches");
|
|
112
120
|
|
|
121
|
+
// seeds tier: a short command-like turn skips graph+walker but still injects
|
|
122
|
+
// keyword seeds directly (classic baseline, ~ms path).
|
|
123
|
+
builtPacks = null;
|
|
124
|
+
const seedsTier = await disc.run({
|
|
125
|
+
userText: "mobile app status", contextText: "", fullContext: "mobile app status",
|
|
126
|
+
packLimit: 6, budget: {}, helpers,
|
|
127
|
+
});
|
|
128
|
+
assert.strictEqual(seedsTier.gated, false, "seeds tier is not gated");
|
|
129
|
+
assert.strictEqual(seedsTier.packBlock, "PACKBLOCK", "seeds tier injects keyword seeds");
|
|
130
|
+
assert.ok(seedsTier.packMatches.some((m) => m.dir === "kazee-mobile"), "seeds tier keeps the user-origin seed");
|
|
131
|
+
assert.strictEqual(seedsTier.episodeBlock, "", "seeds tier never injects episodes");
|
|
132
|
+
|
|
133
|
+
// episodes: with the walker off, transcript hits are candidates but the
|
|
134
|
+
// fail-open path must DROP them — an unjudged snippet never injects.
|
|
135
|
+
const epi = await disc.run({
|
|
136
|
+
userText: "help me deploy the mobile app updater again", contextText: "",
|
|
137
|
+
fullContext: "help me deploy the mobile app updater again", packLimit: 6, budget: {}, helpers,
|
|
138
|
+
});
|
|
139
|
+
assert.strictEqual(epi.episodeBlock, "", "fail-open drops unjudged episodes");
|
|
140
|
+
assert.deepStrictEqual(epi.episodeMatches, [], "fail-open keeps no episodeMatches");
|
|
141
|
+
|
|
113
142
|
console.log("recall discoverer OK");
|
|
114
143
|
})().catch((e) => { console.error(e); process.exit(1); });
|