@inetafrica/open-claudia 2.0.5 → 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);
@@ -10,6 +10,7 @@ const { currentAdapter, currentChannelId } = require("./context");
10
10
  const { vault } = require("./vault-store");
11
11
  const { transcriptPointerNote } = require("./transcripts");
12
12
  const tasksStore = require("./tasks");
13
+ const people = require("./people");
13
14
 
14
15
  function loadSoul() {
15
16
  try { return fs.readFileSync(SOUL_FILE, "utf-8"); }
@@ -37,6 +38,31 @@ function buildSystemPrompt() {
37
38
  } catch (e) {}
38
39
  }
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
+
40
66
  return `
41
67
  ${soul}
42
68
 
@@ -56,7 +82,7 @@ ${soul}
56
82
  - Received user files directory: ${FILES_DIR}
57
83
 
58
84
  ${transcriptPointerNote(state)}
59
- ${pendingTasksBlock}
85
+ ${currentSpeakerBlock}${teamBlock}${pendingTasksBlock}
60
86
  ## Delivery
61
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:
62
88
  - \`open-claudia send-file <path> [caption]\` — any document/binary
@@ -75,7 +101,9 @@ Wake-ups and crons (real schedulers, not hallucinations — use them instead of
75
101
 
76
102
  Persistent todo list with plans + subtasks (per channel; survives compaction and restart).
77
103
 
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.
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.
79
107
 
80
108
  - \`open-claudia task plan "<plan title>" "<step 1>" "<step 2>" "<step 3>" [--description "<why / success criteria>"]\` — atomic create
81
109
  - \`open-claudia task add "<content>" [--parent <plan-id>] [--description "..."]\` — add a single task or subtask
@@ -85,6 +113,21 @@ For any work with 3+ distinct steps you MUST create a plan before starting. The
85
113
 
86
114
  For one-off work under 3 steps, skip the plan and just track it in your own response.
87
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.
130
+
88
131
  Sub-agents (spawn a fresh throwaway Claude for focused research — output comes back on stdout):
89
132
  - \`open-claudia agent "<prompt>" [--role "<role>"]\`
90
133
  - Use when a side question would pollute this conversation, or to fan out independent lookups.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.0.5",
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": {