@inetafrica/open-claudia 3.5.0 → 3.5.4
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 +32 -2
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +23 -5
- package/bin/owner-gate.js +2 -1
- package/bin/schedule.js +7 -2
- package/bin/tool.js +2 -2
- package/bot.js +6 -2
- package/channels/kazee/adapter.js +462 -64
- package/channels/kazee/state.js +78 -2
- package/channels/types.js +11 -5
- package/core/actions.js +6 -0
- package/core/approvals.js +9 -0
- package/core/bot-endpoint.js +194 -0
- package/core/context.js +11 -2
- package/core/handlers.js +161 -59
- package/core/identity.js +33 -3
- package/core/io.js +21 -8
- package/core/jobs.js +3 -0
- package/core/loopback.js +55 -7
- package/core/providers/claude.js +6 -2
- package/core/router.js +25 -13
- package/core/run-context.js +49 -8
- package/core/runner.js +313 -126
- package/core/scheduler.js +64 -32
- package/core/side-chat.js +40 -11
- package/core/speaker-attest.js +2 -0
- package/core/state.js +317 -50
- package/core/system-prompt.js +61 -14
- package/core/tool-guard.js +15 -0
- package/core/transcripts.js +23 -6
- package/package.json +3 -3
- package/test-approval-cards.js +24 -0
- package/test-call-speech.js +5 -3
- package/test-fixtures/fake-agent-cli.js +52 -1
- package/test-provider-compaction.js +50 -11
- package/test-provider-gateway.js +31 -10
- package/test-provider-language.js +15 -1
- package/test-provider-parity-e2e.js +8 -2
- package/test-provider-scheduler.js +77 -20
- package/test-provider-session-commands.js +11 -2
- package/test-provider-side-chat.js +30 -2
- package/test-run-lock.js +122 -39
- package/test-thread-lanes.js +530 -0
- package/core/queue-drain.js +0 -25
- package/test-queue-routing.js +0 -52
package/core/handlers.js
CHANGED
|
@@ -20,8 +20,10 @@ const {
|
|
|
20
20
|
getProviderSettings, resolveProviderSettings,
|
|
21
21
|
getProjectSessions, getLastProjectSession, userOwnsProviderSession,
|
|
22
22
|
createSessionCallbackToken, linkIdentity,
|
|
23
|
-
roomSettingsFor, setRoomSetting, cancelFlagFor,
|
|
23
|
+
roomSettingsFor, setRoomSetting, cancelFlagFor, activeRoomKey,
|
|
24
|
+
laneFor, peekLane, runningLanes, laneBusy, currentLaneKey,
|
|
24
25
|
} = require("./state");
|
|
26
|
+
const { parseConversationKey } = require("./identity");
|
|
25
27
|
const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities, isConfiguredOwnerChannel, removeIdentityMapping } = require("./identity");
|
|
26
28
|
const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
|
|
27
29
|
const { isOnboarded, startOnboarding } = require("./onboarding");
|
|
@@ -85,12 +87,25 @@ function cancelledQueueResult(item, reason) {
|
|
|
85
87
|
};
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
// Drops every message queued in one lane: each one's caller gets a cancelled
|
|
91
|
+
// result (no hanging promise), and an idle lane loses its typing heartbeat (the
|
|
92
|
+
// chip would stay on, and the lane could never be pruned). Returns the items.
|
|
93
|
+
function dropLaneQueue(lane, reason) {
|
|
94
|
+
if (!lane) return [];
|
|
95
|
+
const queued = lane.messageQueue.splice(0);
|
|
96
|
+
lane.waitingForSlot = false;
|
|
90
97
|
for (const item of queued) {
|
|
98
|
+
try { item?.disposeCallListener?.(); } catch (_) {}
|
|
91
99
|
try { item?.deferred?.resolve(cancelledQueueResult(item, reason)); } catch (_) {}
|
|
92
100
|
}
|
|
93
|
-
|
|
101
|
+
if (!laneBusy(lane)) require("./runner").stopLaneTyping(lane);
|
|
102
|
+
return queued;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Clears the CURRENT conversation's queue only: /new, /end or a project change
|
|
106
|
+
// in one thread must not drop what another conversation has waiting.
|
|
107
|
+
function clearTransientQueue(state, reason = "Session context changed before the queued run started") {
|
|
108
|
+
return dropLaneQueue(state ? peekLane(state) : null, reason).length;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
function persistSessionControl(state, options, rollback) {
|
|
@@ -216,7 +231,7 @@ function switchProviderState(providerId, options = {}) {
|
|
|
216
231
|
// stays updated as their default for rooms they have never pinned. Without
|
|
217
232
|
// the room write, switching provider only moved the switcher — everyone else
|
|
218
233
|
// in the room, and every job it spawned, stayed on the old one.
|
|
219
|
-
const roomKey =
|
|
234
|
+
const roomKey = activeRoomKey() || "";
|
|
220
235
|
const previousRoomBackend = roomKey ? (roomSettingsFor(roomKey)?.backend ?? null) : null;
|
|
221
236
|
if (roomKey) setRoomSetting(roomKey, "backend", provider);
|
|
222
237
|
state.settings.backend = provider;
|
|
@@ -297,7 +312,8 @@ function endCurrentSession(options = {}) {
|
|
|
297
312
|
clearTransientQueue(state, "The conversation ended before the queued run started");
|
|
298
313
|
state.currentSession = rootSession();
|
|
299
314
|
state.isFirstMessage = true;
|
|
300
|
-
|
|
315
|
+
const lane = peekLane(state);
|
|
316
|
+
if (lane) lane.cancelRequested = false;
|
|
301
317
|
const persisted = persistSessionControl(state, options, () => {
|
|
302
318
|
state.currentSession = previous.currentSession;
|
|
303
319
|
state.isFirstMessage = previous.isFirstMessage;
|
|
@@ -1413,10 +1429,10 @@ register({
|
|
|
1413
1429
|
if (arg) {
|
|
1414
1430
|
const low = arg.toLowerCase();
|
|
1415
1431
|
if (low === "clone") {
|
|
1416
|
-
state.pendingVoiceClone = { at: Date.now() };
|
|
1432
|
+
laneFor(state).pendingVoiceClone = { at: Date.now() };
|
|
1417
1433
|
return send("🎙️ Send me a voice note now — 5–15s of clear speech and I'll clone it as your voice. Send /voice to cancel.");
|
|
1418
1434
|
}
|
|
1419
|
-
state.pendingVoiceClone = null;
|
|
1435
|
+
laneFor(state).pendingVoiceClone = null;
|
|
1420
1436
|
if (low === "default" || low === "reset") {
|
|
1421
1437
|
ttsVoice.clearVoice();
|
|
1422
1438
|
return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
|
|
@@ -1431,7 +1447,7 @@ register({
|
|
|
1431
1447
|
ttsVoice.setVoice(arg, null);
|
|
1432
1448
|
return send(`🎙️ Voice set: ${arg}`);
|
|
1433
1449
|
}
|
|
1434
|
-
state.pendingVoiceClone = null;
|
|
1450
|
+
laneFor(state).pendingVoiceClone = null;
|
|
1435
1451
|
const rows = ttsVoice.presets().map((p) => [{ text: p.label, callback_data: `vc:p:${p.key}` }]);
|
|
1436
1452
|
rows.push([
|
|
1437
1453
|
{ text: "🎙️ Clone from a clip", callback_data: "vc:clone" },
|
|
@@ -1643,63 +1659,148 @@ register({
|
|
|
1643
1659
|
});
|
|
1644
1660
|
|
|
1645
1661
|
// How long /stop waits for a stopped child to report its exit before it
|
|
1646
|
-
// releases the
|
|
1662
|
+
// releases the conversation anyway (SIGKILL follows SIGTERM after 3 s).
|
|
1647
1663
|
const STOP_RELEASE_MS = 30 * 1000;
|
|
1648
|
-
function releaseStuckStop(state, proc, stopped) {
|
|
1649
|
-
if (
|
|
1650
|
-
if (proc.exitCode != null || proc.signalCode != null) return; // exited: its unwind releases the
|
|
1651
|
-
console.warn(`[stop] child ${proc.pid} never reported its exit; releasing the
|
|
1652
|
-
|
|
1653
|
-
|
|
1664
|
+
function releaseStuckStop(state, lane, proc, stopped) {
|
|
1665
|
+
if (lane.runningProcess !== proc) return;
|
|
1666
|
+
if (proc.exitCode != null || proc.signalCode != null) return; // exited: its unwind releases the lane
|
|
1667
|
+
console.warn(`[stop] child ${proc.pid} never reported its exit; releasing the conversation`);
|
|
1668
|
+
lane.runningProcess = null;
|
|
1669
|
+
lane.ownerToken = null;
|
|
1670
|
+
if (lane.cancelRequested === stopped) lane.cancelRequested = false;
|
|
1671
|
+
try { require("./runner").drainQueuedMessages(state); } catch (_) {}
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
// Stop one lane's turn. Same kill semantics as a single /stop always had:
|
|
1675
|
+
// SIGTERM the tree now, SIGKILL after 3 s, keep the lane busy until the run
|
|
1676
|
+
// unwinds (its own finally frees it and admits what waits). Returns what was
|
|
1677
|
+
// stopped: "running", "preparing", or null when the lane had nothing to stop.
|
|
1678
|
+
function stopLane(state, lane) {
|
|
1679
|
+
const { stopLaneTyping } = require("./runner");
|
|
1680
|
+
if (lane.runningProcess && !lane.activeRunContext) {
|
|
1681
|
+
// Only a compaction runs here (a /compact, or the auto-compaction tail of
|
|
1682
|
+
// a turn that already answered). Kill it and let it fail on its own; a
|
|
1683
|
+
// bare cancel flag would outlive it and silently eat the next message.
|
|
1684
|
+
const pid = lane.runningProcess.pid;
|
|
1685
|
+
const { killProcessTree } = require("./process-tree");
|
|
1686
|
+
killProcessTree(pid, "SIGTERM");
|
|
1687
|
+
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
1688
|
+
stopLaneTyping(lane);
|
|
1689
|
+
return "running";
|
|
1690
|
+
}
|
|
1691
|
+
if (lane.runningProcess) {
|
|
1692
|
+
const proc = lane.runningProcess;
|
|
1693
|
+
const pid = proc.pid;
|
|
1694
|
+
const { killProcessTree } = require("./process-tree");
|
|
1695
|
+
// Quiet unwind: deliverForegroundResult consumes this flag to suppress
|
|
1696
|
+
// the "run failed" diagnostic the SIGTERM exit would otherwise emit. It
|
|
1697
|
+
// names the run it stops, so it can never cancel a different one.
|
|
1698
|
+
const stopped = cancelFlagFor(lane);
|
|
1699
|
+
lane.cancelRequested = stopped;
|
|
1700
|
+
killProcessTree(pid, "SIGTERM");
|
|
1701
|
+
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
1702
|
+
// The lane stays busy until the stopped run has unwound: its own finally
|
|
1703
|
+
// releases it, clears this flag and admits the queue. Releasing it here
|
|
1704
|
+
// let a message sent in those seconds start, meet the flag and be
|
|
1705
|
+
// cancelled "before the provider started" (24 Sep: three messages lost
|
|
1706
|
+
// after one /stop). Backstop: a child that never reports its exit must
|
|
1707
|
+
// not pin the conversation.
|
|
1708
|
+
setTimeout(() => releaseStuckStop(state, lane, proc, stopped), STOP_RELEASE_MS).unref?.();
|
|
1709
|
+
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1710
|
+
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1711
|
+
if (lane.streamInterval) { clearTimeout(lane.streamInterval); lane.streamInterval = null; }
|
|
1712
|
+
// Clearing the heartbeat here orphans the run's own stopper (ownership
|
|
1713
|
+
// check makes it a no-op), so the explicit typing:stop must go out now.
|
|
1714
|
+
stopLaneTyping(lane);
|
|
1715
|
+
return "running";
|
|
1716
|
+
}
|
|
1717
|
+
if (lane.preparingRun) {
|
|
1718
|
+
// Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
|
|
1719
|
+
// the runner's pre-spawn shouldAbort checkpoint bails before spawning.
|
|
1720
|
+
lane.cancelRequested = cancelFlagFor(lane);
|
|
1721
|
+
stopLaneTyping(lane);
|
|
1722
|
+
return "preparing";
|
|
1723
|
+
}
|
|
1724
|
+
return null;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
const STOP_DROP_REASON = "Dropped by /stop before it started";
|
|
1728
|
+
|
|
1729
|
+
// "Dropped 2 queued: "reboot pe2", "and check the…"": what /stop dropped, a
|
|
1730
|
+
// few words of each message (the first few in full, then a count).
|
|
1731
|
+
function droppedNote(items, kind) {
|
|
1732
|
+
const words = (item) => {
|
|
1733
|
+
const all = String(item?.text || item?.runContext?.userPrompt || item?.pendingSideChat?.originalRunContext?.userPrompt || "").replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
|
|
1734
|
+
const head = all.slice(0, 3).join(" ");
|
|
1735
|
+
const short = head.length > 24 ? `${head.slice(0, 23)}…` : (all.length > 3 ? `${head}…` : head);
|
|
1736
|
+
return `"${short || "…"}"`;
|
|
1737
|
+
};
|
|
1738
|
+
const n = items.length;
|
|
1739
|
+
const shown = items.slice(0, 3).map(words);
|
|
1740
|
+
if (n > shown.length) shown.push(`+${n - shown.length} more`);
|
|
1741
|
+
const label = kind === "waiting"
|
|
1742
|
+
? `${n} waiting message${n === 1 ? "" : "s"}`
|
|
1743
|
+
: `${n} queued`;
|
|
1744
|
+
return `Dropped ${label}: ${shown.join(", ")}`;
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// A few words about a conversation: main timeline or which thread, and what
|
|
1748
|
+
// its turn is about.
|
|
1749
|
+
function describeLane(lane, currentRoom = null) {
|
|
1750
|
+
const { threadId, roomKey } = parseConversationKey(lane.key);
|
|
1751
|
+
const where = `${threadId ? `thread …${threadId.slice(-6)}` : "main"}${currentRoom && roomKey !== currentRoom ? ` (${roomKey})` : ""}`;
|
|
1752
|
+
const words = String(lane.activeRunContext?.userPrompt || "")
|
|
1753
|
+
.replace(/\s+/g, " ").trim().split(" ").slice(0, 6).join(" ");
|
|
1754
|
+
const what = lane.isCompacting ? "compacting" : (words ? `"${words.length > 48 ? `${words.slice(0, 47)}…` : words}"` : "working");
|
|
1755
|
+
return `${where}: ${what}`;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
function runningConversationLines(state) {
|
|
1759
|
+
const lanes = runningLanes(state);
|
|
1760
|
+
const waiting = state.lanes instanceof Map
|
|
1761
|
+
? [...state.lanes.values()].filter((lane) => !laneBusy(lane) && lane.messageQueue.length).length
|
|
1762
|
+
: 0;
|
|
1763
|
+
if (!lanes.length && !waiting) return [];
|
|
1764
|
+
return [
|
|
1765
|
+
`Running conversations: ${lanes.length}${waiting ? ` (${waiting} waiting for a slot)` : ""}`,
|
|
1766
|
+
...lanes.map((lane) => `- ${describeLane(lane, parseConversationKey(currentLaneKey(state)).roomKey)}`),
|
|
1767
|
+
];
|
|
1654
1768
|
}
|
|
1655
1769
|
|
|
1656
1770
|
register({
|
|
1657
|
-
name: "stop", description: "Cancel running task",
|
|
1658
|
-
handler: async (env) => {
|
|
1771
|
+
name: "stop", description: "Cancel the running task here (/stop all: every conversation)", args: "[all]",
|
|
1772
|
+
handler: async (env, { tail } = {}) => {
|
|
1659
1773
|
if (!authorized(env)) return;
|
|
1660
1774
|
const state = currentState();
|
|
1661
|
-
const
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
const
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
// messages lost after one /stop). Backstop: a child that never reports
|
|
1684
|
-
// its exit must not pin the room.
|
|
1685
|
-
setTimeout(() => releaseStuckStop(state, proc, stopped), STOP_RELEASE_MS).unref?.();
|
|
1686
|
-
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1687
|
-
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1688
|
-
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1689
|
-
// Clearing the interval here orphans the run's own stopper (ownership
|
|
1690
|
-
// check makes it a no-op), so the explicit typing:stop must go out now.
|
|
1691
|
-
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; state.typingTick = null; }
|
|
1692
|
-
try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
|
|
1775
|
+
const { sideChatCoordinator } = require("./side-chat");
|
|
1776
|
+
if (String(tail || "").trim().toLowerCase() === "all") {
|
|
1777
|
+
const sideCancellation = sideChatCoordinator.cancel(state.userId);
|
|
1778
|
+
const lanes = state.lanes instanceof Map ? [...state.lanes.values()] : [];
|
|
1779
|
+
// Drop the queues first, so no stopped lane's unwind admits them.
|
|
1780
|
+
const dropped = lanes.flatMap((lane) => dropLaneQueue(lane, STOP_DROP_REASON));
|
|
1781
|
+
const stopped = lanes.filter((lane) => stopLane(state, lane)).length;
|
|
1782
|
+
const sideStopped = await sideCancellation;
|
|
1783
|
+
if (!stopped && !dropped.length) return send(sideStopped ? "Cancelled side response." : "Nothing running.");
|
|
1784
|
+
const parts = [];
|
|
1785
|
+
if (stopped) parts.push(`Cancelled ${stopped} running conversation${stopped === 1 ? "" : "s"}.`);
|
|
1786
|
+
if (dropped.length) parts.push(droppedNote(dropped, stopped ? "queued" : "waiting"));
|
|
1787
|
+
return send(parts.join(" "));
|
|
1788
|
+
}
|
|
1789
|
+
// /stop cancels the CURRENT conversation's turn only (other threads keep
|
|
1790
|
+
// running) and drops what was queued in it before the /stop: the person
|
|
1791
|
+
// meant "stop", not "stop this one and start the next". A message sent
|
|
1792
|
+
// after the /stop still queues behind the unwinding run and then runs.
|
|
1793
|
+
const lane = laneFor(state);
|
|
1794
|
+
const sideCancellation = sideChatCoordinator.cancel(state.userId, lane.key);
|
|
1795
|
+
const dropped = dropLaneQueue(lane, STOP_DROP_REASON);
|
|
1796
|
+
if (stopLane(state, lane)) {
|
|
1693
1797
|
await sideCancellation;
|
|
1694
|
-
await send(`Cancelled
|
|
1695
|
-
} else if (
|
|
1696
|
-
//
|
|
1697
|
-
//
|
|
1698
|
-
state.cancelRequested = cancelFlagFor(state);
|
|
1699
|
-
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; state.typingTick = null; }
|
|
1700
|
-
try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
|
|
1798
|
+
await send(dropped.length ? `Cancelled. ${droppedNote(dropped, "queued")}` : "Cancelled.");
|
|
1799
|
+
} else if (dropped.length) {
|
|
1800
|
+
// Nothing to cancel: the dropped messages were waiting for a slot (or
|
|
1801
|
+
// queued behind a turn that is only finishing its tail).
|
|
1701
1802
|
await sideCancellation;
|
|
1702
|
-
await send(
|
|
1803
|
+
await send(droppedNote(dropped, laneBusy(lane) ? "queued" : "waiting"));
|
|
1703
1804
|
} else if (await sideCancellation) await send("Cancelled side response.");
|
|
1704
1805
|
else await send("Nothing running.");
|
|
1705
1806
|
},
|
|
@@ -1741,7 +1842,8 @@ register({
|
|
|
1741
1842
|
`Recall engine: ${activeEngine}`,
|
|
1742
1843
|
`Auto-compact: ${formatCompactWindow(state)}`,
|
|
1743
1844
|
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${cronCount}`,
|
|
1744
|
-
state
|
|
1845
|
+
laneBusy(peekLane(state)) ? "Working..." : "Ready.",
|
|
1846
|
+
...runningConversationLines(state),
|
|
1745
1847
|
].join("\n"));
|
|
1746
1848
|
},
|
|
1747
1849
|
});
|
package/core/identity.js
CHANGED
|
@@ -18,14 +18,42 @@ function defaultCanonicalForChannel(transport, channelId) {
|
|
|
18
18
|
return channelKey(transport, channelId);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
// The conversation
|
|
22
|
-
// pre-unification default canonical: a
|
|
21
|
+
// The conversation key for a channel. Deliberately identical to the
|
|
22
|
+
// pre-unification default canonical: a conversation is keyed by its raw channel
|
|
23
23
|
// identity, never by the unified speaker canonical. This is what keeps each
|
|
24
24
|
// surface's live conversation separate even when the speaker is one being.
|
|
25
|
-
|
|
25
|
+
// A chat THREAD (Kazee "reply in thread") is a conversation of its own inside
|
|
26
|
+
// the channel: `<transport>:<channelId>#thread:<rootId>`. The channel's main
|
|
27
|
+
// timeline keeps the bare channel key, so nothing existing changes. The room
|
|
28
|
+
// (settings, grants) stays the channel key: use roomKeyFor for that.
|
|
29
|
+
const THREAD_SEP = "#thread:";
|
|
30
|
+
function conversationKeyFor(transport, channelId, threadId) {
|
|
31
|
+
const base = channelKey(transport, channelId);
|
|
32
|
+
const t = String(threadId || "").trim();
|
|
33
|
+
return t ? `${base}${THREAD_SEP}${t}` : base;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function roomKeyFor(transport, channelId) {
|
|
26
37
|
return channelKey(transport, channelId);
|
|
27
38
|
}
|
|
28
39
|
|
|
40
|
+
// Split a conversation key back into its parts. The channel id may itself
|
|
41
|
+
// contain ':' (e.g. spaces "task:42"), so only the first ':' separates the
|
|
42
|
+
// transport. Returns {transport, channelId, threadId|null, roomKey}.
|
|
43
|
+
function parseConversationKey(key) {
|
|
44
|
+
const k = String(key || "");
|
|
45
|
+
const i = k.indexOf(THREAD_SEP);
|
|
46
|
+
const room = i >= 0 ? k.slice(0, i) : k;
|
|
47
|
+
const threadId = i >= 0 ? k.slice(i + THREAD_SEP.length) || null : null;
|
|
48
|
+
const c = room.indexOf(":");
|
|
49
|
+
return {
|
|
50
|
+
transport: c >= 0 ? room.slice(0, c) : room,
|
|
51
|
+
channelId: c >= 0 ? room.slice(c + 1) : "",
|
|
52
|
+
threadId,
|
|
53
|
+
roomKey: room,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
29
57
|
function loadIdentities() {
|
|
30
58
|
const raw = readJsonWithFallback(IDENTITIES_FILE, null);
|
|
31
59
|
return {
|
|
@@ -196,6 +224,8 @@ module.exports = {
|
|
|
196
224
|
channelKey,
|
|
197
225
|
defaultCanonicalForChannel,
|
|
198
226
|
conversationKeyFor,
|
|
227
|
+
roomKeyFor,
|
|
228
|
+
parseConversationKey,
|
|
199
229
|
isConfiguredOwnerChannel,
|
|
200
230
|
ownerCanonical,
|
|
201
231
|
channelIsOwner,
|
package/core/io.js
CHANGED
|
@@ -2,7 +2,16 @@
|
|
|
2
2
|
// here so that the right adapter is invoked for the in-flight message
|
|
3
3
|
// without each handler having to know about transports.
|
|
4
4
|
|
|
5
|
-
const { currentAdapter, currentChannelId, currentUserId } = require("./context");
|
|
5
|
+
const { currentAdapter, currentChannelId, currentUserId, currentThreadId } = require("./context");
|
|
6
|
+
|
|
7
|
+
// The chat thread a delivery belongs to. An explicit opts.threadId wins (null =
|
|
8
|
+
// the channel's main timeline); a send aimed at ANOTHER channel never inherits
|
|
9
|
+
// this turn's thread; otherwise it goes back into the thread the turn is in.
|
|
10
|
+
function threadFor(opts = {}) {
|
|
11
|
+
if (opts && Object.prototype.hasOwnProperty.call(opts, "threadId")) return opts.threadId || null;
|
|
12
|
+
if (opts && opts.channelId && String(opts.channelId) !== String(currentChannelId() || "")) return null;
|
|
13
|
+
return currentThreadId();
|
|
14
|
+
}
|
|
6
15
|
|
|
7
16
|
// Adapters historically overloaded their send() return value: a message id
|
|
8
17
|
// (Telegram numeric, Kazee ObjectId string) on success, a bare `true` when
|
|
@@ -32,7 +41,8 @@ async function send(text, opts = {}) {
|
|
|
32
41
|
console.error("send() called outside chat context");
|
|
33
42
|
return { ok: false, messageId: null, editable: false };
|
|
34
43
|
}
|
|
35
|
-
|
|
44
|
+
const threadId = threadFor(opts);
|
|
45
|
+
return normalizeSendResult(await adapter.send(channelId, text, threadId ? { ...opts, threadId } : opts));
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
async function editMessage(messageId, text, opts = {}) {
|
|
@@ -49,11 +59,12 @@ async function deleteMessage(messageId) {
|
|
|
49
59
|
return adapter.delete(channelId, messageId);
|
|
50
60
|
}
|
|
51
61
|
|
|
52
|
-
async function sendVoice(oggPath) {
|
|
62
|
+
async function sendVoice(oggPath, opts = {}) {
|
|
53
63
|
const adapter = currentAdapter();
|
|
54
64
|
const channelId = currentChannelId();
|
|
55
65
|
if (!adapter || !channelId) return false;
|
|
56
|
-
|
|
66
|
+
const threadId = threadFor(opts);
|
|
67
|
+
return threadId ? adapter.sendVoice(channelId, oggPath, { threadId }) : adapter.sendVoice(channelId, oggPath);
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
// Signal the end of a streamed multi-clip spoken reply. Only the voice channel
|
|
@@ -66,7 +77,7 @@ async function sendVoiceEnd() {
|
|
|
66
77
|
return adapter.sendVoiceEnd(channelId);
|
|
67
78
|
}
|
|
68
79
|
|
|
69
|
-
async function sendFile(filePath, caption) {
|
|
80
|
+
async function sendFile(filePath, caption, opts = {}) {
|
|
70
81
|
const adapter = currentAdapter();
|
|
71
82
|
const channelId = currentChannelId();
|
|
72
83
|
if (!adapter || !channelId) return false;
|
|
@@ -79,14 +90,16 @@ async function sendFile(filePath, caption) {
|
|
|
79
90
|
return false;
|
|
80
91
|
}
|
|
81
92
|
} catch (e) { /* fail open only on classification error; primary gate is loopback.handleSend */ }
|
|
82
|
-
|
|
93
|
+
const threadId = threadFor(opts);
|
|
94
|
+
return threadId ? adapter.sendFile(channelId, filePath, caption, { threadId }) : adapter.sendFile(channelId, filePath, caption);
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
async function typing(state) {
|
|
86
98
|
const adapter = currentAdapter();
|
|
87
99
|
const channelId = currentChannelId();
|
|
88
100
|
if (!adapter || !channelId) return;
|
|
89
|
-
|
|
101
|
+
const threadId = currentThreadId();
|
|
102
|
+
return threadId ? adapter.typing(channelId, state, { threadId }) : adapter.typing(channelId, state);
|
|
90
103
|
}
|
|
91
104
|
|
|
92
105
|
function splitMessage(text, maxLen = 4000) {
|
|
@@ -96,4 +109,4 @@ function splitMessage(text, maxLen = 4000) {
|
|
|
96
109
|
return chunks;
|
|
97
110
|
}
|
|
98
111
|
|
|
99
|
-
module.exports = { send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage, normalizeSendResult };
|
|
112
|
+
module.exports = { threadFor, send, editMessage, deleteMessage, sendVoice, sendVoiceEnd, sendFile, typing, splitMessage, normalizeSendResult };
|
package/core/jobs.js
CHANGED
|
@@ -145,6 +145,9 @@ function normalizeRunnableJob(record, options = {}) {
|
|
|
145
145
|
adapter: context.adapter,
|
|
146
146
|
adapterType: context.adapterType,
|
|
147
147
|
channelId: context.channelId,
|
|
148
|
+
// The chat thread the job fires back into (null = the channel's main
|
|
149
|
+
// timeline; jobs from before threads have none).
|
|
150
|
+
threadId: nonEmpty(record.threadId),
|
|
148
151
|
canonicalUserId: context.canonicalUserId,
|
|
149
152
|
userId: nonEmpty(record.userId),
|
|
150
153
|
provider,
|
package/core/loopback.js
CHANGED
|
@@ -137,6 +137,26 @@ function jobCreatorRecord(creator) {
|
|
|
137
137
|
};
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// The chat thread a delivery or a job belongs to. The runner hands a thread
|
|
141
|
+
// turn OC_THREAD_ID, which the CLI sends as `threadId`. For a live, attested
|
|
142
|
+
// run the run decides, never the payload: in its own channel the run's thread
|
|
143
|
+
// (null on the main timeline, so a main turn cannot post into some other
|
|
144
|
+
// thread), and in any other channel no thread (a thread id belongs to its
|
|
145
|
+
// channel). A mismatching claim is logged. A caller with no live run is
|
|
146
|
+
// trusted exactly as its channelId is.
|
|
147
|
+
function threadForCaller(caller, channelId, claimed) {
|
|
148
|
+
const claim = claimed ? String(claimed) : null;
|
|
149
|
+
if (caller && caller.attributed) {
|
|
150
|
+
const sameChannel = String(caller.channelId || "") === String(channelId || "");
|
|
151
|
+
const attested = sameChannel && caller.threadId ? String(caller.threadId) : null;
|
|
152
|
+
if (claim && claim !== attested) {
|
|
153
|
+
console.error(`loopback: payload thread ${claim} does not match the running turn's thread ${attested || "(none)"}; using the turn's`);
|
|
154
|
+
}
|
|
155
|
+
return attested;
|
|
156
|
+
}
|
|
157
|
+
return claim;
|
|
158
|
+
}
|
|
159
|
+
|
|
140
160
|
// Owner gate for every owner-only kind: true only for a LIVE run the bot
|
|
141
161
|
// registered as acting for the owner. The id attest (OC_SPEAKER_ATTEST) is no
|
|
142
162
|
// longer enough: it is valid for the whole boot and can be lifted from another
|
|
@@ -182,18 +202,27 @@ async function handleSend(req, res, url, kind) {
|
|
|
182
202
|
}
|
|
183
203
|
} catch (e) { /* classification failure → fall through; still owner-scoped in practice */ }
|
|
184
204
|
|
|
205
|
+
// Raw-body kinds carry the run attest in headers (the body is the file).
|
|
206
|
+
const caller = callerOf({
|
|
207
|
+
originRunId: req.headers["x-oc-run-id"] || "",
|
|
208
|
+
runAttest: req.headers["x-oc-run-attest"] || "",
|
|
209
|
+
});
|
|
210
|
+
const threadId = threadForCaller(caller, channelId, url.searchParams.get("threadId"));
|
|
211
|
+
|
|
185
212
|
const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
|
|
186
|
-
|
|
213
|
+
// Unique per request: parallel conversations may send the same file name
|
|
214
|
+
// in the same millisecond.
|
|
215
|
+
const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}-${safeName}`);
|
|
187
216
|
await readBodyToFile(req, tmp);
|
|
188
217
|
|
|
189
218
|
let ok = false;
|
|
190
219
|
try {
|
|
191
220
|
if (kind === "send-voice") {
|
|
192
|
-
ok = await adapter.sendVoice(channelId, tmp);
|
|
221
|
+
ok = threadId ? await adapter.sendVoice(channelId, tmp, { threadId }) : await adapter.sendVoice(channelId, tmp);
|
|
193
222
|
} else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
|
|
194
|
-
ok = await adapter.sendPhoto(channelId, tmp, caption);
|
|
223
|
+
ok = threadId ? await adapter.sendPhoto(channelId, tmp, caption, { threadId }) : await adapter.sendPhoto(channelId, tmp, caption);
|
|
195
224
|
} else {
|
|
196
|
-
ok = await adapter.sendFile(channelId, tmp, caption);
|
|
225
|
+
ok = threadId ? await adapter.sendFile(channelId, tmp, caption, { threadId }) : await adapter.sendFile(channelId, tmp, caption);
|
|
197
226
|
}
|
|
198
227
|
} finally {
|
|
199
228
|
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
@@ -228,6 +257,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
228
257
|
// room, and all of their guards apply when it runs. An unattributed caller
|
|
229
258
|
// (a process detached from any live turn) cannot schedule anything.
|
|
230
259
|
let jobCreator = null;
|
|
260
|
+
let jobThreadId = null;
|
|
231
261
|
if (JOB_CREATE_KINDS.has(kind)) {
|
|
232
262
|
const caller = callerOf(payload);
|
|
233
263
|
if (!caller.attributed) {
|
|
@@ -243,6 +273,8 @@ async function handleJson(req, res, url, kind) {
|
|
|
243
273
|
owner: caller.owner === true,
|
|
244
274
|
name: caller.name || null,
|
|
245
275
|
};
|
|
276
|
+
// A job created in a thread fires back into that thread.
|
|
277
|
+
jobThreadId = threadForCaller(caller, jobCreator.channelId, payload.threadId);
|
|
246
278
|
}
|
|
247
279
|
|
|
248
280
|
// Owner-only kinds: notes, tasks, and the people reads. Tasks and people
|
|
@@ -290,9 +322,10 @@ async function handleJson(req, res, url, kind) {
|
|
|
290
322
|
}
|
|
291
323
|
if (!voicePath) return reply(res, 503, { error: "no TTS route available" });
|
|
292
324
|
|
|
325
|
+
const threadId = threadForCaller(callerOf(payload), channelId, payload.threadId);
|
|
293
326
|
let ok = false;
|
|
294
327
|
try {
|
|
295
|
-
ok = await adapter.sendVoice(channelId, voicePath);
|
|
328
|
+
ok = threadId ? await adapter.sendVoice(channelId, voicePath, { threadId }) : await adapter.sendVoice(channelId, voicePath);
|
|
296
329
|
} catch (e) {
|
|
297
330
|
try { fs.unlinkSync(voicePath); } catch (_) {}
|
|
298
331
|
return reply(res, 500, { error: `voice send failed: ${e.message}` });
|
|
@@ -363,6 +396,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
363
396
|
adapter: jobCreator.adapter,
|
|
364
397
|
adapterType: jobCreator.adapterType,
|
|
365
398
|
channelId: jobCreator.channelId,
|
|
399
|
+
threadId: jobThreadId,
|
|
366
400
|
canonicalUserId: jobCreator.canonicalUserId,
|
|
367
401
|
userId: jobCreator.userId,
|
|
368
402
|
createdBy: jobCreatorRecord(jobCreator),
|
|
@@ -394,6 +428,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
394
428
|
adapter: jobCreator.adapter,
|
|
395
429
|
adapterType: jobCreator.adapterType,
|
|
396
430
|
channelId: jobCreator.channelId,
|
|
431
|
+
threadId: jobThreadId,
|
|
397
432
|
canonicalUserId: jobCreator.canonicalUserId,
|
|
398
433
|
userId: jobCreator.userId,
|
|
399
434
|
createdBy: jobCreatorRecord(jobCreator),
|
|
@@ -442,6 +477,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
442
477
|
adapter: jobCreator.adapter,
|
|
443
478
|
adapterType: jobCreator.adapterType,
|
|
444
479
|
channelId: jobCreator.channelId,
|
|
480
|
+
threadId: jobThreadId,
|
|
445
481
|
canonicalUserId: jobCreator.canonicalUserId,
|
|
446
482
|
userId: jobCreator.userId,
|
|
447
483
|
createdBy: jobCreatorRecord(jobCreator),
|
|
@@ -777,7 +813,12 @@ async function handleJson(req, res, url, kind) {
|
|
|
777
813
|
targetChannel = caller.channelId;
|
|
778
814
|
}
|
|
779
815
|
if (!targetAdapter || !targetChannel) return reply(res, 400, { error: "need adapter+channelId or person" });
|
|
780
|
-
|
|
816
|
+
// Optional thread filter: "main" = the channel's main timeline only, a
|
|
817
|
+
// thread id = that thread only; absent = the whole channel.
|
|
818
|
+
const thread = payload.thread === undefined || payload.thread === null || payload.thread === ""
|
|
819
|
+
? undefined
|
|
820
|
+
: (String(payload.thread) === "main" ? null : String(payload.thread));
|
|
821
|
+
const entries = readRecentTranscript(targetAdapter, targetChannel, limit, { thread });
|
|
781
822
|
return reply(res, 200, { ok: true, adapter: targetAdapter, channelId: targetChannel, entries });
|
|
782
823
|
} catch (e) { return reply(res, 400, { error: e.message }); }
|
|
783
824
|
}
|
|
@@ -830,9 +871,11 @@ async function handleJson(req, res, url, kind) {
|
|
|
830
871
|
approverAdapter = owner.adapter;
|
|
831
872
|
approverChannel = owner.channelId;
|
|
832
873
|
}
|
|
874
|
+
const originThread = threadForCaller(callerOf(payload), channelId, payload.threadId);
|
|
833
875
|
const rec = approvals.create({
|
|
834
876
|
tool: payload.tool, verb: payload.verb || "", tier: payload.tier || "destructive",
|
|
835
877
|
command: payload.command, channelId, adapter: adapterId,
|
|
878
|
+
threadId: originThread,
|
|
836
879
|
canonicalUserId: payload.canonicalUserId || null,
|
|
837
880
|
userId: payload.userId || null,
|
|
838
881
|
provider: payload.provider || null,
|
|
@@ -860,6 +903,8 @@ async function handleJson(req, res, url, kind) {
|
|
|
860
903
|
// <pre> needs Telegram's HTML parse mode to render; other adapters ignore it.
|
|
861
904
|
const sendOpts = { keyboard };
|
|
862
905
|
if (buttonAdapter.type === "telegram") sendOpts.parseMode = "HTML";
|
|
906
|
+
// The card goes into the thread the run is in, when it goes to that room.
|
|
907
|
+
if (originThread && buttonAdapter === adapter && String(buttonChannel) === String(channelId)) sendOpts.threadId = originThread;
|
|
863
908
|
const { normalizeSendResult } = require("./io");
|
|
864
909
|
const sent = normalizeSendResult(await buttonAdapter.send(buttonChannel, text, sendOpts));
|
|
865
910
|
if (!sent.ok) { approvals.decide(rec.id, "denied", "send-failed"); return reply(res, 500, { error: "could not deliver approval prompt" }); }
|
|
@@ -875,7 +920,7 @@ async function handleJson(req, res, url, kind) {
|
|
|
875
920
|
return reply(res, 404, { error: "not found" });
|
|
876
921
|
}
|
|
877
922
|
|
|
878
|
-
function readRecentTranscript(adapter, channelId, limit) {
|
|
923
|
+
function readRecentTranscript(adapter, channelId, limit, { thread } = {}) {
|
|
879
924
|
if (!TRANSCRIPTS_DIR) return [];
|
|
880
925
|
try {
|
|
881
926
|
if (!fs2.existsSync(TRANSCRIPTS_DIR)) return [];
|
|
@@ -904,11 +949,14 @@ function readRecentTranscript(adapter, channelId, limit) {
|
|
|
904
949
|
const objChannel = String(obj.chat?.id ?? obj.channelId ?? "");
|
|
905
950
|
if (objAdapter !== adapterPrefix) continue;
|
|
906
951
|
if (objChannel !== channelStr) continue;
|
|
952
|
+
const objThread = obj.chat?.threadId ? String(obj.chat.threadId) : null;
|
|
953
|
+
if (thread !== undefined && objThread !== thread) continue;
|
|
907
954
|
out.push({
|
|
908
955
|
at: obj.at || obj.timestamp || null,
|
|
909
956
|
role: obj.role || obj.author || null,
|
|
910
957
|
text: typeof obj.text === "string" ? obj.text.slice(0, 600) : null,
|
|
911
958
|
kind: obj.kind || null,
|
|
959
|
+
threadId: objThread,
|
|
912
960
|
});
|
|
913
961
|
if (out.length >= limit) break;
|
|
914
962
|
} catch (e) {}
|
package/core/providers/claude.js
CHANGED
|
@@ -85,8 +85,12 @@ function appendSessionArgs(args, context) {
|
|
|
85
85
|
throw providerError("INVALID_PROVIDER_CONTEXT", "resumeSessionId and continueSession are mutually exclusive");
|
|
86
86
|
}
|
|
87
87
|
if (context.fresh) return;
|
|
88
|
-
if (context.resumeSessionId)
|
|
89
|
-
|
|
88
|
+
if (context.resumeSessionId) {
|
|
89
|
+
args.push("--resume", String(context.resumeSessionId));
|
|
90
|
+
// A thread seeded from the main conversation (OC_THREAD_SEED=fork) resumes
|
|
91
|
+
// it into a new session of its own, leaving the main session untouched.
|
|
92
|
+
if (context.forkSession === true) args.push("--fork-session");
|
|
93
|
+
} else if (context.continueSession) args.push("--continue");
|
|
90
94
|
else if (context.sessionId) args.push("--resume", String(context.sessionId));
|
|
91
95
|
}
|
|
92
96
|
|