@inetafrica/open-claudia 2.6.40 → 2.6.42

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.42
4
+ - **Read-time `📖 Recalled my notes on …` now fires for direct file reads, not just the CLI.** The read-side recall banner — and the Hebbian co-use signal that reinforces the recall graph — was wired only to shell commands, watching for `open-claudia pack show <dir>` / `entity show <slug>`. When the agent opened one of its own notes by reading the raw `…/packs/<dir>/PACK.md` or `…/entities/<slug>.md` straight through the Read tool, the detector never saw it: no banner, and the open never counted as co-use. A new `core/recall/read-signal.js` resolves a file path to its memory node (mirroring the existing Write/Edit path-detection), and `runner.js` calls it on Read tool-uses across the Claude and Cursor backends — deduped against the CLI path via the shared notify key, so reading the same node both ways still announces once. Adds `test-read-signal.js`.
5
+
3
6
  ## v2.6.37
4
7
  - **`/recall [on|off]` — watch recall work.** A per-chat debug toggle that, when on, posts a short `🧠 Recall this turn` line just before each reply listing the packs/entities that surfaced — and on the **discoverer** engine, the one-line why-bullet for each. On a gated turn (pre-gate skipped recall) it says so; on a quiet turn with no matches it stays silent. The engines now return a `why` map + `gated` flag, `promptWithDynamicContext` captures a recall summary into the per-turn `consumeLastInjected()` buffer, and `runner.js` renders it when `settings.showRecall` is set. Off by default; flip with `/recall` (buttons) or `/recall on`.
5
8
 
package/core/dream.js CHANGED
@@ -41,6 +41,16 @@ const DREAM_MODEL = pickDreamModel();
41
41
  // reasoning effort by default. Overridable via DREAM_EFFORT (low|medium|high|xhigh|max).
42
42
  const DREAM_EFFORT = process.env.DREAM_EFFORT || "max";
43
43
  const DREAM_CRON = process.env.DREAM_CRON || "0 4 * * *";
44
+ // Consolidation model timeout. The model weighs the WHOLE corpus in one pass,
45
+ // so a fixed budget that was fine at 30 packs starves at 130 (the timeout that
46
+ // motivated this). Floor 20m, +6s per pack, capped at 40m; DREAM_TIMEOUT_MS
47
+ // (ms) overrides entirely. Even if it still times out, the deterministic phases
48
+ // (prefix nesting, graph tend) run regardless — the model pass is best-effort.
49
+ function dreamTimeoutMs(packCount = 0) {
50
+ const override = Number(process.env.DREAM_TIMEOUT_MS);
51
+ if (override > 0) return override;
52
+ return Math.min(20 * 60 * 1000 + packCount * 6000, 40 * 60 * 1000);
53
+ }
44
54
  const MAX_PACK_CHARS = 2500;
45
55
  const MAX_ENTITY_CHARS = 900;
46
56
  const LIMITS = {
@@ -277,7 +287,7 @@ function applyDream(decision, backupRoot) {
277
287
  const lines = [];
278
288
  const gone = new Set(); // dirs/slugs removed this run
279
289
 
280
- for (const m of decision.merges) {
290
+ for (const m of decision.merges || []) {
281
291
  try {
282
292
  const into = m?.into && packs.readPack(m.into);
283
293
  const from = [].concat(m?.from || []).filter((d) => d && d !== m.into && !gone.has(d) && packs.readPack(d));
@@ -312,7 +322,7 @@ function applyDream(decision, backupRoot) {
312
322
  } catch (e) { console.warn(`[dream] merge failed: ${e.message}`); }
313
323
  }
314
324
 
315
- for (const u of decision.umbrellas) {
325
+ for (const u of decision.umbrellas || []) {
316
326
  try {
317
327
  const dir = packs.slugify(u?.dir || u?.name);
318
328
  if (!dir || gone.has(dir)) continue;
@@ -345,7 +355,7 @@ function applyDream(decision, backupRoot) {
345
355
  } catch (e) { console.warn(`[dream] umbrella failed: ${e.message}`); }
346
356
  }
347
357
 
348
- for (const p of decision.parents) {
358
+ for (const p of decision.parents || []) {
349
359
  try {
350
360
  const child = p?.pack && !gone.has(p.pack) && packs.readPack(p.pack);
351
361
  if (!child || !p.parent || gone.has(p.parent) || !packs.readPack(p.parent)) continue;
@@ -358,7 +368,7 @@ function applyDream(decision, backupRoot) {
358
368
  } catch (e) { console.warn(`[dream] parent failed: ${e.message}`); }
359
369
  }
360
370
 
361
- for (const r of decision.retag) {
371
+ for (const r of decision.retag || []) {
362
372
  try {
363
373
  if (!r?.pack || gone.has(r.pack) || !packs.readPack(r.pack)) continue;
364
374
  const desc = typeof r.description === "string" ? r.description.trim() : "";
@@ -374,7 +384,7 @@ function applyDream(decision, backupRoot) {
374
384
  } catch (e) { console.warn(`[dream] retag failed: ${e.message}`); }
375
385
  }
376
386
 
377
- for (const em of decision.entity_merges) {
387
+ for (const em of decision.entity_merges || []) {
378
388
  try {
379
389
  const into = em?.into && entities.readEntity(em.into);
380
390
  const from = [].concat(em?.from || []).filter((s) => s && s !== em.into && !gone.has(s) && entities.readEntity(s));
@@ -397,7 +407,7 @@ function applyDream(decision, backupRoot) {
397
407
  } catch (e) { console.warn(`[dream] entity merge failed: ${e.message}`); }
398
408
  }
399
409
 
400
- for (const en of decision.entity_notes) {
410
+ for (const en of decision.entity_notes || []) {
401
411
  try {
402
412
  const ent = en?.entity && !gone.has(en.entity) && entities.readEntity(en.entity);
403
413
  if (!ent || typeof en.notes !== "string" || !en.notes.trim()) continue;
@@ -736,14 +746,26 @@ async function runDream({ trigger = "manual" } = {}) {
736
746
 
737
747
  _dreaming = true;
738
748
  try {
739
- const { text } = await spawnSubagent(buildDreamPrompt(), {
740
- model: DREAM_MODEL,
741
- effort: DREAM_EFFORT,
742
- timeoutMs: 8 * 60 * 1000,
743
- systemPrompt: "You are a background memory consolidation process. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
744
- });
745
- const decision = parseDream(text);
746
- if (!decision) return { skipped: "dream model returned unparseable output" };
749
+ // Best-effort consolidation: on a large corpus this call can time out or
750
+ // return unreadable JSON. That must NOT abort the dream — the deterministic
751
+ // phases below (prefix nesting, graph tend) need no model and are the
752
+ // load-bearing cleanup. Catch any model failure, fall back to an empty
753
+ // decision (applyDream no-ops), note it for the report, and carry on.
754
+ let decision = null;
755
+ let modelFailNote = "";
756
+ try {
757
+ const { text } = await spawnSubagent(buildDreamPrompt(), {
758
+ model: DREAM_MODEL,
759
+ effort: DREAM_EFFORT,
760
+ timeoutMs: dreamTimeoutMs(packCount),
761
+ systemPrompt: "You are a background memory consolidation process. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
762
+ });
763
+ decision = parseDream(text);
764
+ if (!decision) modelFailNote = "the AI consolidation step returned unreadable output";
765
+ } catch (e) {
766
+ modelFailNote = `the AI consolidation step didn't finish (${e.message})`;
767
+ }
768
+ if (!decision) decision = { report: "" };
747
769
 
748
770
  const backupRoot = makeBackupRoot();
749
771
  const applied = applyDream(decision, backupRoot);
@@ -754,7 +776,10 @@ async function runDream({ trigger = "manual" } = {}) {
754
776
  try { nestLines = nestPrefixFamilies(backupRoot).lines; }
755
777
  catch (e) { console.warn(`[dream] nesting pass failed: ${e.message}`); }
756
778
  const consolidationLines = applied.concat(nestLines);
757
- const report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
779
+ let report = decision.report || (consolidationLines.length > 0 ? "Tidied up my memory overnight." : "");
780
+ if (modelFailNote) {
781
+ report = `Heads up — ${modelFailNote}, so I skipped the AI-led merges this round but still ran the automatic filing + graph upkeep.${report ? " " + report : ""}`;
782
+ }
758
783
 
759
784
  // Phase 2: self-improvement introspection. Reviews the day's seeds, reads
760
785
  // its own code/memory READ-ONLY (allowedTools whitelist), promotes lessons
@@ -860,7 +885,7 @@ function initDream(adapters) {
860
885
  }
861
886
 
862
887
  module.exports = {
863
- runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies,
888
+ runDream, initDream, buildDreamPrompt, parseDream, applyDream, manageAbilityTiers, nestPrefixFamilies, dreamTimeoutMs,
864
889
  buildIntrospectionPrompt, parseIntrospection, applyIntrospection, writeDreamReport,
865
890
  enabled, summaryEnabled, introspectEnabled, selfApplyEnabled,
866
891
  DREAM_CRON, DREAM_MODEL, DREAM_EFFORT, PROMOTE_MIN_PROJECTS,
@@ -0,0 +1,32 @@
1
+ // Read-time recall signal: resolve a file path to the memory node it
2
+ // represents — a context pack (…/packs/<dir>/PACK.md) or an entity note
3
+ // (…/entities/<slug>.md) — or null if it's neither.
4
+ //
5
+ // The runner fires "📖 Recalled my notes on …" and reinforces the recall
6
+ // co-use graph when the agent OPENS one of its own notes. That detection used
7
+ // to watch only shell commands (`open-claudia pack show <dir>` / `entity show
8
+ // <slug>`), so reading the raw .md straight through the Read tool was invisible
9
+ // to both the banner and the graph. This mirrors the Write/Edit path-detection
10
+ // so a direct read counts the same as a CLI read. Best-effort: it returns a
11
+ // display name when the doc is on disk and falls back to the id otherwise.
12
+ const packsLib = require("../packs");
13
+ const entitiesLib = require("../entities");
14
+
15
+ function recallNodeFromPath(filePath) {
16
+ if (!filePath) return null;
17
+ try {
18
+ const packDir = packsLib.packNameFromPath(filePath);
19
+ if (packDir) {
20
+ const pack = packsLib.readPack(packDir);
21
+ return { kind: "pack", id: packDir, name: (pack && (pack.name || pack.dir)) || packDir };
22
+ }
23
+ const entSlug = entitiesLib.entityNameFromPath(filePath);
24
+ if (entSlug) {
25
+ const ent = entitiesLib.readEntity(entSlug);
26
+ return { kind: "entity", id: entSlug, name: (ent && (ent.name || ent.slug)) || entSlug };
27
+ }
28
+ } catch (e) { /* best-effort: announcements never block a turn */ }
29
+ return null;
30
+ }
31
+
32
+ module.exports = { recallNodeFromPath };
package/core/runner.js CHANGED
@@ -28,6 +28,7 @@ const skillsLib = require("./skills");
28
28
  const packsLib = require("./packs");
29
29
  const entitiesLib = require("./entities");
30
30
  const packReview = require("./pack-review");
31
+ const { recallNodeFromPath } = require("./recall/read-signal");
31
32
  const {
32
33
  appendUsageRecord,
33
34
  loadUsageHistory,
@@ -897,6 +898,20 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
897
898
  } catch (e) { /* announcements are best-effort */ }
898
899
  };
899
900
 
901
+ // The same 📖 signal when the agent opens a note with the Read tool instead
902
+ // of the CLI: noteRecallFromShell only sees shell commands, so a raw-file
903
+ // read of a pack/entity would otherwise be invisible to both the banner and
904
+ // the co-use graph. Deduped against the CLI path via the shared notifySkill
905
+ // key, so reading the same node both ways still announces only once.
906
+ const noteRecallFromReadPath = (filePath) => {
907
+ try {
908
+ const node = recallNodeFromPath(filePath);
909
+ if (!node) return;
910
+ openedThisTurn.add(`${node.kind}:${node.id}`);
911
+ notifySkill(`recall:${node.kind}:${node.id}`, `📖 Recalled my notes on: ${node.name}`);
912
+ } catch (e) { /* announcements are best-effort */ }
913
+ };
914
+
900
915
  let args;
901
916
  try {
902
917
  args = await buildClaudeArgs(prompt, opts);
@@ -1009,6 +1024,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1009
1024
  else currentToolDetail = "";
1010
1025
  noteSkillToolUse(block.name, input);
1011
1026
  if (block.name === "Bash" && input.command) noteRecallFromShell(input.command);
1027
+ else if (block.name === "Read") noteRecallFromReadPath(input.file_path || input.filePath);
1012
1028
  }
1013
1029
  }
1014
1030
  }
@@ -1021,7 +1037,9 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1021
1037
  if (a.command) noteRecallFromShell(a.command);
1022
1038
  } else if (tc.readToolCall) {
1023
1039
  currentTool = "Read"; toolUses.push("Read");
1024
- currentToolDetail = (tc.readToolCall.args?.path || "").split("/").slice(-2).join("/");
1040
+ const readPath = tc.readToolCall.args?.path;
1041
+ currentToolDetail = (readPath || "").split("/").slice(-2).join("/");
1042
+ noteRecallFromReadPath(readPath);
1025
1043
  } else if (tc.editToolCall) {
1026
1044
  currentTool = "Edit"; toolUses.push("Edit");
1027
1045
  currentToolDetail = (tc.editToolCall.args?.filePath || "").split("/").slice(-2).join("/");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.40",
3
+ "version": "2.6.42",
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-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"
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-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"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -42,7 +42,8 @@
42
42
  "test-ability-tiers.js",
43
43
  "test-ability-merge-guard.js",
44
44
  "test-learning-e2e.js",
45
- "test-pack-nesting.js"
45
+ "test-pack-nesting.js",
46
+ "test-read-signal.js"
46
47
  ],
47
48
  "keywords": [
48
49
  "claude",
@@ -77,6 +77,21 @@ assert.ok(res.lines.some((l) => l.includes("billing")), "nest pass announces the
77
77
  const again = dream.nestPrefixFamilies(null);
78
78
  assert.strictEqual(again.filed, 0, "second pass files nothing new");
79
79
 
80
+ // ── dream resilience: the deterministic cleanup must not depend on the model ──
81
+ // applyDream tolerates an empty decision (the fallback used when the AI
82
+ // consolidation step times out / returns garbage) instead of throwing.
83
+ assert.deepStrictEqual(dream.applyDream({}, null), [], "empty decision applies nothing, throws nothing");
84
+ assert.deepStrictEqual(dream.applyDream({ report: "" }, null), [], "report-only fallback decision is safe");
85
+
86
+ // timeout scales with corpus: floor 20m, grows per pack, capped at 40m, env override.
87
+ assert.strictEqual(dream.dreamTimeoutMs(0), 20 * 60 * 1000, "floor is 20 minutes");
88
+ assert.ok(dream.dreamTimeoutMs(130) > dream.dreamTimeoutMs(30), "more packs → longer budget");
89
+ assert.strictEqual(dream.dreamTimeoutMs(100000), 40 * 60 * 1000, "capped at 40 minutes");
90
+ const prev = process.env.DREAM_TIMEOUT_MS;
91
+ process.env.DREAM_TIMEOUT_MS = "777000";
92
+ assert.strictEqual(dream.dreamTimeoutMs(50), 777000, "DREAM_TIMEOUT_MS overrides entirely");
93
+ if (prev === undefined) delete process.env.DREAM_TIMEOUT_MS; else process.env.DREAM_TIMEOUT_MS = prev;
94
+
80
95
  // ── versioned-only fold (FTS-gated): a versioned proposal folds into its pack,
81
96
  // a non-versioned near-duplicate does NOT. Skipped when node:sqlite is absent. ──
82
97
  if (packs.reindex()) {
@@ -0,0 +1,57 @@
1
+ // Read-time recall signal: a Read of a pack/entity .md must resolve to the
2
+ // right memory node so the runner can fire "📖 Recalled my notes on …" (and
3
+ // reinforce the co-use graph) even when the agent reads the raw file directly
4
+ // instead of going through `open-claudia pack show` / `entity show`.
5
+ const assert = require("assert");
6
+ const fs = require("fs");
7
+ const os = require("os");
8
+ const path = require("path");
9
+
10
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "read-signal-"));
11
+ process.env.PACKS_DIR = path.join(tmp, "packs");
12
+ process.env.ENTITIES_DIR = path.join(tmp, "entities");
13
+
14
+ const packs = require("./core/packs");
15
+ const entities = require("./core/entities");
16
+ const { recallNodeFromPath } = require("./core/recall/read-signal");
17
+
18
+ // ── a pack PACK.md resolves to its pack node (display name preferred) ──
19
+ packs.createPack({ dir: "billing-invoices", name: "Billing Invoices", description: "invoice generation" });
20
+ const packPath = path.join(process.env.PACKS_DIR, "billing-invoices", "PACK.md");
21
+ assert.deepStrictEqual(
22
+ recallNodeFromPath(packPath),
23
+ { kind: "pack", id: "billing-invoices", name: "Billing Invoices" },
24
+ "pack path → pack node carrying the display name"
25
+ );
26
+
27
+ // ── an entity .md resolves to its entity node ──
28
+ entities.upsertEntity({ name: "David Omondi", type: "person", description: "store keeper" });
29
+ const entPath = path.join(process.env.ENTITIES_DIR, "david-omondi.md");
30
+ assert.deepStrictEqual(
31
+ recallNodeFromPath(entPath),
32
+ { kind: "entity", id: "david-omondi", name: "David Omondi" },
33
+ "entity path → entity node carrying the display name"
34
+ );
35
+
36
+ // ── a node that isn't on disk still resolves by id (name falls back to id) ──
37
+ assert.deepStrictEqual(
38
+ recallNodeFromPath(path.join(process.env.PACKS_DIR, "ghost-pack", "PACK.md")),
39
+ { kind: "pack", id: "ghost-pack", name: "ghost-pack" },
40
+ "missing pack falls back to id as name"
41
+ );
42
+
43
+ // ── anything that isn't a pack/entity file resolves to nothing ──
44
+ assert.strictEqual(recallNodeFromPath(null), null, "null path → null");
45
+ assert.strictEqual(recallNodeFromPath("/etc/passwd"), null, "outside the memory dirs → null");
46
+ assert.strictEqual(
47
+ recallNodeFromPath(path.join(process.env.PACKS_DIR, "billing-invoices", "notes.md")),
48
+ null,
49
+ "a non-PACK.md file inside a pack dir → null"
50
+ );
51
+ assert.strictEqual(
52
+ recallNodeFromPath(path.join(process.env.ENTITIES_DIR, "sub", "deep.md")),
53
+ null,
54
+ "an entity .md in a subdir → null"
55
+ );
56
+
57
+ console.log("read signal OK");