@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/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
|
};
|