@inetafrica/open-claudia 2.6.47 → 2.6.49

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.49
4
+ - **Reusable tools + a directed tool-usage graph.** Two linked features. First, **reusable tools** (`core/tools.js`, `bin/tool.js`): the executable sibling of context packs — when the agent works out an operational procedure (hit an API, drive an interface, transform a file) it crystallises the script into a re-runnable command instead of a throwaway heredoc. Tools are one executable file with a parseable comment header, run pre-authed (the operational keyring is merged into their env so they reference `$NAME` and never hardcode a secret), can link to an owning skill pack, and are surfaced every turn as an always-on index (full docs/source on demand via `open-claudia tool show <name>`). Full CLI: `open-claudia tool list|show|add|run|remove`; `/learn` now crystallises the executable part too. Second, a **directed tool-graph** (`core/tool-graph.js`): a "tool B follows tool A" graph kept deliberately separate from the recall graph — for tools the useful signal is the *order* a chain runs in (auth → list → download), so edges are stored and traversed directionally. Each run-sequence reinforces consecutive pairs (Hebbian, decayed nightly, no structural floor so unused chains prune away). `tool show` and the system-prompt index surface "usually followed by …"; `pack show` gains the reverse view (a pack's linked tools); the nightly dream tends the graph (decay + orphan prune) and flags tools whose `--pack` link dangles. Adds `test-tools.js` + `test-tool-graph.js`.
5
+
3
6
  ## v2.6.43
4
7
  - **Fix the `🧠 Recall this turn` banner rendering raw HTML.** The recall debug banner (from `/recall on`) is built with `<b>` tags and HTML-escaped names, but its two `send()` calls in `runner.js` omitted the Telegram `parseMode: "HTML"` opts that every normal reply already passes via `telegramHtmlOpts()` — so the tags and `&amp;` entities showed up literally in chat instead of rendering. Both recall sends now pass `telegramHtmlOpts()`, matching the rest of the reply path. Display-only; no change to recall behaviour itself.
5
8
 
package/bin/cli.js CHANGED
@@ -284,6 +284,11 @@ switch (command) {
284
284
  break;
285
285
  }
286
286
 
287
+ case "tool": {
288
+ require("./tool").run(args.slice(1));
289
+ break;
290
+ }
291
+
287
292
  case "entity": {
288
293
  require("./entity").run(args.slice(1));
289
294
  break;
@@ -365,6 +370,7 @@ Memory tools:
365
370
  open-claudia transcript-window <pattern> Search project transcript, show hits with context
366
371
  (alias: tw; --help for options)
367
372
  open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
373
+ open-claudia tool list|show|add|run|remove Reusable tools: executable scripts the agent saves & re-runs (keyring preauth)
368
374
  open-claudia entity list|show|match|note Entity notes: people/places/projects memory
369
375
  open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
370
376
  open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
package/bin/pack.js CHANGED
@@ -102,6 +102,16 @@ function run(args) {
102
102
  const prov = packs.readProvenance(p.dir);
103
103
  const authored = packs.SECTIONS.map((s) => `${s}=${prov.sections[s] || "user"}`).join(" ");
104
104
  console.log(`\n# provenance: ${authored}${prov.lastWriter ? ` (last: ${prov.lastWriter} ${prov.ts})` : ""}`);
105
+ // Reverse view: the runnable siblings of this pack's prose. A pack's
106
+ // Procedure documents *how*; a linked tool *is* the how. Surfacing them
107
+ // here keeps the doc and its crystallised commands discoverable together.
108
+ try {
109
+ const linked = require("../core/tools").listTools().filter((t) => t.pack === p.dir);
110
+ if (linked.length) {
111
+ console.log(`\n# tools (${linked.length}) — run with 'open-claudia tool run <name>':`);
112
+ for (const t of linked) console.log(`# ${t.name} — ${t.description || "(no description)"}`);
113
+ }
114
+ } catch (e) { /* tools optional */ }
105
115
  break;
106
116
  }
107
117
 
package/bin/tool.js ADDED
@@ -0,0 +1,148 @@
1
+ // CLI: manage and run reusable tools — executable scripts the agent saves so a
2
+ // procedure it worked out once becomes a re-runnable command, not a re-typed
3
+ // heredoc. The executable sibling of context packs (`open-claudia pack`).
4
+ //
5
+ // open-claudia tool list — index (name — description [skill])
6
+ // open-claudia tool show <name> — full docs, path, keyring status
7
+ // open-claudia tool add <path> [--name n] — register a script as a tool
8
+ // [--pack <dir>] [--desc "..."] [--requires "k1,k2"] [--usage "..."]
9
+ // open-claudia tool run <name> [args...] — run it with keyring pre-loaded
10
+ // open-claudia tool remove <name> — delete one
11
+ // open-claudia tool path — print the tools directory
12
+ //
13
+ // Credentials: a tool runs with the operational keyring merged into its env, so
14
+ // reference creds as $NAME inside the script. `open-claudia keyring list` shows
15
+ // what's available; never hardcode secrets in a tool.
16
+
17
+ const { spawnSync } = require("child_process");
18
+ const tools = require("../core/tools");
19
+
20
+ // Minimal flag parser: pulls --key value (and --key=value) out of argv, returns
21
+ // { positional, flags }.
22
+ function parseFlags(argv) {
23
+ const positional = [];
24
+ const flags = {};
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a.startsWith("--")) {
28
+ const eq = a.indexOf("=");
29
+ if (eq >= 0) { flags[a.slice(2, eq)] = a.slice(eq + 1); }
30
+ else { flags[a.slice(2)] = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[++i] : true; }
31
+ } else positional.push(a);
32
+ }
33
+ return { positional, flags };
34
+ }
35
+
36
+ function run(args) {
37
+ const cmd = (args[0] || "list").toLowerCase();
38
+ const rest = args.slice(1);
39
+
40
+ switch (cmd) {
41
+ case "list":
42
+ case "ls": {
43
+ const all = tools.listTools();
44
+ if (all.length === 0) {
45
+ return console.log(`No tools yet (${tools.TOOLS_DIR}).\nSave one with: open-claudia tool add <script-path> --pack <skill>`);
46
+ }
47
+ console.log(`${all.length} reusable tool(s) — run via 'open-claudia tool run <name>':\n`);
48
+ for (const t of all) {
49
+ const skill = t.pack ? ` [skill: ${t.pack}]` : "";
50
+ console.log(`• ${t.name} — ${t.description || "(no description)"}${skill}`);
51
+ }
52
+ console.log(`\nDocs: open-claudia tool show <name> · Dir: ${tools.TOOLS_DIR}`);
53
+ break;
54
+ }
55
+
56
+ case "show":
57
+ case "cat": {
58
+ const name = rest[0];
59
+ if (!name) { console.error("Usage: tool show <name>"); process.exitCode = 1; return; }
60
+ const t = tools.findTool(name);
61
+ if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
62
+ console.log(`Tool: ${t.name}`);
63
+ if (t.description) console.log(`Description: ${t.description}`);
64
+ if (t.pack) console.log(`Skill pack: ${t.pack} (open-claudia pack show ${t.pack})`);
65
+ console.log(`Usage: open-claudia tool run ${t.name}${t.usage ? " (" + t.usage + ")" : ""}`);
66
+ if (t.requires.length) {
67
+ const missing = tools.missingRequires(t);
68
+ const status = missing.length ? `MISSING: ${missing.join(", ")} — set with 'open-claudia keyring set'` : "all present in keyring";
69
+ console.log(`Requires keyring keys: ${t.requires.join(", ")} (${status})`);
70
+ }
71
+ console.log(`File: ${t.file}${t.executable ? "" : " (not executable!)"}`);
72
+ // Directed tool-graph: what tends to run right after / before this one.
73
+ try {
74
+ const graph = require("../core/tool-graph");
75
+ const fmt = (rows) => rows.map((r) => `${r.name} (${r.weight.toFixed(1)})`).join(", ");
76
+ const after = graph.followers(t.name);
77
+ const before = graph.predecessors(t.name);
78
+ if (after.length) console.log(`Often followed by: ${fmt(after)}`);
79
+ if (before.length) console.log(`Often preceded by: ${fmt(before)}`);
80
+ } catch (e) { /* graph optional (old node) */ }
81
+ console.log(`\n----- source -----\n${t.content}`);
82
+ break;
83
+ }
84
+
85
+ case "add":
86
+ case "save": {
87
+ const { positional, flags } = parseFlags(rest);
88
+ const srcPath = positional[0];
89
+ if (!srcPath) {
90
+ console.error('Usage: tool add <path> [--name n] [--pack dir] [--desc "..."] [--requires "k1,k2"] [--usage "..."]');
91
+ process.exitCode = 1; return;
92
+ }
93
+ try {
94
+ const t = tools.addTool(srcPath, {
95
+ name: typeof flags.name === "string" ? flags.name : undefined,
96
+ pack: typeof flags.pack === "string" ? flags.pack : undefined,
97
+ description: typeof flags.desc === "string" ? flags.desc : (typeof flags.description === "string" ? flags.description : undefined),
98
+ requires: typeof flags.requires === "string" ? flags.requires.split(/[,\s]+/).filter(Boolean) : undefined,
99
+ usage: typeof flags.usage === "string" ? flags.usage : undefined,
100
+ });
101
+ console.log(`Saved tool "${t.name}"${t.pack ? ` (skill: ${t.pack})` : ""}.`);
102
+ console.log(`Run it: open-claudia tool run ${t.name}`);
103
+ if (t.requires.length) {
104
+ const missing = tools.missingRequires(t);
105
+ if (missing.length) console.log(`Note: missing keyring keys it needs: ${missing.join(", ")}`);
106
+ }
107
+ } catch (e) {
108
+ console.error(`Could not add tool: ${e.message}`); process.exitCode = 1;
109
+ }
110
+ break;
111
+ }
112
+
113
+ case "run":
114
+ case "exec": {
115
+ const name = rest[0];
116
+ if (!name) { console.error("Usage: tool run <name> [args...]"); process.exitCode = 1; return; }
117
+ const t = tools.findTool(name);
118
+ if (!t) { console.error(`No tool named "${name}". Run: open-claudia tool list`); process.exitCode = 1; return; }
119
+ const missing = tools.missingRequires(t);
120
+ if (missing.length) {
121
+ console.error(`Cannot run ${t.name}: missing keyring keys ${missing.join(", ")}. Set them with 'open-claudia keyring set <name> <value>'.`);
122
+ process.exitCode = 1; return;
123
+ }
124
+ const r = spawnSync(t.file, rest.slice(1), { stdio: "inherit", env: tools.runEnv() });
125
+ if (r.error) { console.error(`Failed to run ${t.name}: ${r.error.message}`); process.exitCode = 1; return; }
126
+ process.exitCode = typeof r.status === "number" ? r.status : 1;
127
+ break;
128
+ }
129
+
130
+ case "remove":
131
+ case "rm": {
132
+ const name = rest[0];
133
+ if (!name) { console.error("Usage: tool remove <name>"); process.exitCode = 1; return; }
134
+ const removed = tools.removeTool(name);
135
+ console.log(removed ? `Removed tool "${removed.name}".` : `No tool named "${name}".`);
136
+ break;
137
+ }
138
+
139
+ case "path":
140
+ console.log(tools.TOOLS_DIR);
141
+ break;
142
+
143
+ default:
144
+ console.log('Usage: open-claudia tool [list | show <name> | add <path> [flags] | run <name> [args] | remove <name> | path]');
145
+ }
146
+ }
147
+
148
+ module.exports = { run };
@@ -127,6 +127,16 @@ class VoiceAdapter {
127
127
  });
128
128
  }
129
129
 
130
+ // Management API — REST surface over the core stores (tasks, jobs, packs,
131
+ // entities, people, lessons, ideas) so the app is a full Open Claudia
132
+ // frontend, not just a voice channel.
133
+ if (pathname.startsWith("/v1/manage/")) {
134
+ return require("./manage").handle(req, res, url, {
135
+ json: (code, payload) => this._json(res, code, payload),
136
+ readBody: (cb) => this._readBody(req, res, cb),
137
+ });
138
+ }
139
+
130
140
  if (req.method === "POST" && pathname === "/v1/messages/text") {
131
141
  return this._readBody(req, res, (buf) => this._onText(buf, res));
132
142
  }
@@ -330,10 +340,11 @@ class VoiceAdapter {
330
340
  this._broadcast({ kind: "delete", messageId, ts: Date.now() });
331
341
  }
332
342
 
333
- async sendVoice(channelId, oggPath) {
343
+ async sendVoice(channelId, audioPath) {
334
344
  try {
335
- const id = this._registerMedia(oggPath, "audio/ogg", path.basename(oggPath));
336
- this._broadcast({ kind: "voice", messageId: this._mkId("v"), url: `/v1/media/${id}`, mime: "audio/ogg", ts: Date.now() });
345
+ const mime = this._guessMime(audioPath);
346
+ const id = this._registerMedia(audioPath, mime, path.basename(audioPath));
347
+ this._broadcast({ kind: "voice", messageId: this._mkId("v"), url: `/v1/media/${id}`, mime, ts: Date.now() });
337
348
  return true;
338
349
  } catch (e) {
339
350
  console.error("voice sendVoice error:", e.message);
@@ -341,6 +352,12 @@ class VoiceAdapter {
341
352
  }
342
353
  }
343
354
 
355
+ // Marks the end of a streamed multi-clip spoken reply so the client can stop
356
+ // queueing and re-arm the mic for the next hands-free turn.
357
+ async sendVoiceEnd() {
358
+ this._broadcast({ kind: "voice-end", ts: Date.now() });
359
+ }
360
+
344
361
  async sendPhoto(channelId, filePath, caption) { return this.sendFile(channelId, filePath, caption); }
345
362
 
346
363
  async sendFile(channelId, filePath, caption) {
@@ -442,7 +459,7 @@ class VoiceAdapter {
442
459
  _cors(res, code) {
443
460
  res.writeHead(code, {
444
461
  "Access-Control-Allow-Origin": "*",
445
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
462
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
446
463
  "Access-Control-Allow-Headers": "Authorization, Content-Type",
447
464
  });
448
465
  res.end();
@@ -0,0 +1,285 @@
1
+ // Management API for the voice/companion app — a thin REST surface over the
2
+ // same core stores the CLI uses (tasks, jobs, packs, entities, people,
3
+ // lessons, ideas). Lets the Expo client act as a full Open Claudia frontend:
4
+ // browse and manage work across every channel, not just the voice one.
5
+ //
6
+ // Mounted by the voice adapter under /v1/manage/*. Auth is already enforced
7
+ // by the adapter before we get here (same bearer token as the rest of the
8
+ // bridge). All routes return JSON.
9
+ //
10
+ // Scope note: tasks are stored per channel (tasks/<adapter>-<channelId>.json),
11
+ // so the owner's real backlog lives on the Telegram channel, not voice. We
12
+ // aggregate across every task file so the app shows the whole picture. The
13
+ // file stem is an opaque "key"; since tasks.filePathFor re-applies the same
14
+ // safe() (idempotent on already-safe strings) and just concatenates with a
15
+ // single "-", splitting the key at the first "-" round-trips to the same file
16
+ // regardless of where the boundary truly was.
17
+
18
+ const fs = require("fs");
19
+ const path = require("path");
20
+ const tasks = require("../../core/tasks");
21
+ const jobs = require("../../core/jobs");
22
+ const packs = require("../../core/packs");
23
+ const entities = require("../../core/entities");
24
+ const people = require("../../core/people");
25
+ const lessons = require("../../core/lessons");
26
+ const ideas = require("../../core/ideas");
27
+ const { TASKS_DIR, DEFAULT_CLAUDE_MODEL } = require("../../core/config");
28
+
29
+ let PKG_VERSION = "unknown";
30
+ try { PKG_VERSION = require("../../package.json").version || "unknown"; } catch (e) {}
31
+
32
+ // ── task channel keys ──────────────────────────────────────────────
33
+
34
+ function taskFileKeys() {
35
+ let entries = [];
36
+ try { entries = fs.readdirSync(TASKS_DIR); } catch (e) { return []; }
37
+ return entries
38
+ .filter((f) => f.endsWith(".json") && !f.endsWith(".tmp") && !f.startsWith("."))
39
+ .map((f) => f.slice(0, -5));
40
+ }
41
+
42
+ function keyToAC(key) {
43
+ const i = String(key).indexOf("-");
44
+ if (i < 0) return { adapter: key, channelId: "" };
45
+ return { adapter: key.slice(0, i), channelId: key.slice(i + 1) };
46
+ }
47
+
48
+ // Friendlier channel label for the UI than the raw file stem.
49
+ function channelLabel(key) {
50
+ const { adapter } = keyToAC(key);
51
+ const map = { telegram: "Telegram", kazee: "Kazee", voice: "Voice" };
52
+ return map[adapter] || adapter || key;
53
+ }
54
+
55
+ function isOpen(t) { return t.status === "pending" || t.status === "in_progress"; }
56
+
57
+ // ── responses ──────────────────────────────────────────────────────
58
+
59
+ function overview() {
60
+ const keys = taskFileKeys();
61
+ let openTasks = 0, inProgress = 0;
62
+ for (const key of keys) {
63
+ const { adapter, channelId } = keyToAC(key);
64
+ for (const t of tasks.load(adapter, channelId)) {
65
+ if (isOpen(t)) openTasks++;
66
+ if (t.status === "in_progress") inProgress++;
67
+ }
68
+ }
69
+ const allJobs = jobs.listAll();
70
+ return {
71
+ version: PKG_VERSION,
72
+ model: DEFAULT_CLAUDE_MODEL,
73
+ counts: {
74
+ tasksOpen: openTasks,
75
+ tasksInProgress: inProgress,
76
+ taskChannels: keys.length,
77
+ crons: allJobs.filter((j) => j.kind === "cron").length,
78
+ wakeups: allJobs.filter((j) => j.kind === "wakeup").length,
79
+ packs: safeLen(() => packs.listPacks()),
80
+ entities: safeLen(() => entities.listEntities()),
81
+ people: safeLen(() => people.list()),
82
+ lessons: safeLen(() => lessons.listLessons()),
83
+ ideas: safeLen(() => ideas.listIdeas()),
84
+ },
85
+ };
86
+ }
87
+
88
+ function safeLen(fn) { try { return (fn() || []).length; } catch (e) { return 0; } }
89
+
90
+ // Aggregated task tree across every channel, grouped per channel and sorted
91
+ // so the busiest channel (most open tasks) comes first.
92
+ function allTasks() {
93
+ const groups = [];
94
+ for (const key of taskFileKeys()) {
95
+ const { adapter, channelId } = keyToAC(key);
96
+ const tree = tasks.tree(adapter, channelId);
97
+ const open = tree.filter((r) => isOpen(r) || (r.children || []).some(isOpen)).length;
98
+ if (tree.length === 0) continue;
99
+ groups.push({ key, label: channelLabel(key), adapter, channelId, open, total: tree.length, tasks: tree });
100
+ }
101
+ groups.sort((a, b) => b.open - a.open || b.total - a.total);
102
+ return { groups };
103
+ }
104
+
105
+ function allJobsView() {
106
+ const list = jobs.listAll().map((j) => ({
107
+ id: j.id,
108
+ kind: j.kind,
109
+ label: j.label || "",
110
+ prompt: j.prompt || "",
111
+ adapterType: j.adapterType || j.adapter || "",
112
+ channelId: j.channelId || "",
113
+ project: j.project || null,
114
+ source: j.source || "agent",
115
+ schedule: j.schedule || null,
116
+ fireAt: j.fireAt || null,
117
+ lastFireAt: j.lastFireAt || null,
118
+ lastFireOk: j.lastFireOk === undefined ? null : j.lastFireOk,
119
+ createdAt: j.createdAt || null,
120
+ }));
121
+ // Wakeups by soonest fire, then crons.
122
+ list.sort((a, b) => {
123
+ if (a.kind !== b.kind) return a.kind === "wakeup" ? -1 : 1;
124
+ if (a.kind === "wakeup") return (a.fireAt || 0) - (b.fireAt || 0);
125
+ return String(a.label).localeCompare(String(b.label));
126
+ });
127
+ return { jobs: list };
128
+ }
129
+
130
+ function packSummaries() {
131
+ return {
132
+ packs: (packs.listPacks() || []).map((p) => ({
133
+ dir: p.dir, name: p.name, description: p.description, tags: p.tags,
134
+ parent: p.parent, skill: p.skill, kind: p.kind, updated: p.updated,
135
+ usage_count: p.usage_count, archived: !!p.archived,
136
+ })),
137
+ };
138
+ }
139
+
140
+ function packDetail(dir) {
141
+ const p = packs.readPack(dir);
142
+ if (!p) return null;
143
+ return p; // includes sections {Stance, Procedure, State, Journal}
144
+ }
145
+
146
+ function entitySummaries() {
147
+ return {
148
+ entities: (entities.listEntities() || []).map((e) => ({
149
+ slug: e.slug, name: e.name, type: e.type, description: e.description,
150
+ aliases: e.aliases, updated: e.updated, last_seen: e.last_seen,
151
+ })),
152
+ };
153
+ }
154
+
155
+ function entityDetail(slug) {
156
+ const e = entities.readEntity(slug);
157
+ if (!e) return null;
158
+ // readEntity stores sections as parseSections(body) → { sections: {...} }.
159
+ const sections = e.sections && e.sections.sections ? e.sections.sections : e.sections;
160
+ return { ...e, sections };
161
+ }
162
+
163
+ function peopleView() {
164
+ return {
165
+ people: (people.list() || []).map((p) => ({
166
+ id: p.id, name: p.name, isOwner: !!p.isOwner, bio: p.bio || null,
167
+ handles: (p.handles || []).map((h) => ({ adapter: h.adapter, channelId: h.channelId })),
168
+ primaryChannel: p.primaryChannel || null,
169
+ notes: (p.notes || []).map((n) => (typeof n === "string" ? { text: n } : { text: n.text || "", at: n.at || n.createdAt || null })),
170
+ })),
171
+ };
172
+ }
173
+
174
+ function lessonsView() {
175
+ return { lessons: (lessons.listLessons() || []).map((l) => ({ id: l.id, text: l.text, src: l.src || "", origin: l.origin || "", created: l.created || "", reinforced: l.reinforced || 0 })) };
176
+ }
177
+
178
+ function ideasView() {
179
+ return { ideas: (ideas.listIdeas() || []).map((i) => ({ id: i.id, text: i.text, created: i.created || "" })) };
180
+ }
181
+
182
+ // ── router ─────────────────────────────────────────────────────────
183
+ // helpers: { json(code, payload), readBody(cb) } supplied by the adapter so
184
+ // we don't reach into its privates.
185
+
186
+ function handle(req, res, url, helpers) {
187
+ const { json, readBody } = helpers;
188
+ const method = req.method;
189
+ const sub = url.pathname.slice("/v1/manage/".length).replace(/\/+$/, "");
190
+ const parts = sub.split("/").filter(Boolean);
191
+
192
+ try {
193
+ // GET reads
194
+ if (method === "GET") {
195
+ if (sub === "overview") return json(200, { ok: true, ...overview() });
196
+ if (sub === "tasks") return json(200, { ok: true, ...allTasks() });
197
+ if (sub === "jobs") return json(200, { ok: true, ...allJobsView() });
198
+ if (sub === "packs") return json(200, { ok: true, ...packSummaries() });
199
+ if (parts[0] === "packs" && parts[1]) {
200
+ const d = packDetail(decodeURIComponent(parts.slice(1).join("/")));
201
+ return d ? json(200, { ok: true, pack: d }) : json(404, { ok: false, error: "pack not found" });
202
+ }
203
+ if (sub === "entities") return json(200, { ok: true, ...entitySummaries() });
204
+ if (parts[0] === "entities" && parts[1]) {
205
+ const e = entityDetail(decodeURIComponent(parts.slice(1).join("/")));
206
+ return e ? json(200, { ok: true, entity: e }) : json(404, { ok: false, error: "entity not found" });
207
+ }
208
+ if (sub === "people") return json(200, { ok: true, ...peopleView() });
209
+ if (sub === "lessons") return json(200, { ok: true, ...lessonsView() });
210
+ if (sub === "ideas") return json(200, { ok: true, ...ideasView() });
211
+ return json(404, { ok: false, error: "unknown manage route" });
212
+ }
213
+
214
+ // POST /tasks — add a task (or subtask)
215
+ if (method === "POST" && sub === "tasks") {
216
+ return readBody((buf) => {
217
+ let body = {};
218
+ try { body = JSON.parse(buf.toString("utf-8") || "{}"); } catch (e) {}
219
+ const { adapter, channelId } = resolveTaskTarget(body);
220
+ const content = String(body.content || "").trim();
221
+ if (!content) return json(400, { ok: false, error: "content required" });
222
+ try {
223
+ const t = tasks.add(adapter, channelId, content, {
224
+ parentId: body.parentId || null,
225
+ description: body.description || null,
226
+ });
227
+ return json(201, { ok: true, task: t });
228
+ } catch (e) { return json(400, { ok: false, error: e.message }); }
229
+ });
230
+ }
231
+
232
+ // PATCH /tasks/:id — { key|adapter+channelId, action: start|done|remove|pending }
233
+ if (method === "PATCH" && parts[0] === "tasks" && parts[1]) {
234
+ const id = parts[1];
235
+ return readBody((buf) => {
236
+ let body = {};
237
+ try { body = JSON.parse(buf.toString("utf-8") || "{}"); } catch (e) {}
238
+ const { adapter, channelId } = resolveTaskTarget(body);
239
+ const action = String(body.action || "").toLowerCase();
240
+ try {
241
+ if (action === "start") {
242
+ const t = tasks.update(adapter, channelId, id, { status: "in_progress" });
243
+ return t ? json(200, { ok: true, task: t }) : json(404, { ok: false, error: "task not found" });
244
+ }
245
+ if (action === "pending") {
246
+ const t = tasks.update(adapter, channelId, id, { status: "pending" });
247
+ return t ? json(200, { ok: true, task: t }) : json(404, { ok: false, error: "task not found" });
248
+ }
249
+ if (action === "done") {
250
+ const r = tasks.complete(adapter, channelId, id);
251
+ if (!r) return json(404, { ok: false, error: "task not found" });
252
+ if (r.blocked) return json(409, { ok: false, error: "plan has open subtasks", openChildren: r.openChildren.map((c) => c.content) });
253
+ return json(200, { ok: true, removedCount: r.removedCount });
254
+ }
255
+ if (action === "remove") {
256
+ const r = tasks.remove(adapter, channelId, id);
257
+ return r ? json(200, { ok: true, alsoRemoved: r.alsoRemoved }) : json(404, { ok: false, error: "task not found" });
258
+ }
259
+ return json(400, { ok: false, error: "unknown action" });
260
+ } catch (e) { return json(400, { ok: false, error: e.message }); }
261
+ });
262
+ }
263
+
264
+ // DELETE /jobs/:id — cancel a wakeup or cron
265
+ if (method === "DELETE" && parts[0] === "jobs" && parts[1]) {
266
+ const removed = jobs.remove(parts[1]);
267
+ return removed ? json(200, { ok: true, removed: { id: removed.id, kind: removed.kind, label: removed.label } }) : json(404, { ok: false, error: "job not found" });
268
+ }
269
+
270
+ return json(404, { ok: false, error: "unknown manage route" });
271
+ } catch (e) {
272
+ return json(500, { ok: false, error: e.message });
273
+ }
274
+ }
275
+
276
+ // A task mutation needs the exact (adapter, channelId). The client passes the
277
+ // channel "key" (file stem) it got from GET /tasks; fall back to explicit
278
+ // adapter+channelId, then to the voice channel as a last resort.
279
+ function resolveTaskTarget(body) {
280
+ if (body && body.key) return keyToAC(body.key);
281
+ if (body && body.adapter) return { adapter: body.adapter, channelId: body.channelId || "" };
282
+ return { adapter: "voice", channelId: "voice-owner" };
283
+ }
284
+
285
+ module.exports = { handle };
package/core/dream.js CHANGED
@@ -681,6 +681,7 @@ function writeDreamReport(data) {
681
681
  sec.push("", "### Proposed (not applied — DREAM_SELF_APPLY=off)", "```json", JSON.stringify(data.introspection.proposed, null, 2), "```");
682
682
  }
683
683
  if (data.graphNote) sec.push("", data.graphNote);
684
+ if (data.toolNote) sec.push("", data.toolNote);
684
685
  if (data.staleNote) sec.push("", data.staleNote);
685
686
  if (data.backupRoot) sec.push("", `Backups: ${data.backupRoot}`);
686
687
  const block = sec.join("\n") + "\n\n---\n\n";
@@ -825,6 +826,25 @@ async function runDream({ trigger = "manual" } = {}) {
825
826
  }
826
827
  } catch (e) { /* graph is best-effort */ }
827
828
 
829
+ // Tool hygiene: tend the directed tool-graph (decay reinforced follows-edges,
830
+ // prune faded/orphaned ones) and flag tools whose --pack link now dangles.
831
+ // Read-only toward the tools themselves — a tool is never auto-deleted (that's
832
+ // destructive); we just surface the breakage for the user to re-link or remove.
833
+ let toolNote = "";
834
+ try {
835
+ const toolsLib = require("./tools");
836
+ const tg = require("./tool-graph").tend(toolsLib);
837
+ const bits = [];
838
+ if (tg.edges || tg.decayed || tg.pruned) {
839
+ bits.push(`🔧 Tool graph: ${tg.edges} edges / ${tg.nodes} nodes (decayed ${tg.decayed}, pruned ${tg.pruned}).`);
840
+ }
841
+ const dangling = toolsLib.listTools().filter((t) => t.pack && !packs.readPack(t.pack));
842
+ if (dangling.length) {
843
+ bits.push(`🔗 ${dangling.length} tool(s) link a missing skill pack: ${dangling.map((t) => `${t.name}→${t.pack}`).join(", ")} — re-link with --pack or remove.`);
844
+ }
845
+ toolNote = bits.join("\n");
846
+ } catch (e) { /* tool graph is best-effort (old node) */ }
847
+
828
848
  const staleNote = staleTaskReport();
829
849
  const dreamLines = consolidationLines.concat(introApplied);
830
850
 
@@ -832,7 +852,7 @@ async function runDream({ trigger = "manual" } = {}) {
832
852
  model: DREAM_MODEL, effort: DREAM_EFFORT, trigger,
833
853
  consolidation: { report: decision.report, lines: consolidationLines },
834
854
  introspection: { report: introReport, lines: introApplied, proposed: introProposed },
835
- graphNote, staleNote, backupRoot,
855
+ graphNote, toolNote, staleNote, backupRoot,
836
856
  });
837
857
 
838
858
  // Richer chat summary: consolidation + introspection narrative + what
@@ -846,12 +866,13 @@ async function runDream({ trigger = "manual" } = {}) {
846
866
  if (n) parts.push(`📋 ${n} self-improvement change(s) staged for your OK (DREAM_SELF_APPLY=off) — see the log below.`);
847
867
  }
848
868
  if (graphNote) parts.push(graphNote);
869
+ if (toolNote) parts.push(toolNote);
849
870
  if (staleNote) parts.push(staleNote);
850
871
  if (dreamLines.length) parts.push(`🗄 Anything changed or merged away is backed up under ${backupRoot}`);
851
872
  if (reportPath) parts.push(`📔 Full dream log: ${reportPath}`);
852
873
  const message = parts.length ? parts.join("\n\n") : "";
853
874
 
854
- return { applied: dreamLines, report, introReport, message, staleNote, graphNote, reportPath, trigger };
875
+ return { applied: dreamLines, report, introReport, message, staleNote, graphNote, toolNote, reportPath, trigger };
855
876
  } finally {
856
877
  _dreaming = false;
857
878
  }
package/core/handlers.js CHANGED
@@ -1217,7 +1217,8 @@ register({
1217
1217
  `First check the already-injected packs and 'open-claudia pack list' for a pack this work belongs to; if one fits, fold the reusable how-to into that pack's Procedure section (exact commands in order, prerequisites, pitfalls) and append a dated Journal line — edit the PACK.md directly. ` +
1218
1218
  `Only if no pack fits, create a new one: 'open-claudia pack' has no create subcommand, so write ~/.open-claudia/packs/<kebab-name>/PACK.md following the four-section format (Stance, Procedure, State, Journal) with name + a when-to-use description in the frontmatter. Give it an ACTIVITY-oriented name and description (what you DO — the verbs, tools, and artifacts) so it is findable from other projects by the work being done, not by a project name; and if this how-to came from a specific project, add a 'learned_on: <project-pack-dir>' frontmatter line so its cross-project reuse can be tracked. ` +
1219
1219
  `Then mark that pack as a reusable skill so it is always surfaced in future conversations: run 'open-claudia pack skill <dir> on'. This flags it as a reusable ability and adds it to the always-on skill index (name + description); the Procedure still loads on demand. ` +
1220
- `No secrets or tokens. When done, reply with one short line naming the pack you created or updated and what it covers. ` +
1220
+ `CRUCIAL also crystallise the EXECUTABLE part: if the work involved a script, API call, or any repeatable command sequence you wrote inline (a heredoc, a one-off .py/.sh, a curl chain), turn it into a reusable tool instead of leaving it to be re-typed. Write the script to a file (parameterise the bits that vary; read any credential from the env as $NAME rather than hardcoding), then register it with 'open-claudia tool add <path> --pack <dir> --desc "..." [--requires "key1,key2"] [--usage "..."]', linking it to the pack you just touched. Reference creds by their keyring names (see 'open-claudia keyring list'); never write a secret into the tool. The tool is the runnable form of the pack's Procedure — save both. ` +
1221
+ `No secrets or tokens in packs OR tools. When done, reply with one short line naming the pack you created or updated, any tool you saved, and what it covers. ` +
1221
1222
  `If the recent work is genuinely not reusable, say so instead of forcing a skill.`;
1222
1223
  await runClaude(prompt, currentState().currentSession.dir, env.messageId);
1223
1224
  },
package/core/io.js CHANGED
@@ -35,6 +35,16 @@ async function sendVoice(oggPath) {
35
35
  return adapter.sendVoice(channelId, oggPath);
36
36
  }
37
37
 
38
+ // Signal the end of a streamed multi-clip spoken reply. Only the voice channel
39
+ // implements it (so the client knows the reply is complete and can re-arm the
40
+ // mic); other adapters simply have no such method and this is a no-op.
41
+ async function sendVoiceEnd() {
42
+ const adapter = currentAdapter();
43
+ const channelId = currentChannelId();
44
+ if (!adapter || !channelId || typeof adapter.sendVoiceEnd !== "function") return;
45
+ return adapter.sendVoiceEnd(channelId);
46
+ }
47
+
38
48
  async function sendFile(filePath, caption) {
39
49
  const adapter = currentAdapter();
40
50
  const channelId = currentChannelId();
@@ -56,4 +66,4 @@ function splitMessage(text, maxLen = 4000) {
56
66
  return chunks;
57
67
  }
58
68
 
59
- module.exports = { send, editMessage, deleteMessage, sendVoice, sendFile, typing, splitMessage };
69
+ module.exports = { send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage };