@inetafrica/open-claudia 2.0.4 → 2.1.0

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/core/people.js ADDED
@@ -0,0 +1,247 @@
1
+ // People store: the roster of human team members the bot knows about.
2
+ // A person aggregates one or more "handles" (adapter + channelId pairs),
3
+ // optional notes, and a primary channel for outbound messages. Auth
4
+ // (can-this-chat-talk-to-me) stays in core/access.js; this layer adds
5
+ // identity, notes, and cross-channel grouping on top.
6
+ //
7
+ // Owner is just a person with isOwner=true. The owner is auto-seeded
8
+ // on first read from the existing auth.json (the bootstrap owner) and
9
+ // any KAZEE_OWNER_USER_ID in env.
10
+
11
+ const fs = require("fs");
12
+ const { PEOPLE_FILE, AUTH_FILE, config } = require("./config");
13
+ const { channelKey, setIdentityMapping, canonicalForChannel } = require("./identity");
14
+
15
+ function nowIso() { return new Date().toISOString(); }
16
+
17
+ function nextId() {
18
+ return `person_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
19
+ }
20
+
21
+ function load() {
22
+ try {
23
+ const raw = JSON.parse(fs.readFileSync(PEOPLE_FILE, "utf-8"));
24
+ if (!Array.isArray(raw)) return [];
25
+ return raw;
26
+ } catch (e) { return []; }
27
+ }
28
+
29
+ function save(list) {
30
+ const tmp = `${PEOPLE_FILE}.tmp`;
31
+ fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
32
+ fs.renameSync(tmp, PEOPLE_FILE);
33
+ }
34
+
35
+ function findById(id) {
36
+ return load().find((p) => p.id === id) || null;
37
+ }
38
+
39
+ function findByName(name) {
40
+ const needle = String(name || "").trim().toLowerCase();
41
+ if (!needle) return null;
42
+ return load().find((p) => (p.name || "").toLowerCase() === needle) || null;
43
+ }
44
+
45
+ function findByCanonicalUserId(canonicalUserId) {
46
+ const id = String(canonicalUserId || "").toLowerCase();
47
+ if (!id) return null;
48
+ return load().find((p) => (p.handles || []).some((h) => h.canonicalUserId === id)) || null;
49
+ }
50
+
51
+ function findByHandle(adapter, channelId) {
52
+ const a = String(adapter || "").toLowerCase();
53
+ const c = String(channelId || "");
54
+ return load().find((p) => (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c)) || null;
55
+ }
56
+
57
+ function list() { return load(); }
58
+
59
+ function add({ name, isOwner = false, bio = null }) {
60
+ const all = load();
61
+ const p = {
62
+ id: nextId(),
63
+ name: String(name || "").trim(),
64
+ isOwner: !!isOwner,
65
+ bio: bio ? String(bio) : null,
66
+ handles: [],
67
+ notes: [],
68
+ primaryChannel: null,
69
+ createdAt: nowIso(),
70
+ updatedAt: nowIso(),
71
+ };
72
+ if (!p.name) throw new Error("person name is required");
73
+ all.push(p);
74
+ save(all);
75
+ return p;
76
+ }
77
+
78
+ function update(id, patch) {
79
+ const all = load();
80
+ const idx = all.findIndex((p) => p.id === id);
81
+ if (idx < 0) return null;
82
+ all[idx] = { ...all[idx], ...patch, updatedAt: nowIso() };
83
+ save(all);
84
+ return all[idx];
85
+ }
86
+
87
+ function remove(id) {
88
+ const all = load();
89
+ const idx = all.findIndex((p) => p.id === id);
90
+ if (idx < 0) return null;
91
+ const [removed] = all.splice(idx, 1);
92
+ save(all);
93
+ return removed;
94
+ }
95
+
96
+ // Attach a channel handle to a person. Also writes the channel→canonical
97
+ // link in identities.json so the rest of the bot resolves the chat to
98
+ // the same canonical user id as any other handle on this person.
99
+ function linkHandle(personId, { adapter, channelId, displayName = null, approvedBy = null }) {
100
+ const all = load();
101
+ const p = all.find((x) => x.id === personId);
102
+ if (!p) throw new Error(`person not found: ${personId}`);
103
+ const a = String(adapter || "").toLowerCase();
104
+ const c = String(channelId || "");
105
+ if (!a || !c) throw new Error("adapter and channelId are required");
106
+
107
+ if (!Array.isArray(p.handles)) p.handles = [];
108
+ const conflict = all.find((other) =>
109
+ other.id !== p.id &&
110
+ (other.handles || []).some((h) => h.adapter === a && String(h.channelId) === c));
111
+ if (conflict) throw new Error(`handle ${a}:${c} already linked to ${conflict.name} (${conflict.id})`);
112
+
113
+ const canonical = canonicalForChannel(a, c);
114
+ const existing = p.handles.find((h) => h.adapter === a && String(h.channelId) === c);
115
+ if (!existing) {
116
+ p.handles.push({
117
+ adapter: a,
118
+ channelId: c,
119
+ canonicalUserId: canonical,
120
+ displayName,
121
+ approvedBy,
122
+ addedAt: nowIso(),
123
+ });
124
+ } else {
125
+ existing.canonicalUserId = canonical;
126
+ existing.displayName = displayName || existing.displayName;
127
+ }
128
+
129
+ if (!p.primaryChannel) p.primaryChannel = { adapter: a, channelId: c };
130
+ p.updatedAt = nowIso();
131
+ save(all);
132
+
133
+ // Mirror into identities.json so canonicalForChannel resolves cross-handle.
134
+ if (p.handles.length > 1) {
135
+ const primary = p.handles[0];
136
+ const primaryCanonical = primary.canonicalUserId || canonicalForChannel(primary.adapter, primary.channelId);
137
+ try { setIdentityMapping(a, c, primaryCanonical); } catch (e) {}
138
+ }
139
+
140
+ return p;
141
+ }
142
+
143
+ function unlinkHandle(personId, { adapter, channelId }) {
144
+ const all = load();
145
+ const p = all.find((x) => x.id === personId);
146
+ if (!p) return null;
147
+ const a = String(adapter || "").toLowerCase();
148
+ const c = String(channelId || "");
149
+ const before = (p.handles || []).length;
150
+ p.handles = (p.handles || []).filter((h) => !(h.adapter === a && String(h.channelId) === c));
151
+ if (p.primaryChannel && p.primaryChannel.adapter === a && String(p.primaryChannel.channelId) === c) {
152
+ p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
153
+ }
154
+ if (p.handles.length === before) return null;
155
+ p.updatedAt = nowIso();
156
+ save(all);
157
+ return p;
158
+ }
159
+
160
+ function setPrimary(personId, { adapter, channelId }) {
161
+ const all = load();
162
+ const p = all.find((x) => x.id === personId);
163
+ if (!p) return null;
164
+ const a = String(adapter || "").toLowerCase();
165
+ const c = String(channelId || "");
166
+ const has = (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c);
167
+ if (!has) throw new Error(`person ${p.id} has no handle ${a}:${c}`);
168
+ p.primaryChannel = { adapter: a, channelId: c };
169
+ p.updatedAt = nowIso();
170
+ save(all);
171
+ return p;
172
+ }
173
+
174
+ function note(personId, text, { by = null } = {}) {
175
+ const all = load();
176
+ const p = all.find((x) => x.id === personId);
177
+ if (!p) return null;
178
+ if (!Array.isArray(p.notes)) p.notes = [];
179
+ const entry = { at: nowIso(), by, text: String(text || "").trim() };
180
+ if (!entry.text) throw new Error("note text is required");
181
+ p.notes.push(entry);
182
+ p.updatedAt = nowIso();
183
+ save(all);
184
+ return entry;
185
+ }
186
+
187
+ function removeNote(personId, index) {
188
+ const all = load();
189
+ const p = all.find((x) => x.id === personId);
190
+ if (!p || !Array.isArray(p.notes)) return null;
191
+ if (index < 0 || index >= p.notes.length) return null;
192
+ const [removed] = p.notes.splice(index, 1);
193
+ p.updatedAt = nowIso();
194
+ save(all);
195
+ return removed;
196
+ }
197
+
198
+ function owners() { return load().filter((p) => p.isOwner); }
199
+
200
+ function hasOwnerRecord() { return owners().length > 0; }
201
+
202
+ // First-boot seeding: if people.json is empty but auth.json has an
203
+ // owner entry (the legacy bootstrap), promote that owner into a person
204
+ // record so the new system has a baseline before any intros happen.
205
+ function seedOwnerFromLegacy() {
206
+ if (hasOwnerRecord()) return null;
207
+ let legacyOwner = null;
208
+ try {
209
+ const auth = JSON.parse(fs.readFileSync(AUTH_FILE, "utf-8"));
210
+ legacyOwner = (auth.authorized || []).find((a) => a.isOwner === true) || null;
211
+ } catch (e) {}
212
+ if (!legacyOwner) return null;
213
+
214
+ const adapter = "telegram";
215
+ const channelId = String(legacyOwner.chatId);
216
+ const name = legacyOwner.name || legacyOwner.username || "Owner";
217
+
218
+ const person = add({ name, isOwner: true, bio: "Seeded from legacy auth.json" });
219
+ try { linkHandle(person.id, { adapter, channelId, displayName: legacyOwner.username || null, approvedBy: "bootstrap" }); } catch (e) {}
220
+
221
+ const kazeeOwnerUserId = (config && config.KAZEE_OWNER_USER_ID) || "";
222
+ if (kazeeOwnerUserId) {
223
+ try { linkHandle(person.id, { adapter: "kazee", channelId: kazeeOwnerUserId, approvedBy: "bootstrap" }); } catch (e) {}
224
+ }
225
+ return person;
226
+ }
227
+
228
+ function roster() {
229
+ return load().map((p) => ({
230
+ id: p.id,
231
+ name: p.name,
232
+ isOwner: !!p.isOwner,
233
+ bio: p.bio || null,
234
+ handles: (p.handles || []).map((h) => ({ adapter: h.adapter, channelId: h.channelId })),
235
+ primaryChannel: p.primaryChannel || null,
236
+ notes: (p.notes || []).slice(-5),
237
+ }));
238
+ }
239
+
240
+ module.exports = {
241
+ load, save, list, roster,
242
+ add, update, remove,
243
+ findById, findByName, findByCanonicalUserId, findByHandle,
244
+ linkHandle, unlinkHandle, setPrimary,
245
+ note, removeNote,
246
+ owners, hasOwnerRecord, seedOwnerFromLegacy,
247
+ };
package/core/relay.js ADDED
@@ -0,0 +1,61 @@
1
+ // Cross-channel relay: lets the agent (or any authorized caller) send
2
+ // a message to another person on any of their handles, without being
3
+ // inside that chat's async-local context. Resolves person → handle →
4
+ // adapter → send. Every send is audited.
5
+
6
+ const people = require("./people");
7
+ const registry = require("./adapter-registry");
8
+ const audit = require("./audit");
9
+
10
+ function resolveTarget({ personId, personName, canonicalUserId, adapter, channelId }) {
11
+ if (adapter && channelId) {
12
+ return { adapter: String(adapter).toLowerCase(), channelId: String(channelId), person: people.findByHandle(adapter, channelId) };
13
+ }
14
+ let person = null;
15
+ if (personId) person = people.findById(personId);
16
+ if (!person && personName) person = people.findByName(personName);
17
+ if (!person && canonicalUserId) person = people.findByCanonicalUserId(canonicalUserId);
18
+ if (!person) throw new Error("target person not found");
19
+ const handles = person.handles || [];
20
+ if (handles.length === 0) throw new Error(`person ${person.name} has no handles`);
21
+ const primary = person.primaryChannel
22
+ ? handles.find((h) => h.adapter === person.primaryChannel.adapter && String(h.channelId) === String(person.primaryChannel.channelId))
23
+ : null;
24
+ const handle = primary || handles[0];
25
+ return { adapter: handle.adapter, channelId: handle.channelId, person };
26
+ }
27
+
28
+ async function send({ text, target, from = null, kind = "relay" }) {
29
+ if (!text || !String(text).trim()) throw new Error("text is required");
30
+ const resolved = resolveTarget(target);
31
+ const adapter = registry.findAdapter(resolved.adapter) || registry.getAdapters().find((a) => a.type === resolved.adapter);
32
+ if (!adapter) throw new Error(`no live adapter for ${resolved.adapter}`);
33
+
34
+ let ok = false;
35
+ let messageId = null;
36
+ let errorMsg = null;
37
+ try {
38
+ const result = await adapter.send(resolved.channelId, String(text));
39
+ if (typeof result === "object" && result && "messageId" in result) { messageId = result.messageId; ok = !!messageId; }
40
+ else { messageId = result; ok = !!result; }
41
+ } catch (e) { errorMsg = e.message; }
42
+
43
+ audit.log(kind, {
44
+ from,
45
+ to: {
46
+ personId: resolved.person?.id || null,
47
+ personName: resolved.person?.name || null,
48
+ adapter: resolved.adapter,
49
+ channelId: resolved.channelId,
50
+ },
51
+ ok,
52
+ messageId,
53
+ textPreview: String(text).slice(0, 200),
54
+ error: errorMsg,
55
+ });
56
+
57
+ if (!ok) throw new Error(errorMsg || "send failed");
58
+ return { ok, messageId, to: { adapter: resolved.adapter, channelId: resolved.channelId, person: resolved.person } };
59
+ }
60
+
61
+ module.exports = { send, resolveTarget };
package/core/router.js CHANGED
@@ -8,6 +8,7 @@ const { runInChat } = require("./context");
8
8
  const { dispatch } = require("./commands");
9
9
  const { handleAction } = require("./actions");
10
10
  const { isChatAuthorized } = require("./access");
11
+ const introFlow = require("./intro-flow");
11
12
  const { send, deleteMessage } = require("./io");
12
13
  const { currentState } = require("./state");
13
14
  const { runClaude } = require("./runner");
@@ -78,7 +79,13 @@ async function onMessage(envelope) {
78
79
  return;
79
80
  }
80
81
 
81
- if (!isChatAuthorized(envelope.channelId)) return;
82
+ if (!isChatAuthorized(envelope.channelId)) {
83
+ if (envelope.type === "text") {
84
+ if (isDuplicate(envelope)) return;
85
+ await introFlow.handleInbound(envelope, (text) => send(text));
86
+ }
87
+ return;
88
+ }
82
89
 
83
90
  if (envelope.type === "voice") return handleVoice(envelope);
84
91
  if (envelope.type === "audio") return handleAudio(envelope);
@@ -6,9 +6,11 @@ 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");
13
+ const people = require("./people");
12
14
 
13
15
  function loadSoul() {
14
16
  try { return fs.readFileSync(SOUL_FILE, "utf-8"); }
@@ -20,10 +22,47 @@ function buildSystemPrompt() {
20
22
  const soul = loadSoul();
21
23
  const hasVoice = WHISPER_CLI && FFMPEG;
22
24
  const adapter = currentAdapter();
25
+ const channelId = currentChannelId();
23
26
  const channelLabel = adapter
24
27
  ? (adapter.type === "kazee" ? "Kazee Chat" : adapter.type === "telegram" ? "Telegram" : adapter.type)
25
28
  : "Telegram";
26
29
 
30
+ let pendingTasksBlock = "";
31
+ if (adapter && channelId) {
32
+ try {
33
+ const pending = tasksStore.pendingSummary(adapter.id, channelId);
34
+ if (pending.length > 0) {
35
+ const tree = tasksStore.formatTree(adapter.id, channelId, { showIds: true });
36
+ 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`;
37
+ }
38
+ } catch (e) {}
39
+ }
40
+
41
+ let teamBlock = "";
42
+ let currentSpeakerBlock = "";
43
+ try {
44
+ const roster = people.roster();
45
+ if (roster.length > 0) {
46
+ const lines = roster.map((p) => {
47
+ const handleSummary = (p.handles || []).map((h) => `${h.adapter}:${h.channelId}`).join(", ") || "(no handles)";
48
+ const primary = p.primaryChannel ? ` primary=${p.primaryChannel.adapter}:${p.primaryChannel.channelId}` : "";
49
+ const noteSummary = (p.notes || []).length > 0
50
+ ? "\n Notes: " + p.notes.slice(-3).map((n) => `[${n.at?.slice(0, 10) || ""}] ${n.text}`).join(" | ")
51
+ : "";
52
+ return `- ${p.name}${p.isOwner ? " (owner)" : ""} — ${handleSummary}${primary}${p.bio ? "\n Bio: " + p.bio : ""}${noteSummary}`;
53
+ });
54
+ teamBlock = `\n## Team\nThe humans you know about. You can reach any of them via the relay tool below. Use names from this list when the user refers to teammates.\n\n${lines.join("\n")}\n`;
55
+ }
56
+
57
+ if (adapter && channelId) {
58
+ const speaker = people.findByHandle(adapter.type, channelId);
59
+ if (speaker) {
60
+ const recentNotes = (speaker.notes || []).slice(-3).map((n) => `- [${n.at?.slice(0, 10) || ""}] ${n.text}`).join("\n");
61
+ currentSpeakerBlock = `\n## Speaker\nYou are talking to ${speaker.name}${speaker.isOwner ? " (the owner)" : ""} on ${adapter.type}.${speaker.bio ? "\nBio: " + speaker.bio : ""}${recentNotes ? "\nRecent notes:\n" + recentNotes : ""}\n`;
62
+ }
63
+ }
64
+ } catch (e) {}
65
+
27
66
  return `
28
67
  ${soul}
29
68
 
@@ -43,7 +82,7 @@ ${soul}
43
82
  - Received user files directory: ${FILES_DIR}
44
83
 
45
84
  ${transcriptPointerNote(state)}
46
-
85
+ ${currentSpeakerBlock}${teamBlock}${pendingTasksBlock}
47
86
  ## Delivery
48
87
  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
88
  - \`open-claudia send-file <path> [caption]\` — any document/binary
@@ -60,10 +99,34 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
60
99
  - \`open-claudia cron-list\` — list all wakeups + crons on this channel.
61
100
  - \`open-claudia cron-remove <id>\` — cancel one.
62
101
 
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>\`
102
+ Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
103
+
104
+ Use a plan when work is likely to outlive a single turn — i.e. it may hit a compaction, a restart, or a scheduled wakeup before completing, or its progress is something the user will want to see between turns. 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 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.
105
+
106
+ Skip the plan for work that visibly completes within the current turn (e.g. running a few CLI commands in sequence and replying). A plan there is overhead the user has to read past.
107
+
108
+ - \`open-claudia task plan "<plan title>" "<step 1>" "<step 2>" "<step 3>" [--description "<why / success criteria>"]\` — atomic create
109
+ - \`open-claudia task add "<content>" [--parent <plan-id>] [--description "..."]\` — add a single task or subtask
110
+ - \`open-claudia task list\` — renders as a tree
111
+ - \`open-claudia task start <id>\` / \`task done <id>\` / \`task remove <id>\` (removing a plan removes its subtasks)
112
+ - \`open-claudia task clear-completed\` — only clears plans whose subtasks are all done
113
+
114
+ For one-off work under 3 steps, skip the plan and just track it in your own response.
115
+
116
+ Cross-channel relay (talk to other team members on their primary channel — see Team block above for names):
117
+ - \`open-claudia send-to --person "<name>" "<message>"\` — sends to that person's primary channel.
118
+ - \`open-claudia send-to --adapter <type> --channel <id> "<message>"\` — explicit channel target.
119
+ - Be honest about provenance. If you are forwarding on someone's behalf, prefix the message: "From <speaker name>: ...". If you are reaching out autonomously (cron, wakeup, your own judgement), prefix with "Open Claudia: ...".
120
+ - Use sparingly — every relay is logged to the audit trail. Don't message people unprompted unless the user asked you to or the context obviously calls for it.
121
+
122
+ Recent activity lookup (read other chats' transcripts on demand — don't try to carry everything in context):
123
+ - \`open-claudia recent --person "<name>" [--limit 20]\`
124
+ - \`open-claudia recent --adapter <type> --channel <id> [--limit 20]\`
125
+
126
+ People management (owner-only commands; safe to call to inspect):
127
+ - \`open-claudia people list\` / \`people show <id-or-name>\` — read-only, anyone can call.
128
+ - \`open-claudia people note <id-or-name> "<note>"\` — record something the team should remember.
129
+ - \`open-claudia intros list\` / \`intros approve <id>\` / \`intros reject <id>\` — owner-gated.
67
130
 
68
131
  Sub-agents (spawn a fresh throwaway Claude for focused research — output comes back on stdout):
69
132
  - \`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.1.0",
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": {