@inetafrica/open-claudia 2.14.6 → 2.14.8
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 +9 -0
- package/channels/kazee/adapter.js +16 -1
- package/core/handlers.js +19 -4
- package/core/identity.js +23 -0
- package/core/runner.js +23 -4
- package/core/state.js +6 -0
- package/package.json +5 -3
- package/test-identity-prune.js +66 -0
- package/test-kazee-message-id.js +98 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.14.8
|
|
4
|
+
- **Kazee replies no longer post twice.** On a successful send, the Kazee adapter's `send()` returned the created message's id so the runner can edit its streaming preview in place — but it read only `res.message._id`, then fell back to the ActionHero envelope's numeric `messageId`, then `res._id`. chat-central serves the new message's id under `message.id` on some routes (the adapter's own reply-to fetch already handles `m._id || m.id`), so when only `.id` was present `send()` returned `null`. The final-delivery path in `core/runner.js` reads that return as "did the send fail?": `const sent = await send(firstChunk, { replyTo }); if (!sent) await send(firstChunk);`. A successful-but-idless send therefore looked like a failure and the reply was posted a second time — once as a reply-quote, once as a plain bubble (exactly the pair seen in-chat). `send()` now returns `message._id || message.id` (or the top-level equivalents) and **never** the numeric envelope `messageId` — that counter is a per-request id, not a chat message id, and using it also made later `PUT /message/<counter>` edits 500.
|
|
5
|
+
- **A streaming preview minted on one channel is never edited on another.** Under unified identity the owner's Telegram and Kazee turns share ONE per-canonical state object, and `statusMessageId` (the id of the live "typing…/preview" bubble) sat on it untagged. A Telegram message id (e.g. `24991`) could then be edited under the Kazee adapter → `PUT /message/24991` → *"Cast to ObjectId failed"* 500, a frozen preview, and a duplicate final send. `statusMessageId` now carries the channel it was minted on (`statusMessageChannel`, `core/state.js`); every edit-in-place site in `core/runner.js` (streaming tick + final delivery + the external-hold notice) only edits when the channel matches, otherwise it discards the stale id and sends fresh on the correct channel. Telegram-only deployments are unaffected (single channel → always matches).
|
|
6
|
+
- **Agent-space / OpenClaw:** no new deps, no new env, no schema change; Docker image builds identically. Both are correctness fixes to multi-channel message delivery — the double-send fix helps every Kazee deployment; the channel-scope guard is inert unless a canonical spans two channels. New hermetic test `test-kazee-message-id.js` (id extraction across `_id`/`id`/top-level shapes + the numeric-envelope-counter guard) wired into `npm test`; full suite green.
|
|
7
|
+
|
|
8
|
+
## v2.14.7
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
|
|
3
12
|
## v2.14.6
|
|
4
13
|
- **`/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
14
|
- **`/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.
|
|
@@ -226,7 +226,22 @@ class KazeeAdapter {
|
|
|
226
226
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
227
227
|
try {
|
|
228
228
|
const res = await this._request("POST", `/chat/${encodeURIComponent(channelId)}/message`, body);
|
|
229
|
-
|
|
229
|
+
// Return the created chat message's id so callers can edit it in place.
|
|
230
|
+
// The id can arrive as `_id` (raw Mongo doc) or `id` (serialized) and
|
|
231
|
+
// may sit on `res.message` or at the top level — mirror the reply-to
|
|
232
|
+
// fetch which already handles `m._id || m.id`. Never fall back to the
|
|
233
|
+
// envelope's `messageId`: that's ActionHero's numeric request counter,
|
|
234
|
+
// not a chat message id, and using it makes later edits 500 (and, when
|
|
235
|
+
// the real id is missing, a null return double-sends the reply).
|
|
236
|
+
const m = res?.message || res || {};
|
|
237
|
+
const id = m._id || m.id;
|
|
238
|
+
if (id) return String(id);
|
|
239
|
+
// POST returned 2xx but carried no message id (server fast-ack / payload
|
|
240
|
+
// fallback path). Returning null makes the caller treat a delivered
|
|
241
|
+
// reply as a failed send and resend it — the Kazee double-send. Signal
|
|
242
|
+
// success with a truthy, non-editable sentinel so nothing sends twice.
|
|
243
|
+
console.error("Kazee send: POST ok but no message id in response keys:", Object.keys(res || {}).join(","));
|
|
244
|
+
return true;
|
|
230
245
|
} catch (e) {
|
|
231
246
|
const errMsg = e.message || "";
|
|
232
247
|
// Don't retry on client errors (4xx) — they won't self-heal.
|
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
|
|
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/core/runner.js
CHANGED
|
@@ -789,6 +789,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
789
789
|
const channelId = currentChannelId();
|
|
790
790
|
const adapter = currentAdapter();
|
|
791
791
|
const { settings } = state;
|
|
792
|
+
// A streaming status/preview message id is only editable on the channel it
|
|
793
|
+
// was minted on. Under unified identity the owner's Telegram + Kazee turns
|
|
794
|
+
// share one per-canonical state object, so a message id minted on one channel
|
|
795
|
+
// must never be used as an edit target under the other adapter (Telegram int
|
|
796
|
+
// vs Kazee ObjectId → PUT /message/<id> 500s). Also rejects the non-string
|
|
797
|
+
// "sent, no id" sentinel a socket adapter returns, so we edit only real ids.
|
|
798
|
+
const canEditStatus = () => typeof state.statusMessageId === "string" && state.statusMessageChannel === channelId;
|
|
792
799
|
// Owner-vs-external, resolved once per turn. All internal-activity chatter
|
|
793
800
|
// (recall banners, tool/skill traces, usage alerts) is owner-only so nothing
|
|
794
801
|
// about how the assistant works leaks to an external speaker.
|
|
@@ -861,6 +868,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
861
868
|
state.cancelRequested = false;
|
|
862
869
|
startTyping();
|
|
863
870
|
state.statusMessageId = null;
|
|
871
|
+
state.statusMessageChannel = null;
|
|
864
872
|
state.streamBuffer = "";
|
|
865
873
|
let assistantText = "";
|
|
866
874
|
|
|
@@ -1236,9 +1244,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1236
1244
|
// vetted and delivered.
|
|
1237
1245
|
const display = formatProgress(guardedTurn ? "" : assistantText, toolUses, currentTool, elapsed, currentToolDetail);
|
|
1238
1246
|
if (display && display !== lastUpdate) {
|
|
1247
|
+
// A statusMessageId minted on another channel (shared per-canonical
|
|
1248
|
+
// state under unified identity) can't be edited here — drop it so a
|
|
1249
|
+
// fresh preview is created on this channel instead of 500-ing.
|
|
1250
|
+
if (state.statusMessageId && state.statusMessageChannel !== channelId) {
|
|
1251
|
+
state.statusMessageId = null;
|
|
1252
|
+
state.statusMessageChannel = null;
|
|
1253
|
+
}
|
|
1239
1254
|
if (!state.statusMessageId && assistantText) {
|
|
1240
1255
|
state.statusMessageId = await send(display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ replyTo: replyToMsgId }));
|
|
1241
|
-
|
|
1256
|
+
state.statusMessageChannel = channelId;
|
|
1257
|
+
} else if (canEditStatus()) {
|
|
1242
1258
|
await editMessage(state.statusMessageId, display.length > 4000 ? display.slice(-4000) : display, telegramHtmlOpts({ streaming: true }));
|
|
1243
1259
|
}
|
|
1244
1260
|
lastUpdate = display;
|
|
@@ -1474,7 +1490,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1474
1490
|
const finalText = redactSensitive(assistantText || "(no output)");
|
|
1475
1491
|
appendProjectTranscript("assistant", finalText, {
|
|
1476
1492
|
exitCode: code,
|
|
1477
|
-
sourceMessageId: state.statusMessageId
|
|
1493
|
+
sourceMessageId: typeof state.statusMessageId === "string" ? state.statusMessageId : null,
|
|
1478
1494
|
}, state, getActiveSessionId);
|
|
1479
1495
|
|
|
1480
1496
|
// Phase 3 (3.3): vet the outbound reply for external speakers. Owner/no-
|
|
@@ -1504,8 +1520,11 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1504
1520
|
// external now. Clear the voice-input flag so it can't leak into the
|
|
1505
1521
|
// next turn (no TTS flush runs on this path).
|
|
1506
1522
|
state.lastInputWasVoice = false;
|
|
1507
|
-
if (
|
|
1508
|
-
} else if (
|
|
1523
|
+
if (canEditStatus()) { try { await editMessage(state.statusMessageId, "Checking with the owner…", telegramHtmlOpts()); } catch (e) {} }
|
|
1524
|
+
} else if (canEditStatus() && chunks.length === 1) {
|
|
1525
|
+
// Edit the streaming preview into the final reply — but only if that
|
|
1526
|
+
// preview was minted on THIS channel. A foreign-channel id falls through
|
|
1527
|
+
// to a fresh send below (never edited cross-channel, which would 500).
|
|
1509
1528
|
await editMessage(state.statusMessageId, firstChunk, telegramHtmlOpts({ streaming: true }));
|
|
1510
1529
|
} else {
|
|
1511
1530
|
const sent = await send(firstChunk, telegramHtmlOpts({ replyTo: replyToMsgId }));
|
package/core/state.js
CHANGED
|
@@ -69,6 +69,12 @@ function createUserState(userId) {
|
|
|
69
69
|
cancelRequested: false,
|
|
70
70
|
typingHeartbeat: null,
|
|
71
71
|
statusMessageId: null,
|
|
72
|
+
// Channel the statusMessageId was minted on. Under unified identity the
|
|
73
|
+
// owner's Telegram + Kazee turns share ONE state object, but a message id
|
|
74
|
+
// is only valid on the channel that created it — editing a Telegram id on
|
|
75
|
+
// Kazee 500s. Guard every edit-in-place against this so a foreign-channel
|
|
76
|
+
// id is discarded (fresh send) instead of edited.
|
|
77
|
+
statusMessageChannel: null,
|
|
72
78
|
streamBuffer: "",
|
|
73
79
|
streamInterval: null,
|
|
74
80
|
lastSessionId: saved.lastSessionId || null,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.14.
|
|
3
|
+
"version": "2.14.8",
|
|
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 && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-kazee-message-id.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -59,7 +59,9 @@
|
|
|
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",
|
|
64
|
+
"test-kazee-message-id.js"
|
|
63
65
|
],
|
|
64
66
|
"keywords": [
|
|
65
67
|
"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");
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Kazee adapter send() must return the created chat message's id so the runner
|
|
2
|
+
// can edit the streaming preview in place. Regression guard for the double-send
|
|
3
|
+
// bug: when send() returned null on a *successful* POST (because the id sat at
|
|
4
|
+
// `message.id` rather than `message._id`), runner.js's `if (!sent) send()`
|
|
5
|
+
// fallback fired and posted the reply twice. It must also NEVER return the
|
|
6
|
+
// ActionHero envelope's numeric `messageId` (a per-request counter, not a chat
|
|
7
|
+
// id) — doing so made later PUT /message/<counter> edits 500.
|
|
8
|
+
//
|
|
9
|
+
// Hermetic: temp HOME so requiring core/config + core/identity never reads the
|
|
10
|
+
// real config. Stub _request so no network/socket is touched.
|
|
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
|
+
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "oc-kzid-"));
|
|
19
|
+
const CFG = path.join(HOME, ".open-claudia");
|
|
20
|
+
fs.mkdirSync(CFG, { recursive: true });
|
|
21
|
+
fs.writeFileSync(path.join(CFG, ".env"), [
|
|
22
|
+
`WORKSPACE=${path.join(HOME, "ws")}`,
|
|
23
|
+
`CLAUDE_PATH=${process.execPath}`,
|
|
24
|
+
`TELEGRAM_BOT_TOKEN=dummy`,
|
|
25
|
+
`TELEGRAM_CHAT_ID=6251055967`,
|
|
26
|
+
``,
|
|
27
|
+
].join("\n"));
|
|
28
|
+
process.env.HOME = HOME;
|
|
29
|
+
|
|
30
|
+
const { KazeeAdapter } = require(path.join(REPO, "channels/kazee/adapter"));
|
|
31
|
+
|
|
32
|
+
function makeAdapter() {
|
|
33
|
+
return new KazeeAdapter({ id: "kazee", url: "http://test.invalid", token: "t", botUserId: "bot1" });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Drive send() against a canned _request response and return the resolved id.
|
|
37
|
+
async function sendWith(res) {
|
|
38
|
+
const a = makeAdapter();
|
|
39
|
+
a._request = async () => res;
|
|
40
|
+
return a.send("room1", "hello", {});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Drive send() against a _request that throws (genuine transport failure).
|
|
44
|
+
async function sendFailing() {
|
|
45
|
+
const a = makeAdapter();
|
|
46
|
+
a._request = async () => { throw new Error("Kazee POST /chat/x/message → 404: boom"); };
|
|
47
|
+
return a.send("room1", "hello", {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
(async () => {
|
|
51
|
+
const OID = "6a4fbe7008ae07aaf42b36da"; // Mongo ObjectId shape
|
|
52
|
+
|
|
53
|
+
// Raw Mongo doc: id under message._id.
|
|
54
|
+
assert.strictEqual(await sendWith({ success: true, message: { _id: OID } }), OID, "message._id");
|
|
55
|
+
|
|
56
|
+
// Serialized doc: id under message.id (the previously-broken case that
|
|
57
|
+
// returned null → double-send).
|
|
58
|
+
assert.strictEqual(await sendWith({ success: true, message: { id: OID } }), OID, "message.id");
|
|
59
|
+
|
|
60
|
+
// Top-level id variants (defensive; some routes flatten).
|
|
61
|
+
assert.strictEqual(await sendWith({ _id: OID }), OID, "top-level _id");
|
|
62
|
+
assert.strictEqual(await sendWith({ id: OID }), OID, "top-level id");
|
|
63
|
+
|
|
64
|
+
// Real ids must come back as strings (so the runner's `typeof === "string"`
|
|
65
|
+
// editability guard treats them as editable targets).
|
|
66
|
+
assert.strictEqual(typeof (await sendWith({ message: { _id: OID } })), "string", "real id is a string");
|
|
67
|
+
|
|
68
|
+
// The ActionHero envelope's numeric request counter must NEVER be used as a
|
|
69
|
+
// chat message id — that is exactly what produced PUT /message/24991 → 500.
|
|
70
|
+
// With no real id present the call is still a *successful* send, so it must
|
|
71
|
+
// resolve to the truthy sentinel (not 24991, and crucially not a falsy null
|
|
72
|
+
// that would make the runner resend and double-post the reply).
|
|
73
|
+
assert.strictEqual(
|
|
74
|
+
await sendWith({ success: true, message: {}, messageId: 24991 }),
|
|
75
|
+
true,
|
|
76
|
+
"numeric envelope messageId is never the id; idless success → sentinel true",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// Genuinely idless success → truthy sentinel, NOT null. A null here is the
|
|
80
|
+
// root cause of the double-send: send() succeeded (message delivered) but the
|
|
81
|
+
// caller reads null as "failed" and resends. The sentinel is non-string so it
|
|
82
|
+
// is never used as an edit target.
|
|
83
|
+
assert.strictEqual(await sendWith({ success: true }), true, "idless success → sentinel true");
|
|
84
|
+
assert.strictEqual(await sendWith({}), true, "empty-body success → sentinel true");
|
|
85
|
+
assert.strictEqual(typeof (await sendWith({ success: true })), "boolean", "sentinel is non-string (not editable)");
|
|
86
|
+
|
|
87
|
+
// A genuine transport failure (throw / non-2xx) must still return null so the
|
|
88
|
+
// caller CAN fall back to a plain resend — that path is correct, only the
|
|
89
|
+
// success-without-id path was double-sending.
|
|
90
|
+
assert.strictEqual(await sendFailing(), null, "real failure → null");
|
|
91
|
+
|
|
92
|
+
fs.rmSync(HOME, { recursive: true, force: true });
|
|
93
|
+
console.log("kazee-message-id OK");
|
|
94
|
+
})().catch((e) => {
|
|
95
|
+
try { fs.rmSync(HOME, { recursive: true, force: true }); } catch (_) {}
|
|
96
|
+
console.error(e);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
});
|