@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 +3 -0
- package/bin/cli.js +6 -0
- package/bin/pack.js +10 -0
- package/bin/tool.js +148 -0
- package/channels/voice/adapter.js +21 -4
- package/channels/voice/manage.js +285 -0
- package/core/dream.js +23 -2
- package/core/handlers.js +2 -1
- package/core/io.js +11 -1
- package/core/media.js +53 -1
- package/core/runner.js +43 -4
- package/core/system-prompt.js +49 -1
- package/core/tool-graph.js +217 -0
- package/core/tools.js +227 -0
- package/package.json +5 -3
- package/test-tool-graph.js +97 -0
- package/test-tools.js +92 -0
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 `&` 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,
|
|
343
|
+
async sendVoice(channelId, audioPath) {
|
|
334
344
|
try {
|
|
335
|
-
const
|
|
336
|
-
|
|
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
|
-
`
|
|
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 };
|