@inetafrica/open-claudia 3.5.1 → 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 +15 -0
- package/bin/cli.js +13 -5
- package/bin/loopback-client.js +4 -0
- package/bin/schedule.js +7 -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/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 +302 -120
- package/core/scheduler.js +45 -3
- 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/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 +10 -1
- 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/channels/kazee/state.js
CHANGED
|
@@ -4,19 +4,29 @@
|
|
|
4
4
|
// "messages since I last engaged" delta and advances it on every engage, so
|
|
5
5
|
// group chatter the gate stayed silent through is caught up exactly once.
|
|
6
6
|
// Parent-process only; small, atomic writes.
|
|
7
|
+
//
|
|
8
|
+
// Chat threads ("reply in thread") keep their own small record, keyed by the
|
|
9
|
+
// thread's root message id: whether the bot has replied in it (a group thread
|
|
10
|
+
// the bot is part of engages it without an @-mention), who posted the root
|
|
11
|
+
// (fetched once, so the gate never asks twice), and the thread's own context
|
|
12
|
+
// watermark. Bounded: the least recently touched threads are dropped first.
|
|
7
13
|
|
|
8
14
|
const path = require("path");
|
|
9
15
|
const { CONFIG_DIR } = require("../../core/config");
|
|
10
16
|
const { atomicWriteFileSync, readJsonWithFallback } = require("../../core/fsutil");
|
|
11
17
|
|
|
12
18
|
const STATE_FILE = path.join(CONFIG_DIR, "kazee-state.json");
|
|
19
|
+
const MAX_THREADS = 2000;
|
|
13
20
|
|
|
14
21
|
let store = null;
|
|
15
22
|
|
|
16
23
|
function load() {
|
|
17
24
|
if (store) return store;
|
|
18
25
|
const raw = readJsonWithFallback(STATE_FILE, null) || {};
|
|
19
|
-
store = {
|
|
26
|
+
store = {
|
|
27
|
+
chats: raw.chats && typeof raw.chats === "object" ? raw.chats : {},
|
|
28
|
+
threads: raw.threads && typeof raw.threads === "object" && !Array.isArray(raw.threads) ? raw.threads : {},
|
|
29
|
+
};
|
|
20
30
|
return store;
|
|
21
31
|
}
|
|
22
32
|
|
|
@@ -39,4 +49,70 @@ function getContextWatermark(chatId) {
|
|
|
39
49
|
return (c && c.contextAt) || null;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
|
|
52
|
+
function getThread(rootId) {
|
|
53
|
+
const s = load();
|
|
54
|
+
const t = s.threads[String(rootId || "")];
|
|
55
|
+
return t && typeof t === "object" ? t : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Merge `patch` into the thread's record. Writes only when something changed,
|
|
59
|
+
// so the per-send "the bot replied here" mark costs one write per thread.
|
|
60
|
+
function updateThread(rootId, patch = {}) {
|
|
61
|
+
const id = String(rootId || "");
|
|
62
|
+
if (!id) return null;
|
|
63
|
+
const s = load();
|
|
64
|
+
const prev = s.threads[id] || {};
|
|
65
|
+
const changed = Object.keys(patch).some((k) => patch[k] !== undefined && prev[k] !== patch[k]);
|
|
66
|
+
if (!changed) return prev;
|
|
67
|
+
const next = { ...prev };
|
|
68
|
+
for (const [k, v] of Object.entries(patch)) if (v !== undefined) next[k] = v;
|
|
69
|
+
next.at = Date.now();
|
|
70
|
+
s.threads[id] = next;
|
|
71
|
+
const ids = Object.keys(s.threads);
|
|
72
|
+
if (ids.length > MAX_THREADS) {
|
|
73
|
+
ids.sort((a, b) => (Number(s.threads[a].at) || 0) - (Number(s.threads[b].at) || 0));
|
|
74
|
+
for (const old of ids.slice(0, ids.length - MAX_THREADS)) delete s.threads[old];
|
|
75
|
+
}
|
|
76
|
+
save();
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function markBotRepliedInThread(chatId, rootId) {
|
|
81
|
+
return updateThread(rootId, { chatId: String(chatId || ""), botReplied: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function botRepliedInThread(rootId) {
|
|
85
|
+
return getThread(rootId)?.botReplied === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function setThreadRootAuthor(chatId, rootId, authorId) {
|
|
89
|
+
if (!authorId) return null;
|
|
90
|
+
return updateThread(rootId, { chatId: String(chatId || ""), rootAuthor: String(authorId) });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function getThreadRootAuthor(rootId) {
|
|
94
|
+
return getThread(rootId)?.rootAuthor || null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setThreadWatermark(chatId, rootId, iso) {
|
|
98
|
+
if (!iso) return null;
|
|
99
|
+
return updateThread(rootId, { chatId: String(chatId || ""), contextAt: String(iso) });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getThreadWatermark(rootId) {
|
|
103
|
+
return getThread(rootId)?.contextAt || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
setContextWatermark,
|
|
108
|
+
getContextWatermark,
|
|
109
|
+
getThread,
|
|
110
|
+
markBotRepliedInThread,
|
|
111
|
+
botRepliedInThread,
|
|
112
|
+
setThreadRootAuthor,
|
|
113
|
+
getThreadRootAuthor,
|
|
114
|
+
setThreadWatermark,
|
|
115
|
+
getThreadWatermark,
|
|
116
|
+
STATE_FILE,
|
|
117
|
+
MAX_THREADS,
|
|
118
|
+
};
|
package/channels/types.js
CHANGED
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* @property {string} type // "text" | "voice" | "audio" | "photo" | "document" | "action"
|
|
20
20
|
* @property {string} [text] // command text or message body
|
|
21
21
|
* @property {string} [messageId] // outer-channel message id
|
|
22
|
-
* @property {string} [replyToId] // id of message being replied to
|
|
22
|
+
* @property {string} [replyToId] // id of message being replied to (a quote, not a thread)
|
|
23
|
+
* @property {string} [threadId] // chat thread root id when the message was posted IN a thread
|
|
24
|
+
* // (Kazee "reply in thread"); null/absent = the main timeline.
|
|
25
|
+
* // A thread is its own conversation: own session, queue and lane.
|
|
23
26
|
* @property {Object} [reply] // {text, media?: MediaRef[], document, photo} for reply context
|
|
24
27
|
* @property {Array<MediaRef>} [media]
|
|
25
28
|
* @property {Object} [from] // {id, name, username}
|
|
@@ -38,6 +41,8 @@
|
|
|
38
41
|
* // (telegram-native, accepted by both adapters)
|
|
39
42
|
* // or {buttons:[PortableButton]} (portable)
|
|
40
43
|
* @property {string|number} [replyTo]
|
|
44
|
+
* @property {string} [threadId] // post into this chat thread (Kazee threadRootId); core/io
|
|
45
|
+
* // fills it from the turn's thread unless set explicitly
|
|
41
46
|
*
|
|
42
47
|
* @typedef {Object} CommandSpec
|
|
43
48
|
* @property {string} name // no leading slash
|
|
@@ -53,10 +58,11 @@
|
|
|
53
58
|
* send(channelId, text, opts): Promise<messageId|null>
|
|
54
59
|
* edit(channelId, messageId, text, opts): Promise<void>
|
|
55
60
|
* delete(channelId, messageId): Promise<void>
|
|
56
|
-
* sendVoice(channelId, oggPath): Promise<boolean>
|
|
57
|
-
* sendFile(channelId, filePath, caption?): Promise<boolean>
|
|
58
|
-
* typing(channelId, state?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
|
|
59
|
-
*
|
|
61
|
+
* sendVoice(channelId, oggPath, opts?): Promise<boolean> // opts.threadId: post into that thread
|
|
62
|
+
* sendFile(channelId, filePath, caption?, opts?): Promise<boolean> // opts.threadId: post into that thread
|
|
63
|
+
* typing(channelId, state?, opts?): Promise<void> // state: "thinking" | "typing" (default). Non-socket channels ignore state.
|
|
64
|
+
* // opts.threadId: show the indicator inside that thread
|
|
65
|
+
* typingStop?(channelId, opts?): Promise<void> // optional — socket channels only
|
|
60
66
|
* downloadMedia(mediaRef): Promise<localPath|null>
|
|
61
67
|
* buildThreadContext?(envelope): Promise<{contextBlock: string, media?: MediaRef[]}|null>
|
|
62
68
|
* registerCommands(commands: CommandSpec[]): Promise<void>
|
package/core/actions.js
CHANGED
|
@@ -622,6 +622,7 @@ async function handleAction(envelope) {
|
|
|
622
622
|
adapter: "",
|
|
623
623
|
adapterType: rec.adapter,
|
|
624
624
|
channelId: String(rec.channelId),
|
|
625
|
+
threadId: rec.threadId || null,
|
|
625
626
|
canonicalUserId: rec.canonicalUserId,
|
|
626
627
|
userId: rec.userId || null,
|
|
627
628
|
provider: rec.provider,
|
|
@@ -692,6 +693,9 @@ async function handleAction(envelope) {
|
|
|
692
693
|
adapter: isExternalAction ? "" : (rec.adapter || (adapter && adapter.id) || ""),
|
|
693
694
|
adapterType: isExternalAction ? (rec.adapter || (adapter && adapter.type)) : (adapter && adapter.type),
|
|
694
695
|
channelId: String(rec.channelId || envelope.channelId),
|
|
696
|
+
// Back into the thread the held run was in (the press itself may
|
|
697
|
+
// come from another room: the owner's approver channel).
|
|
698
|
+
threadId: rec.threadId || (rec.channelId ? null : envelope.threadId || null),
|
|
695
699
|
canonicalUserId: rec.canonicalUserId,
|
|
696
700
|
userId: rec.userId || null,
|
|
697
701
|
provider: rec.provider,
|
|
@@ -991,6 +995,8 @@ async function handleAction(envelope) {
|
|
|
991
995
|
adapter: envelope.adapter.id,
|
|
992
996
|
adapterType: envelope.adapter.type,
|
|
993
997
|
channelId: String(envelope.channelId),
|
|
998
|
+
// A preset picked in a thread fires in that thread.
|
|
999
|
+
threadId: envelope.threadId || null,
|
|
994
1000
|
canonicalUserId: envelope.canonicalUserId,
|
|
995
1001
|
project: cronProject,
|
|
996
1002
|
prompt: p.prompt,
|
package/core/approvals.js
CHANGED
|
@@ -19,6 +19,7 @@ const path = require("path");
|
|
|
19
19
|
const crypto = require("crypto");
|
|
20
20
|
const CONFIG_DIR = require("../config-dir");
|
|
21
21
|
const { atomicWriteFileSync } = require("./fsutil");
|
|
22
|
+
const { currentChannelId, currentThreadId } = require("./context");
|
|
22
23
|
|
|
23
24
|
const APPROVALS_DIR = process.env.APPROVALS_DIR || path.join(CONFIG_DIR, "approvals");
|
|
24
25
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
@@ -124,17 +125,25 @@ function create({
|
|
|
124
125
|
// room holds many people they are different values, so recording only the
|
|
125
126
|
// room would hand one member's grant to everyone in it.
|
|
126
127
|
speakerId = "", room = "",
|
|
128
|
+
// The chat thread of the ORIGIN turn (null = main timeline), so a re-run
|
|
129
|
+
// this approval wakes lands back in that thread. Unset: taken from the
|
|
130
|
+
// current turn when it is in the origin channel.
|
|
131
|
+
threadId = undefined,
|
|
127
132
|
} = {}) {
|
|
128
133
|
fs.mkdirSync(APPROVALS_DIR, { recursive: true, mode: 0o700 });
|
|
129
134
|
prune();
|
|
130
135
|
const id = `apr_${Date.now()}_${crypto.randomBytes(4).toString("hex")}`;
|
|
131
136
|
const exactCommand = approvedCommand(command);
|
|
137
|
+
const originThread = threadId !== undefined
|
|
138
|
+
? threadId
|
|
139
|
+
: (currentThreadId() && String(currentChannelId() || "") === String(channelId || "") ? currentThreadId() : null);
|
|
132
140
|
const rec = {
|
|
133
141
|
id, status: "pending",
|
|
134
142
|
tool: String(tool || ""), verb: String(verb || ""), tier: String(tier || ""),
|
|
135
143
|
command: exactCommand,
|
|
136
144
|
commandHash: commandHash(exactCommand),
|
|
137
145
|
channelId: String(channelId || ""), adapter: String(adapter || ""),
|
|
146
|
+
threadId: originThread ? String(originThread) : null,
|
|
138
147
|
canonicalUserId: String(canonicalUserId || ""), userId: String(userId || ""),
|
|
139
148
|
provider: String(provider || ""), providerSettings: providerSettings && typeof providerSettings === "object" ? structuredClone(providerSettings) : {},
|
|
140
149
|
project: String(project || ""), projectDir: String(projectDir || ""),
|
package/core/context.js
CHANGED
|
@@ -6,8 +6,8 @@ const { AsyncLocalStorage } = require("node:async_hooks");
|
|
|
6
6
|
|
|
7
7
|
const chatContext = new AsyncLocalStorage();
|
|
8
8
|
|
|
9
|
-
function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
|
|
10
|
-
return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
|
|
9
|
+
function runInChat({ adapter, channelId, threadId, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup }, fn) {
|
|
10
|
+
return chatContext.run({ adapter, channelId, threadId: threadId ? String(threadId) : null, canonicalUserId, userId, transport, raw, conversationKey, roomKey, inboundText, speakerName, triageApprovalId, isGroup: !!isGroup }, fn);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function currentStore() {
|
|
@@ -24,6 +24,14 @@ function currentChannelId() {
|
|
|
24
24
|
return s ? s.channelId : null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// The chat thread this turn belongs to (a Kazee thread's root message id), or
|
|
28
|
+
// null for the channel's main timeline. A thread is its own conversation: its
|
|
29
|
+
// own session, transcript, queue and run lane, and every reply goes back into it.
|
|
30
|
+
function currentThreadId() {
|
|
31
|
+
const s = currentStore();
|
|
32
|
+
return s && s.threadId ? s.threadId : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
function currentCanonicalUserId() {
|
|
28
36
|
const s = currentStore();
|
|
29
37
|
return s ? s.canonicalUserId : null;
|
|
@@ -114,6 +122,7 @@ module.exports = {
|
|
|
114
122
|
runInChat,
|
|
115
123
|
currentAdapter,
|
|
116
124
|
currentChannelId,
|
|
125
|
+
currentThreadId,
|
|
117
126
|
currentCanonicalUserId,
|
|
118
127
|
currentUserId,
|
|
119
128
|
currentTransport,
|
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,
|