@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.
Files changed (45) hide show
  1. package/CHANGELOG.md +32 -2
  2. package/bin/cli.js +13 -5
  3. package/bin/loopback-client.js +23 -5
  4. package/bin/owner-gate.js +2 -1
  5. package/bin/schedule.js +7 -2
  6. package/bin/tool.js +2 -2
  7. package/bot.js +6 -2
  8. package/channels/kazee/adapter.js +462 -64
  9. package/channels/kazee/state.js +78 -2
  10. package/channels/types.js +11 -5
  11. package/core/actions.js +6 -0
  12. package/core/approvals.js +9 -0
  13. package/core/bot-endpoint.js +194 -0
  14. package/core/context.js +11 -2
  15. package/core/handlers.js +161 -59
  16. package/core/identity.js +33 -3
  17. package/core/io.js +21 -8
  18. package/core/jobs.js +3 -0
  19. package/core/loopback.js +55 -7
  20. package/core/providers/claude.js +6 -2
  21. package/core/router.js +25 -13
  22. package/core/run-context.js +49 -8
  23. package/core/runner.js +313 -126
  24. package/core/scheduler.js +64 -32
  25. package/core/side-chat.js +40 -11
  26. package/core/speaker-attest.js +2 -0
  27. package/core/state.js +317 -50
  28. package/core/system-prompt.js +61 -14
  29. package/core/tool-guard.js +15 -0
  30. package/core/transcripts.js +23 -6
  31. package/package.json +3 -3
  32. package/test-approval-cards.js +24 -0
  33. package/test-call-speech.js +5 -3
  34. package/test-fixtures/fake-agent-cli.js +52 -1
  35. package/test-provider-compaction.js +50 -11
  36. package/test-provider-gateway.js +31 -10
  37. package/test-provider-language.js +15 -1
  38. package/test-provider-parity-e2e.js +8 -2
  39. package/test-provider-scheduler.js +77 -20
  40. package/test-provider-session-commands.js +11 -2
  41. package/test-provider-side-chat.js +30 -2
  42. package/test-run-lock.js +122 -39
  43. package/test-thread-lanes.js +530 -0
  44. package/core/queue-drain.js +0 -25
  45. 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
- if (state.runningProcess || state.preparingRun) return () => {};
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
- if (state.pendingVoiceClone) {
241
- const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
242
- state.pendingVoiceClone = null;
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
- state.lastInputWasVoice = true;
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
- state.voiceLat = { startAt: tStart, downloadMs: tAfterDownload - tStart, sttMs: tAfterStt - tAfterDownload };
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
- if (state.pendingVoiceClone) {
280
- const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
281
- state.pendingVoiceClone = null;
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, {
@@ -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
- sessionId: admittedSessionId,
140
- admittedSessionId,
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: !!state.lastInputWasVoice,
158
- voiceStream: !!opts.voiceStream || (!!state.lastInputWasVoice && origin.transport === "voice"),
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
  };