@inetafrica/open-claudia 2.14.6 → 2.14.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.14.7
4
+ - **New `/link remove <key>` — prune a single identity link through the code path, not by hand-editing `identities.json`.** Until now the only identity commands were `/links` (list) and `/link` (create); there was no way to drop a specific channel→canonical mapping. `/channel remove <transport>` clears a whole transport's links, but only when that adapter is currently live (`removeAdapter` bails with *"No adapter …"* otherwise), and it never touches `telegram` entries by design — so a stray or stale link (e.g. a leftover test fixture, or a link whose transport is no longer added) had no clean removal path, and hand-editing the file doesn't stick because `identity.js` loads the map once into a shared in-memory object and re-serializes the whole thing on every write (a disk-only edit is resurrected by the cache). New `identity.removeIdentityMapping(key)` removes one channel key from the in-memory map **then** persists (memory + disk consistent), and prunes the canonical's `preferred`-channel pointer only when that was its last remaining channel. Owner-only; refuses to remove your own configured owner channel (`identity.isConfiguredOwnerChannel` — a live Telegram chat id or Kazee owner id) so you can't accidentally drop yourself out of unified identity — re-point with `/link` instead. `/links` now shows a *"Remove one with /link remove <key>"* hint.
5
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. Purely an owner-driven identity-maintenance command — inert for any deployment that never runs it. New hermetic test `test-identity-prune.js` (last-link pruning + memory/disk consistency + unknown-key no-op) wired into `npm test`; full suite green.
6
+
3
7
  ## v2.14.6
4
8
  - **`/channel add kazee` started from the inline "Add Kazee" button now completes — owner-capture confirm lands on the operator's channel, not on Kazee.** The wizard's owner-confirm prompt (*"Is this you — the owner?"*) is meant to go back to the channel the operator ran `/channel` from, via `channel-wizard.sayToOrigin`, which needs `session.origin` — captured from the envelope passed to `start()`. The typed `/channel add kazee` path (`handlers.js`) passed the envelope; the button path (`actions.js` `chn:add:kazee`) did **not**, so `origin` was null and the prompt fell back to ambient `io.send` scope — which, while processing the operator's inbound Kazee trigger message, is the Kazee room. The Yes/No/Skip buttons then rendered on Kazee, where a tap can never resolve the session: it's keyed by the operator's canonical id, but a Kazee tap resolves to the owner's *unlinked* Kazee user id (the very thing owner-capture is establishing) → `handleAction` finds no session → *"That setup step expired."* `actions.js` now passes the envelope to `wizard.start`, exactly like the typed path.
5
9
  - **`/channel remove` and the inline "Remove <id>" button now share one teardown, so they can't drift.** v2.14.5 made the typed `/channel remove` a complete teardown (adapter + `.env` + `people.unlinkAdapterHandles` + `identity.removeTransportIdentities`), but the button (`actions.js` `chn:rm:`) still ran the pre-v2.14.5 version that cleared only `.env` — so removing via the button left exactly the handle/identity residue v2.14.5 was built to clear. The full teardown is now a single `channel-wizard.removeChannel(id)` called by both entry points; callers only guard `telegram` and format `r.summary` / `r.error`. Net −10 lines.
package/core/handlers.js CHANGED
@@ -14,7 +14,7 @@ const {
14
14
  const { register } = require("./commands");
15
15
  const { send, deleteMessage } = require("./io");
16
16
  const { currentState, resetSettings, resetSessionUsage, saveState, getProjectSessions, getLastProjectSession, recordSession, linkIdentity } = require("./state");
17
- const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities } = require("./identity");
17
+ const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
18
18
  const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
19
19
  const { isOnboarded, startOnboarding } = require("./onboarding");
20
20
  const { listProjects, findProject, projectKeyboard, workspacePath } = require("./projects");
@@ -247,20 +247,35 @@ register({
247
247
  const rows = Object.entries(identities.channels)
248
248
  .sort(([a], [b]) => a.localeCompare(b))
249
249
  .map(([channel, userId]) => `${channel} -> ${userId}`);
250
- send(rows.length ? rows.join("\n") : "No explicit identity links. Unlinked chats use <transport>:<channelId>.");
250
+ send(rows.length
251
+ ? `${rows.join("\n")}\n\nRemove one with /link remove <key>.`
252
+ : "No explicit identity links. Unlinked chats use <transport>:<channelId>.");
251
253
  },
252
254
  });
253
255
 
254
256
  register({
255
- name: "link", description: "Link this chat to a canonical user id", args: "[<chat-id>] <email-or-id>",
257
+ name: "link", description: "Link this chat to a canonical user id", args: "[<chat-id>] <email-or-id> | remove <key>",
256
258
  handler: async (env, { tail }) => {
257
259
  if (!authorized(env)) return;
258
260
  if (!tail) {
259
261
  send(ownerEnv(env)
260
- ? "Usage:\n/link <email-or-user-id>\n/link <chat-id> <email-or-user-id>\n/link <transport>:<chat-id> <email-or-user-id>\n\nOwner can link any chat; other users can link only their current chat."
262
+ ? "Usage:\n/link <email-or-user-id>\n/link <chat-id> <email-or-user-id>\n/link <transport>:<chat-id> <email-or-user-id>\n/link remove <transport>:<chat-id>\n\nOwner can link any chat; other users can link only their current chat."
261
263
  : "Usage:\n/link <email-or-user-id>\n\nThis links your chat to a canonical user id.");
262
264
  return;
263
265
  }
266
+ const removeMatch = tail.match(/^remove\s+(\S+)$/i);
267
+ if (removeMatch) {
268
+ if (!ownerEnv(env)) return send("Only the owner can remove identity links.");
269
+ const key = removeMatch[1];
270
+ const [t, ...rest] = key.split(":");
271
+ const chId = rest.join(":");
272
+ if (isConfiguredOwnerChannel(t, chId)) {
273
+ return send(`Refusing to remove ${key} — that's your own configured owner channel. Use /link to re-point it if you really need to.`);
274
+ }
275
+ const r = removeIdentityMapping(key);
276
+ if (!r.removed) return send(`No identity link for ${key}. Run /links to see current links.`);
277
+ return send(`Removed link ${r.key} -> ${r.canonical}${r.prunedPreferred ? " (also cleared its preferred-channel pointer)" : ""}.`);
278
+ }
264
279
  const parts = tail.split(/\s+/).filter(Boolean);
265
280
  if (parts.length === 0 || parts.length > 2) return send("Usage: /link <email-or-user-id>");
266
281
 
package/core/identity.js CHANGED
@@ -156,6 +156,28 @@ function removeTransportIdentities(transport) {
156
156
  return changed;
157
157
  }
158
158
 
159
+ // Remove a single channel→canonical link by its exact key (the left-hand side
160
+ // shown in /links, e.g. "telegram:1111"). Also prunes the canonical's
161
+ // preferred-channel pointer when this was its last remaining channel, so no
162
+ // orphaned pointer lingers. Same memory-then-disk discipline as the transport
163
+ // sweep: a disk-only edit would be resurrected by the cached in-memory object.
164
+ function removeIdentityMapping(key) {
165
+ const k = String(key || "").trim();
166
+ if (!k || !Object.prototype.hasOwnProperty.call(identities.channels, k)) {
167
+ return { removed: false, key: k };
168
+ }
169
+ const canonical = identities.channels[k];
170
+ delete identities.channels[k];
171
+ let prunedPreferred = false;
172
+ const stillReferenced = Object.values(identities.channels).some((c) => c === canonical);
173
+ if (!stillReferenced && Object.prototype.hasOwnProperty.call(identities.preferred, canonical)) {
174
+ delete identities.preferred[canonical];
175
+ prunedPreferred = true;
176
+ }
177
+ saveIdentities();
178
+ return { removed: true, key: k, canonical, prunedPreferred };
179
+ }
180
+
159
181
  module.exports = {
160
182
  identities,
161
183
  normalizeCanonicalUserId,
@@ -169,5 +191,6 @@ module.exports = {
169
191
  canonicalForStoredUserKey,
170
192
  setIdentityMapping,
171
193
  removeTransportIdentities,
194
+ removeIdentityMapping,
172
195
  saveIdentities,
173
196
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.14.6",
3
+ "version": "2.14.7",
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": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-enforcer.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-identity-prune.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -59,7 +59,8 @@
59
59
  "test-persona-pipeline.js",
60
60
  "test-recall-relationship-gate.js",
61
61
  "test-relationship.js",
62
- "test-enforcer.js"
62
+ "test-enforcer.js",
63
+ "test-identity-prune.js"
63
64
  ],
64
65
  "keywords": [
65
66
  "claude",
@@ -0,0 +1,66 @@
1
+ // Targeted identity-link removal (/link remove <key> → identity.removeIdentityMapping).
2
+ // Verifies, in a hermetic temp HOME so the real config is never touched, that:
3
+ // • removing a channel key drops it from BOTH memory and disk;
4
+ // • the canonical's preferred pointer is pruned only when no other channel
5
+ // still references it (last-link semantics);
6
+ // • a shared canonical keeps its preferred pointer until the last link goes;
7
+ // • removing an unknown key is a no-op (removed:false), not a throw.
8
+ // Isolation matches test-unified-identity.js: temp HOME → config-dir points at a
9
+ // throwaway .open-claudia; require identity AFTER HOME is set so its load-once
10
+ // cache reads the seeded file.
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const os = require("os");
15
+ const assert = require("assert");
16
+
17
+ const REPO = __dirname;
18
+
19
+ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "oc-prunetest-"));
20
+ const CFG = path.join(HOME, ".open-claudia");
21
+ fs.mkdirSync(CFG, { recursive: true });
22
+ fs.writeFileSync(path.join(CFG, ".env"), [
23
+ `WORKSPACE=${path.join(HOME, "ws")}`,
24
+ `CLAUDE_PATH=${process.execPath}`,
25
+ `TELEGRAM_BOT_TOKEN=dummy`,
26
+ `TELEGRAM_CHAT_ID=6251055967`,
27
+ ``,
28
+ ].join("\n"));
29
+ // Two channels sharing one canonical + that canonical's preferred pointer —
30
+ // the exact shape of the stray fixtures the command exists to clean.
31
+ const IDFILE = path.join(CFG, "identities.json");
32
+ fs.writeFileSync(IDFILE, JSON.stringify({
33
+ channels: { "telegram:1111": "telegram:111", "kazee:owner-linked-kazee": "telegram:111" },
34
+ preferred: { "telegram:111": { transport: "kazee", channelId: "owner-linked-kazee" } },
35
+ }));
36
+
37
+ process.env.HOME = HOME;
38
+ const id = require(path.join(REPO, "core/identity"));
39
+
40
+ // Unknown key → no-op.
41
+ const miss = id.removeIdentityMapping("telegram:does-not-exist");
42
+ assert.deepStrictEqual(miss, { removed: false, key: "telegram:does-not-exist" }, "unknown key is a no-op");
43
+
44
+ // First removal: canonical still referenced by the kazee link → preferred kept.
45
+ const r1 = id.removeIdentityMapping("telegram:1111");
46
+ assert.strictEqual(r1.removed, true, "first: removed");
47
+ assert.strictEqual(r1.canonical, "telegram:111", "first: canonical reported");
48
+ assert.strictEqual(r1.prunedPreferred, false, "first: preferred kept (still referenced)");
49
+ let disk = JSON.parse(fs.readFileSync(IDFILE, "utf-8"));
50
+ assert.ok(!("telegram:1111" in disk.channels), "first: channel gone from disk");
51
+ assert.ok("kazee:owner-linked-kazee" in disk.channels, "first: sibling link intact on disk");
52
+ assert.ok("telegram:111" in disk.preferred, "first: preferred still on disk");
53
+ assert.ok(!("telegram:1111" in id.identities.channels), "first: channel gone from memory");
54
+
55
+ // Second removal: last link to the canonical → preferred pruned.
56
+ const r2 = id.removeIdentityMapping("kazee:owner-linked-kazee");
57
+ assert.strictEqual(r2.removed, true, "second: removed");
58
+ assert.strictEqual(r2.prunedPreferred, true, "second: preferred pruned (last link)");
59
+ disk = JSON.parse(fs.readFileSync(IDFILE, "utf-8"));
60
+ assert.deepStrictEqual(disk.channels, {}, "second: channels empty on disk");
61
+ assert.deepStrictEqual(disk.preferred, {}, "second: preferred empty on disk");
62
+ assert.deepStrictEqual(id.identities.channels, {}, "second: channels empty in memory");
63
+ assert.deepStrictEqual(id.identities.preferred, {}, "second: preferred empty in memory");
64
+
65
+ fs.rmSync(HOME, { recursive: true, force: true });
66
+ console.log("identity-prune OK");