@inetafrica/open-claudia 2.0.4 → 2.0.5

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/bin/task.js CHANGED
@@ -1,42 +1,101 @@
1
1
  // open-claudia task <subcommand>
2
2
  // Per-channel todo list. Use this for multi-step work so progress
3
- // survives compactions and restarts.
3
+ // survives compactions and restarts. Supports one level of hierarchy:
4
+ // a parent "plan" task can have child subtasks via --parent.
4
5
 
5
6
  const { postJson } = require("./loopback-client");
6
7
 
7
8
  const HELP = `
8
- Per-channel todo list.
9
+ Per-channel todo list with optional plan/subtask hierarchy.
9
10
 
10
- open-claudia task add "<content>"
11
+ open-claudia task add "<content>" [--parent <id>] [--description "<...>"]
12
+ open-claudia task plan "<title>" "<sub1>" "<sub2>" ... [--description "<...>"]
11
13
  open-claudia task list [--status pending|in_progress|completed]
12
14
  open-claudia task start <id>
13
15
  open-claudia task done <id>
14
- open-claudia task remove <id>
15
- open-claudia task clear-completed
16
+ open-claudia task remove <id> # removing a plan removes its subtasks
17
+ open-claudia task clear-completed # only clears plans whose subtasks are all done
18
+
19
+ Use plans for any work with 3+ distinct steps. Mark subtasks in_progress
20
+ as you start them and done as you finish, so a resumed turn or a new
21
+ turn after compaction can pick up where you left off.
16
22
  `;
17
23
 
18
- function fmt(t, idx) {
19
- const mark = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[~]" : "[ ]";
20
- return `${idx + 1}. ${mark} ${t.content} (${t.id})`;
24
+ function statusMark(t) {
25
+ return t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[~]" : "[ ]";
26
+ }
27
+
28
+ function takeFlag(args, name) {
29
+ const idx = args.indexOf(`--${name}`);
30
+ if (idx < 0) return null;
31
+ const v = args[idx + 1];
32
+ args.splice(idx, 2);
33
+ return v;
34
+ }
35
+
36
+ function renderTree(list) {
37
+ const byParent = new Map();
38
+ const roots = [];
39
+ for (const t of list) {
40
+ if (t.parentId) {
41
+ if (!byParent.has(t.parentId)) byParent.set(t.parentId, []);
42
+ byParent.get(t.parentId).push(t);
43
+ } else {
44
+ roots.push(t);
45
+ }
46
+ }
47
+ const lines = [];
48
+ roots.forEach((root, i) => {
49
+ lines.push(`${i + 1}. ${statusMark(root)} ${root.content} (${root.id})`);
50
+ if (root.description) lines.push(` ${root.description}`);
51
+ (byParent.get(root.id) || []).forEach((c) => {
52
+ lines.push(` ${statusMark(c)} ${c.content} (${c.id})`);
53
+ });
54
+ });
55
+ for (const orphan of list) {
56
+ if (orphan.parentId && !roots.find((r) => r.id === orphan.parentId)) {
57
+ lines.push(` ${statusMark(orphan)} ${orphan.content} (${orphan.id}) [orphan]`);
58
+ }
59
+ }
60
+ return lines.join("\n");
21
61
  }
22
62
 
23
63
  async function runAdd(args) {
64
+ const parentId = takeFlag(args, "parent");
65
+ const description = takeFlag(args, "description");
24
66
  const content = args.join(" ").trim();
25
- if (!content) { console.error("Usage: task add \"<content>\""); process.exit(2); }
67
+ if (!content) { console.error("Usage: task add \"<content>\" [--parent <id>] [--description \"<...>\"]"); process.exit(2); }
26
68
  try {
27
- const res = await postJson("task-add", { content });
69
+ const res = await postJson("task-add", { content, parentId, description });
28
70
  console.log(`Added task ${res.task.id}.`);
29
71
  process.exit(0);
30
72
  } catch (e) { console.error(e.message); process.exit(1); }
31
73
  }
32
74
 
75
+ async function runPlan(args) {
76
+ const description = takeFlag(args, "description");
77
+ if (args.length < 2) {
78
+ console.error("Usage: task plan \"<title>\" \"<sub1>\" \"<sub2>\" ... [--description \"<...>\"]");
79
+ process.exit(2);
80
+ }
81
+ const [title, ...subtasks] = args;
82
+ try {
83
+ const res = await postJson("task-plan", { title, subtasks, description });
84
+ const parent = res.plan.parent;
85
+ const kids = res.plan.children || [];
86
+ console.log(`Plan ${parent.id}: ${parent.content}`);
87
+ kids.forEach((c) => console.log(` - ${c.content} (${c.id})`));
88
+ process.exit(0);
89
+ } catch (e) { console.error(e.message); process.exit(1); }
90
+ }
91
+
33
92
  async function runList(args) {
34
- const status = args[args.indexOf("--status") + 1] && args.includes("--status") ? args[args.indexOf("--status") + 1] : null;
93
+ const status = args.includes("--status") ? args[args.indexOf("--status") + 1] : null;
35
94
  try {
36
95
  const res = await postJson("task-list", status ? { status } : {});
37
96
  const list = res.tasks || [];
38
97
  if (list.length === 0) { console.log("No tasks."); process.exit(0); }
39
- list.forEach((t, i) => console.log(fmt(t, i)));
98
+ console.log(renderTree(list));
40
99
  process.exit(0);
41
100
  } catch (e) { console.error(e.message); process.exit(1); }
42
101
  }
@@ -58,7 +117,8 @@ async function runRemove(args) {
58
117
  try {
59
118
  const res = await postJson("task-remove", { id });
60
119
  if (!res.ok) { console.error(`Not found: ${id}`); process.exit(1); }
61
- console.log(`Removed ${id}.`);
120
+ const extra = (res.removed && res.removed.alsoRemoved) ? ` (and ${res.removed.alsoRemoved} subtask${res.removed.alsoRemoved === 1 ? "" : "s"})` : "";
121
+ console.log(`Removed ${id}${extra}.`);
62
122
  process.exit(0);
63
123
  } catch (e) { console.error(e.message); process.exit(1); }
64
124
  }
@@ -77,6 +137,7 @@ async function run(args) {
77
137
  if (!sub || sub === "--help" || sub === "help") { console.log(HELP); process.exit(sub ? 0 : 2); }
78
138
  switch (sub) {
79
139
  case "add": return runAdd(rest);
140
+ case "plan": return runPlan(rest);
80
141
  case "list": case "ls": return runList(rest);
81
142
  case "start": return runUpdate(rest, "in_progress");
82
143
  case "done": case "complete": return runUpdate(rest, "completed");
package/core/config.js CHANGED
@@ -78,7 +78,7 @@ if (!CLAUDE_PATH) { console.error("CLAUDE_PATH not set"); process.exit(1); }
78
78
  if (!fs.existsSync(WORKSPACE)) {
79
79
  try {
80
80
  fs.mkdirSync(WORKSPACE, { recursive: true });
81
- console.log(`Created workspace: ${WORKSPACE}`);
81
+ console.error(`Created workspace: ${WORKSPACE}`);
82
82
  } catch (e) {
83
83
  console.error(`Failed to create workspace: ${e.message}`);
84
84
  process.exit(1);
@@ -99,14 +99,14 @@ if (!resolvedCursorPath) {
99
99
  try { resolvedCursorPath = execSync("which agent 2>/dev/null", { encoding: "utf-8" }).trim() || null; }
100
100
  catch (e) { resolvedCursorPath = null; }
101
101
  }
102
- if (resolvedCursorPath) console.log(`Cursor Agent CLI: ${resolvedCursorPath}`);
102
+ if (resolvedCursorPath) console.error(`Cursor Agent CLI: ${resolvedCursorPath}`);
103
103
 
104
104
  let resolvedCodexPath = CODEX_PATH;
105
105
  if (!resolvedCodexPath) {
106
106
  try { resolvedCodexPath = execSync("which codex 2>/dev/null", { encoding: "utf-8" }).trim() || null; }
107
107
  catch (e) { resolvedCodexPath = null; }
108
108
  }
109
- if (resolvedCodexPath) console.log(`Codex CLI: ${resolvedCodexPath}`);
109
+ if (resolvedCodexPath) console.error(`Codex CLI: ${resolvedCodexPath}`);
110
110
 
111
111
  if (!fs.existsSync(TEMP_DIR)) fs.mkdirSync(TEMP_DIR, { recursive: true });
112
112
  if (!fs.existsSync(FILES_DIR)) fs.mkdirSync(FILES_DIR, { recursive: true });
package/core/loopback.js CHANGED
@@ -64,7 +64,7 @@ function readBodyToFile(req, dest) {
64
64
  const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
65
65
  const JSON_KINDS = new Set([
66
66
  "schedule-wakeup", "cron-add", "cron-remove", "job-list",
67
- "task-add", "task-update", "task-remove", "task-list", "task-clear-completed",
67
+ "task-add", "task-plan", "task-update", "task-remove", "task-list", "task-tree", "task-clear-completed",
68
68
  ]);
69
69
 
70
70
  function readBodyAsString(req, max = 64 * 1024) {
@@ -177,8 +177,28 @@ async function handleJson(req, res, url, kind) {
177
177
 
178
178
  if (kind === "task-add") {
179
179
  if (!payload.content) return reply(res, 400, { error: "missing content" });
180
- const t = tasksStore.add(adapterId, channelId, payload.content);
181
- return reply(res, 200, { ok: true, task: t });
180
+ try {
181
+ const t = tasksStore.add(adapterId, channelId, payload.content, {
182
+ parentId: payload.parentId || null,
183
+ description: payload.description || null,
184
+ });
185
+ return reply(res, 200, { ok: true, task: t });
186
+ } catch (e) { return reply(res, 400, { error: e.message }); }
187
+ }
188
+
189
+ if (kind === "task-plan") {
190
+ if (!payload.title) return reply(res, 400, { error: "missing title" });
191
+ const subs = Array.isArray(payload.subtasks) ? payload.subtasks : [];
192
+ if (subs.length === 0) return reply(res, 400, { error: "subtasks must be a non-empty array" });
193
+ const result = tasksStore.plan(adapterId, channelId, payload.title, subs, {
194
+ description: payload.description || null,
195
+ });
196
+ return reply(res, 200, { ok: true, plan: result });
197
+ }
198
+
199
+ if (kind === "task-tree") {
200
+ const t = tasksStore.tree(adapterId, channelId);
201
+ return reply(res, 200, { ok: true, tree: t });
182
202
  }
183
203
 
184
204
  if (kind === "task-update") {
@@ -6,9 +6,10 @@ const fs = require("fs");
6
6
  const path = require("path");
7
7
  const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, WHISPER_CLI, FFMPEG } = require("./config");
8
8
  const { currentState } = require("./state");
9
- const { currentAdapter } = require("./context");
9
+ const { currentAdapter, currentChannelId } = require("./context");
10
10
  const { vault } = require("./vault-store");
11
11
  const { transcriptPointerNote } = require("./transcripts");
12
+ const tasksStore = require("./tasks");
12
13
 
13
14
  function loadSoul() {
14
15
  try { return fs.readFileSync(SOUL_FILE, "utf-8"); }
@@ -20,10 +21,22 @@ function buildSystemPrompt() {
20
21
  const soul = loadSoul();
21
22
  const hasVoice = WHISPER_CLI && FFMPEG;
22
23
  const adapter = currentAdapter();
24
+ const channelId = currentChannelId();
23
25
  const channelLabel = adapter
24
26
  ? (adapter.type === "kazee" ? "Kazee Chat" : adapter.type === "telegram" ? "Telegram" : adapter.type)
25
27
  : "Telegram";
26
28
 
29
+ let pendingTasksBlock = "";
30
+ if (adapter && channelId) {
31
+ try {
32
+ const pending = tasksStore.pendingSummary(adapter.id, channelId);
33
+ if (pending.length > 0) {
34
+ const tree = tasksStore.formatTree(adapter.id, channelId, { showIds: true });
35
+ pendingTasksBlock = `\n## Pending tasks\nYou may be resuming prior work. Before starting anything new, review these and decide whether to continue, update, or abandon them. Mark a subtask in_progress when you actually start it and completed when it's done.\n\n${tree}\n`;
36
+ }
37
+ } catch (e) {}
38
+ }
39
+
27
40
  return `
28
41
  ${soul}
29
42
 
@@ -43,7 +56,7 @@ ${soul}
43
56
  - Received user files directory: ${FILES_DIR}
44
57
 
45
58
  ${transcriptPointerNote(state)}
46
-
59
+ ${pendingTasksBlock}
47
60
  ## Delivery
48
61
  Reply normally in your final answer. To send a file, image, or voice clip back to the current chat, run the bot CLI from inside this task — channel context is already in the env:
49
62
  - \`open-claudia send-file <path> [caption]\` — any document/binary
@@ -60,10 +73,17 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
60
73
  - \`open-claudia cron-list\` — list all wakeups + crons on this channel.
61
74
  - \`open-claudia cron-remove <id>\` — cancel one.
62
75
 
63
- Persistent todo list (per channel; use for multi-step work so progress survives compaction):
64
- - \`open-claudia task add "<content>"\`
65
- - \`open-claudia task list\`
66
- - \`open-claudia task start <id>\` / \`task done <id>\` / \`task remove <id>\`
76
+ Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
77
+
78
+ For any work with 3+ distinct steps you MUST create a plan before starting. The plan is a parent task whose children are the steps. As you work, mark each subtask in_progress when you begin and completed when done — this is how a resumed turn (after compaction, restart, or wakeup) sees where you left off. If pending tasks already exist when you start a turn they will be shown under "## Pending tasks" above; check them first.
79
+
80
+ - \`open-claudia task plan "<plan title>" "<step 1>" "<step 2>" "<step 3>" [--description "<why / success criteria>"]\` — atomic create
81
+ - \`open-claudia task add "<content>" [--parent <plan-id>] [--description "..."]\` — add a single task or subtask
82
+ - \`open-claudia task list\` — renders as a tree
83
+ - \`open-claudia task start <id>\` / \`task done <id>\` / \`task remove <id>\` (removing a plan removes its subtasks)
84
+ - \`open-claudia task clear-completed\` — only clears plans whose subtasks are all done
85
+
86
+ For one-off work under 3 steps, skip the plan and just track it in your own response.
67
87
 
68
88
  Sub-agents (spawn a fresh throwaway Claude for focused research — output comes back on stdout):
69
89
  - \`open-claudia agent "<prompt>" [--role "<role>"]\`
package/core/tasks.js CHANGED
@@ -2,6 +2,10 @@
2
2
  // multi-step work that should survive a turn, a compaction, or a
3
3
  // restart. Scoped by adapter+channel so Telegram and Kazee see
4
4
  // separate lists.
5
+ //
6
+ // Tasks support a single level of hierarchy via parentId: a top-level
7
+ // "plan" task can have child subtasks. This lets the agent break a big
8
+ // piece of work into a plan whose progress is visible at a glance.
5
9
 
6
10
  const fs = require("fs");
7
11
  const path = require("path");
@@ -34,12 +38,20 @@ function nextId() {
34
38
  return `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
35
39
  }
36
40
 
37
- function add(adapter, channelId, content) {
41
+ function add(adapter, channelId, content, opts = {}) {
38
42
  const list = load(adapter, channelId);
43
+ const parentId = opts.parentId || null;
44
+ if (parentId) {
45
+ const parent = list.find((t) => t.id === parentId);
46
+ if (!parent) throw new Error(`parent task not found: ${parentId}`);
47
+ if (parent.parentId) throw new Error(`nested subtasks not supported: ${parentId} is already a subtask`);
48
+ }
39
49
  const t = {
40
50
  id: nextId(),
41
51
  content: String(content || "").trim(),
42
52
  status: "pending",
53
+ parentId,
54
+ description: opts.description ? String(opts.description) : null,
43
55
  createdAt: Date.now(),
44
56
  updatedAt: Date.now(),
45
57
  };
@@ -48,6 +60,16 @@ function add(adapter, channelId, content) {
48
60
  return t;
49
61
  }
50
62
 
63
+ function plan(adapter, channelId, title, subtaskContents, opts = {}) {
64
+ const parent = add(adapter, channelId, title, { description: opts.description || null });
65
+ const children = [];
66
+ for (const c of subtaskContents || []) {
67
+ if (!String(c || "").trim()) continue;
68
+ children.push(add(adapter, channelId, c, { parentId: parent.id }));
69
+ }
70
+ return { parent, children };
71
+ }
72
+
51
73
  function update(adapter, channelId, id, patch) {
52
74
  const list = load(adapter, channelId);
53
75
  const idx = list.findIndex((t) => t.id === id);
@@ -59,17 +81,32 @@ function update(adapter, channelId, id, patch) {
59
81
 
60
82
  function remove(adapter, channelId, id) {
61
83
  const list = load(adapter, channelId);
62
- const idx = list.findIndex((t) => t.id === id);
63
- if (idx < 0) return null;
64
- const [removed] = list.splice(idx, 1);
65
- save(adapter, channelId, list);
66
- return removed;
84
+ const target = list.find((t) => t.id === id);
85
+ if (!target) return null;
86
+ const ids = new Set([id]);
87
+ if (!target.parentId) {
88
+ for (const t of list) if (t.parentId === id) ids.add(t.id);
89
+ }
90
+ const next = list.filter((t) => !ids.has(t.id));
91
+ save(adapter, channelId, next);
92
+ return { removed: target, alsoRemoved: ids.size - 1 };
67
93
  }
68
94
 
69
95
  function clearCompleted(adapter, channelId) {
70
- const list = load(adapter, channelId).filter((t) => t.status !== "completed");
71
- save(adapter, channelId, list);
72
- return list;
96
+ const all = load(adapter, channelId);
97
+ const drop = new Set();
98
+ for (const t of all) {
99
+ if (t.status !== "completed") continue;
100
+ if (t.parentId) { drop.add(t.id); continue; }
101
+ const kids = all.filter((c) => c.parentId === t.id);
102
+ if (kids.length === 0 || kids.every((c) => c.status === "completed")) {
103
+ drop.add(t.id);
104
+ for (const c of kids) drop.add(c.id);
105
+ }
106
+ }
107
+ const remaining = all.filter((t) => !drop.has(t.id));
108
+ save(adapter, channelId, remaining);
109
+ return remaining;
73
110
  }
74
111
 
75
112
  function list(adapter, channelId, opts = {}) {
@@ -78,15 +115,54 @@ function list(adapter, channelId, opts = {}) {
78
115
  return all;
79
116
  }
80
117
 
118
+ function tree(adapter, channelId) {
119
+ const all = load(adapter, channelId);
120
+ const byParent = new Map();
121
+ const roots = [];
122
+ for (const t of all) {
123
+ if (t.parentId) {
124
+ if (!byParent.has(t.parentId)) byParent.set(t.parentId, []);
125
+ byParent.get(t.parentId).push(t);
126
+ } else {
127
+ roots.push(t);
128
+ }
129
+ }
130
+ return roots.map((r) => ({ ...r, children: byParent.get(r.id) || [] }));
131
+ }
132
+
133
+ function pendingSummary(adapter, channelId) {
134
+ const all = load(adapter, channelId);
135
+ return all.filter((t) => t.status === "pending" || t.status === "in_progress");
136
+ }
137
+
138
+ function statusMark(t) {
139
+ return t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[~]" : "[ ]";
140
+ }
141
+
81
142
  function format(t, idx) {
82
- const mark = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[~]" : "[ ]";
83
143
  const num = typeof idx === "number" ? `${idx + 1}. ` : "";
84
- return `${num}${mark} ${t.content}`;
144
+ return `${num}${statusMark(t)} ${t.content}`;
145
+ }
146
+
147
+ function formatTree(adapter, channelId, opts = {}) {
148
+ const showIds = opts.showIds !== false;
149
+ const t = tree(adapter, channelId);
150
+ const lines = [];
151
+ t.forEach((root, i) => {
152
+ const head = `${i + 1}. ${statusMark(root)} ${root.content}` + (showIds ? ` (${root.id})` : "");
153
+ lines.push(head);
154
+ if (root.description) lines.push(` ${root.description}`);
155
+ root.children.forEach((child) => {
156
+ lines.push(` ${statusMark(child)} ${child.content}` + (showIds ? ` (${child.id})` : ""));
157
+ });
158
+ });
159
+ return lines.join("\n");
85
160
  }
86
161
 
87
162
  module.exports = {
88
163
  filePathFor,
89
164
  load, save,
90
- add, update, remove, list, clearCompleted,
91
- format,
165
+ add, plan, update, remove, list, tree, clearCompleted,
166
+ pendingSummary,
167
+ format, formatTree, statusMark,
92
168
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
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": {