@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/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
|
|
package/core/router.js
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
const fs = require("fs");
|
|
6
6
|
const path = require("path");
|
|
7
7
|
const { runInChat } = require("./context");
|
|
8
|
-
const { conversationKeyFor } = require("./identity");
|
|
8
|
+
const { conversationKeyFor, roomKeyFor } = require("./identity");
|
|
9
9
|
const { dispatch } = require("./commands");
|
|
10
10
|
const { handleAction } = require("./actions");
|
|
11
11
|
const { isChatAuthorized } = require("./access");
|
|
12
12
|
const introFlow = require("./intro-flow");
|
|
13
13
|
const { send, deleteMessage } = require("./io");
|
|
14
|
-
const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
|
|
14
|
+
const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey, laneFor, peekLane, laneBusy, slotBlocker } = require("./state");
|
|
15
15
|
const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
|
|
16
16
|
const { transcribeAudio, cloneVoiceFromClip } = require("./media");
|
|
17
17
|
const { hydrateContextMedia } = require("./context-media");
|
|
@@ -89,11 +89,13 @@ function scope(envelope, fn) {
|
|
|
89
89
|
return runInChat({
|
|
90
90
|
adapter: envelope.adapter,
|
|
91
91
|
channelId: envelope.channelId,
|
|
92
|
+
threadId: envelope.threadId || null,
|
|
92
93
|
canonicalUserId: envelope.canonicalUserId,
|
|
93
94
|
userId: envelope.userId,
|
|
94
95
|
transport: envelope.adapter?.type,
|
|
95
96
|
raw: envelope.raw,
|
|
96
|
-
conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId),
|
|
97
|
+
conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId, envelope.threadId),
|
|
98
|
+
roomKey: roomKeyFor(envelope.adapter?.type, envelope.channelId),
|
|
97
99
|
inboundText: envelope.text || "",
|
|
98
100
|
speakerName: envelope.from?.name || envelope.from?.username || "",
|
|
99
101
|
isGroup: envelope.isGroup === true || envelope.surface === "group",
|
|
@@ -219,9 +221,13 @@ async function admitGuardedTurn(askText, envelope, state = currentState()) {
|
|
|
219
221
|
// runAgent — many seconds with no feedback. Start the "thinking" heartbeat as
|
|
220
222
|
// soon as a turn-bound message arrives; the run's own heartbeat supersedes it
|
|
221
223
|
// at executeAdmittedRun, after which this stopper is a no-op. Skipped while a
|
|
222
|
-
// run is active — the chip is already owned by that turn
|
|
224
|
+
// run is active in this conversation — the chip is already owned by that turn
|
|
225
|
+
// (another conversation's run has its own chip and does not matter here) —
|
|
226
|
+
// and when the message will WAIT (the conversation already has a queue, or no
|
|
227
|
+
// slot is free): nothing is thinking about it yet.
|
|
223
228
|
function startPreTurnTyping(state) {
|
|
224
|
-
|
|
229
|
+
const lane = peekLane(state);
|
|
230
|
+
if (laneBusy(lane) || lane?.messageQueue?.length || slotBlocker(state)) return () => {};
|
|
225
231
|
try { return startTypingHeartbeat(state); } catch (_) { return () => {}; }
|
|
226
232
|
}
|
|
227
233
|
|
|
@@ -237,9 +243,10 @@ async function handleVoice(envelope) {
|
|
|
237
243
|
}
|
|
238
244
|
// /voice clone armed this channel: the next clip is a cloning sample, not a
|
|
239
245
|
// prompt. Consume the flag (fresh only) before the normal transcribe path.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
246
|
+
const lane = laneFor(state);
|
|
247
|
+
if (lane.pendingVoiceClone) {
|
|
248
|
+
const fresh = Date.now() - lane.pendingVoiceClone.at < 5 * 60 * 1000;
|
|
249
|
+
lane.pendingVoiceClone = null;
|
|
243
250
|
if (fresh) return captureVoiceClone(envelope, media);
|
|
244
251
|
}
|
|
245
252
|
const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
|
|
@@ -256,9 +263,13 @@ async function handleVoice(envelope) {
|
|
|
256
263
|
if (!transcript) return send("Couldn't transcribe. Try typing it.");
|
|
257
264
|
await send(`Heard: "${transcript}"`, { replyTo: envelope.messageId });
|
|
258
265
|
if (!(await admitGuardedTurn(transcript, envelope, state))) return;
|
|
259
|
-
|
|
266
|
+
// Voice flags live on the conversation's lane: a voice note in one thread
|
|
267
|
+
// never makes another conversation's reply speak. Resolved again after the
|
|
268
|
+
// awaits: an idle lane may have been pruned and recreated meanwhile.
|
|
269
|
+
const voiceLane = laneFor(state);
|
|
270
|
+
voiceLane.lastInputWasVoice = true;
|
|
260
271
|
// Per-stage latency for the voice pipeline; the runner fills in the rest.
|
|
261
|
-
|
|
272
|
+
voiceLane.voiceLat = { startAt: tStart, downloadMs: tAfterDownload - tStart, sttMs: tAfterStt - tAfterDownload };
|
|
262
273
|
return runWithContext(transcript, runContext, envelope, {
|
|
263
274
|
runContext,
|
|
264
275
|
attachments: [{ type: "voice", path: oggPath, mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
|
|
@@ -276,9 +287,10 @@ async function handleAudio(envelope) {
|
|
|
276
287
|
try {
|
|
277
288
|
const media = envelope.media?.[0];
|
|
278
289
|
if (!media) return;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
290
|
+
const lane = laneFor(state);
|
|
291
|
+
if (lane.pendingVoiceClone) {
|
|
292
|
+
const fresh = Date.now() - lane.pendingVoiceClone.at < 5 * 60 * 1000;
|
|
293
|
+
lane.pendingVoiceClone = null;
|
|
282
294
|
if (fresh) return captureVoiceClone(envelope, media);
|
|
283
295
|
}
|
|
284
296
|
const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
|
package/core/run-context.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require("crypto");
|
|
4
4
|
const path = require("path");
|
|
5
|
-
const { currentConversationKey } = require("./context");
|
|
6
|
-
const { resolveProviderSettings } = require("./state");
|
|
5
|
+
const { currentConversationKey, currentThreadId } = require("./context");
|
|
6
|
+
const { resolveProviderSettings, peekLane } = require("./state");
|
|
7
|
+
const { parseConversationKey } = require("./identity");
|
|
7
8
|
|
|
8
9
|
function cloneSerializable(value, label = "run context value") {
|
|
9
10
|
if (value === undefined) return undefined;
|
|
@@ -51,6 +52,28 @@ function projectSnapshot(state, cwd, requestedProject) {
|
|
|
51
52
|
return { name: dir ? path.basename(dir) : null, dir };
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
// OC_THREAD_SEED=fork (Claude only): a thread's FIRST turn resumes the
|
|
56
|
+
// channel's main conversation with --fork-session, so the thread inherits the
|
|
57
|
+
// whole main context in a session of its own. Default "fresh": a new thread
|
|
58
|
+
// starts a new session, seeded only with the thread's own messages. Codex has
|
|
59
|
+
// no fork, so it always starts fresh.
|
|
60
|
+
function threadSeedMode() {
|
|
61
|
+
let value = process.env.OC_THREAD_SEED;
|
|
62
|
+
try { value = require("./config").config?.OC_THREAD_SEED ?? value; } catch (_) {}
|
|
63
|
+
return String(value || "fresh").trim().toLowerCase() === "fork" ? "fork" : "fresh";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function threadForkSession(state, { provider, laneKey, threadId, activeSessionId, opts, liveCall }) {
|
|
67
|
+
if (provider !== "claude" || !threadId || activeSessionId || liveCall) return null;
|
|
68
|
+
if (opts.fresh || opts.resumeSessionId || opts.continueSession || opts.project !== undefined) return null;
|
|
69
|
+
if ((opts.purpose || "foreground") !== "foreground") return null;
|
|
70
|
+
if (threadSeedMode() !== "fork") return null;
|
|
71
|
+
const mainKey = parseConversationKey(laneKey).roomKey;
|
|
72
|
+
const mainSessionId = mainKey && mainKey !== laneKey ? state.activeSessions?.[mainKey]?.claude || null : null;
|
|
73
|
+
if (!mainSessionId || state.quarantinedSessions?.[mainSessionId]) return null;
|
|
74
|
+
return mainSessionId;
|
|
75
|
+
}
|
|
76
|
+
|
|
54
77
|
function serializableOrigin(origin, fallback = {}) {
|
|
55
78
|
const source = origin || fallback || {};
|
|
56
79
|
return {
|
|
@@ -102,14 +125,22 @@ function captureRunContext({
|
|
|
102
125
|
// session associated with the same chat room.
|
|
103
126
|
const liveCall = store?.adapter?.liveCall === true;
|
|
104
127
|
const activeSessionId = state.activeSessions?.[project.name]?.[provider] || null;
|
|
128
|
+
// The run's lane (its conversation: a thread is its own) and thread are
|
|
129
|
+
// fixed here, at admission: queueing, /stop, preview, typing and delivery all
|
|
130
|
+
// follow the lane the message arrived in, never a later one.
|
|
131
|
+
const laneKey = String(opts.laneKey || currentConversationKey() || project.name || "Workspace");
|
|
132
|
+
const threadId = (store && store.threadId) || currentThreadId() || parseConversationKey(laneKey).threadId || null;
|
|
133
|
+
const lane = peekLane(state, laneKey);
|
|
134
|
+
const forkFromSessionId = threadForkSession(state, { provider, laneKey, threadId, activeSessionId, opts, liveCall });
|
|
105
135
|
const requestedSessionId = opts.fresh || liveCall
|
|
106
136
|
? null
|
|
107
|
-
: (opts.resumeSessionId || activeSessionId || null);
|
|
137
|
+
: (opts.resumeSessionId || activeSessionId || forkFromSessionId || null);
|
|
108
138
|
// A quarantined session's provider-side context tripped a safety refusal;
|
|
109
139
|
// resuming would re-send the flagged context and refuse again. Force the
|
|
110
140
|
// turn fresh instead (explicit resume requests included).
|
|
111
141
|
const quarantinedResume = !!(requestedSessionId && state.quarantinedSessions?.[requestedSessionId]);
|
|
112
142
|
const admittedSessionId = quarantinedResume ? null : requestedSessionId;
|
|
143
|
+
const forkedFromMain = !!forkFromSessionId && admittedSessionId === forkFromSessionId;
|
|
113
144
|
const origin = serializableOrigin(opts.origin, store || {});
|
|
114
145
|
const resolvedChannelId = channelId != null
|
|
115
146
|
? String(channelId)
|
|
@@ -132,12 +163,18 @@ function captureRunContext({
|
|
|
132
163
|
? crypto.randomUUID()
|
|
133
164
|
: `run-${now}-${Math.random().toString(36).slice(2, 10)}`),
|
|
134
165
|
admittedAt: now,
|
|
166
|
+
laneKey,
|
|
167
|
+
threadId: threadId ? String(threadId) : null,
|
|
135
168
|
canonicalUserId: canonicalUserId || state.userId || origin.canonicalUserId || null,
|
|
136
169
|
project,
|
|
137
170
|
provider,
|
|
138
171
|
providerSettings,
|
|
139
|
-
|
|
140
|
-
|
|
172
|
+
// A fork turn resumes the MAIN session only to copy it: the turn's own
|
|
173
|
+
// session does not exist until the provider reports it. Never expose the
|
|
174
|
+
// main id as this run's session (OC_SESSION_ID, jobs and approvals made in
|
|
175
|
+
// the turn), or a later wakeup would --resume the main conversation.
|
|
176
|
+
sessionId: forkedFromMain ? null : admittedSessionId,
|
|
177
|
+
admittedSessionId: forkedFromMain ? null : admittedSessionId,
|
|
141
178
|
previousSessionId: activeSessionId,
|
|
142
179
|
purpose: opts.purpose || (opts.label ? String(opts.label) : "foreground"),
|
|
143
180
|
channelId: resolvedChannelId,
|
|
@@ -147,15 +184,18 @@ function captureRunContext({
|
|
|
147
184
|
userPrompt: prompt,
|
|
148
185
|
fresh: !!opts.fresh || liveCall || quarantinedResume,
|
|
149
186
|
continueSession: quarantinedResume || liveCall ? false : !!opts.continueSession,
|
|
150
|
-
resumeSessionId: quarantinedResume || liveCall ? null : (opts.resumeSessionId || null),
|
|
187
|
+
resumeSessionId: quarantinedResume || liveCall ? null : (opts.resumeSessionId || forkFromSessionId || null),
|
|
188
|
+
// Claude --fork-session: resume the main conversation into a NEW session
|
|
189
|
+
// (the thread's own), leaving the main session untouched.
|
|
190
|
+
forkSession: !!forkFromSessionId && !quarantinedResume,
|
|
151
191
|
quarantinedResume,
|
|
152
192
|
skipAutoCompact: liveCall || !!opts.skipAutoCompact,
|
|
153
193
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : null,
|
|
154
194
|
maxTurns: Number.isFinite(opts.maxTurns) ? opts.maxTurns : null,
|
|
155
195
|
imagePaths: cloneSerializable(opts.imagePaths || opts.images || [], "image paths"),
|
|
156
196
|
attachments: cloneSerializable(opts.attachments || [], "attachments"),
|
|
157
|
-
lastInputWasVoice: !!
|
|
158
|
-
voiceStream: !!opts.voiceStream || (!!
|
|
197
|
+
lastInputWasVoice: !!lane?.lastInputWasVoice,
|
|
198
|
+
voiceStream: !!opts.voiceStream || (!!lane?.lastInputWasVoice && origin.transport === "voice"),
|
|
159
199
|
liveCall,
|
|
160
200
|
callHistory: liveCall
|
|
161
201
|
? cloneSerializable(store.adapter.callHistory || [], "call history") : [],
|
|
@@ -221,4 +261,5 @@ module.exports = {
|
|
|
221
261
|
deepFreeze,
|
|
222
262
|
sameCapturedProject,
|
|
223
263
|
serializableOrigin,
|
|
264
|
+
threadSeedMode,
|
|
224
265
|
};
|