@inetafrica/open-claudia 3.5.1 → 3.5.7

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 (44) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bin/cli.js +13 -5
  4. package/bin/loopback-client.js +4 -0
  5. package/bin/schedule.js +7 -2
  6. package/bot.js +6 -2
  7. package/channels/kazee/adapter.js +519 -64
  8. package/channels/kazee/state.js +88 -2
  9. package/channels/types.js +11 -5
  10. package/core/actions.js +6 -0
  11. package/core/approvals.js +9 -0
  12. package/core/context.js +11 -2
  13. package/core/handlers.js +161 -59
  14. package/core/identity.js +33 -3
  15. package/core/io.js +21 -8
  16. package/core/jobs.js +3 -0
  17. package/core/loopback.js +55 -7
  18. package/core/providers/claude.js +6 -2
  19. package/core/providers/utility-policy.js +3 -1
  20. package/core/router.js +25 -13
  21. package/core/run-context.js +49 -8
  22. package/core/runner.js +318 -122
  23. package/core/sandbox-probe.js +78 -0
  24. package/core/scheduler.js +45 -3
  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/thread-title.js +237 -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 +7 -1
  38. package/test-provider-scheduler.js +77 -20
  39. package/test-provider-session-commands.js +11 -2
  40. package/test-provider-side-chat.js +30 -2
  41. package/test-run-lock.js +122 -39
  42. package/test-thread-lanes.js +530 -0
  43. package/core/queue-drain.js +0 -25
  44. package/test-queue-routing.js +0 -52
package/core/state.js CHANGED
@@ -7,7 +7,7 @@ const path = require("path");
7
7
  const crypto = require("crypto");
8
8
  const {
9
9
  STATE_FILE, SESSIONS_FILE, IDENTITIES_FILE, CONFIG_DIR, CHAT_ID, UNIFIED_IDENTITY,
10
- JOBS_FILE, CRONS_FILE, WORKSPACE,
10
+ JOBS_FILE, CRONS_FILE, WORKSPACE, config,
11
11
  } = require("./config");
12
12
  const {
13
13
  normalizeCanonicalUserId,
@@ -19,6 +19,7 @@ const {
19
19
  defaultCanonicalForChannel,
20
20
  channelKey,
21
21
  identities,
22
+ parseConversationKey,
22
23
  } = require("./identity");
23
24
  const { currentChannelId, currentCanonicalUserId, currentConversationKey, currentRoomKey } = require("./context");
24
25
  const { atomicWriteFileSync, readJsonWithFallback } = require("./fsutil");
@@ -32,6 +33,14 @@ const {
32
33
  const STATE_SCHEMA_VERSION = 3;
33
34
  const SESSIONS_SCHEMA_VERSION = 3;
34
35
  const MAX_USAGE_ENTRIES = 50;
36
+ // Thread conversations get their own usage budget so a busy day of threads can
37
+ // never evict the channels' own usage records (and vice versa).
38
+ const MAX_THREAD_USAGE_ENTRIES = 200;
39
+ // Every thread keeps its provider session pointer in activeSessions; without a
40
+ // bound one speaker's state would grow by one entry per thread forever. Channel
41
+ // keys (and anything that is not a thread) are never evicted.
42
+ const MAX_THREAD_SESSIONS = 200;
43
+ const THREAD_KEY_MARK = "#thread:";
35
44
  const MAX_SESSIONS_PER_PROJECT = 20;
36
45
  const MAX_SESSION_CALLBACKS = 200;
37
46
  const SESSION_CALLBACK_TTL_MS = 10 * 60 * 1000;
@@ -93,10 +102,18 @@ function normalizeActiveSessions(value) {
93
102
  return result;
94
103
  }
95
104
 
96
- function boundUsageBySession(value, limit = MAX_USAGE_ENTRIES) {
105
+ // Usage keys start with the conversation key, so thread and channel records are
106
+ // bounded separately (partitioned, oldest first out within each partition) and
107
+ // a thread can never push a channel's usage out, nor one thread flood the rest.
108
+ function boundUsageBySession(value, limit = MAX_USAGE_ENTRIES, threadLimit = MAX_THREAD_USAGE_ENTRIES) {
97
109
  const entries = Object.entries(cloneRecord(value))
98
110
  .filter(([key, usage]) => typeof key === "string" && key && isRecord(usage));
99
- return Object.fromEntries(entries.slice(-Math.max(1, limit)));
111
+ const isThread = ([key]) => key.split("\u0000")[0].includes(THREAD_KEY_MARK);
112
+ const keep = new Set([
113
+ ...entries.filter((entry) => !isThread(entry)).slice(-Math.max(1, limit)),
114
+ ...entries.filter(isThread).slice(-Math.max(1, threadLimit)),
115
+ ]);
116
+ return Object.fromEntries(entries.filter((entry) => keep.has(entry)));
100
117
  }
101
118
 
102
119
  function freshUsage() {
@@ -398,11 +415,19 @@ function roomSettingsFor(roomKey) {
398
415
  return isRecord(entry) && isRecord(entry.settings) ? entry.settings : null;
399
416
  }
400
417
 
401
- // The room whose settings govern the state's current turn. Stamped in
402
- // getUserState from the live room key; null outside a chat turn (CLI, boot,
403
- // tests), which is what keeps those paths on the person's settings.
418
+ // The room whose settings govern the current turn, read from the request
419
+ // context (never from anything stamped on the shared state: one speaker can be
420
+ // running in two rooms at once, and a stamp would follow whichever message came
421
+ // last). A thread's room is its channel, so threads share the chat's settings.
422
+ // null outside a chat turn (CLI, boot, tests), which is what keeps those paths
423
+ // on the person's settings.
424
+ function activeRoomKey() {
425
+ const key = currentRoomKey();
426
+ return key ? parseConversationKey(key).roomKey || null : null;
427
+ }
428
+
404
429
  function roomOverlayFor(state) {
405
- return roomSettingsFor(state && state.roomKey);
430
+ return roomSettingsFor(activeRoomKey());
406
431
  }
407
432
 
408
433
  function setRoomSetting(roomKey, key, value) {
@@ -459,10 +484,34 @@ function setProviderSession(state, providerId, projectName, sessionId) {
459
484
  throw new TypeError("provider, project, and sessionId are required");
460
485
  }
461
486
  if (!isRecord(state.activeSessions)) state.activeSessions = {};
462
- state.activeSessions[project] = { ...cloneRecord(state.activeSessions[project]), [provider]: sessionId };
487
+ const bucket = { ...cloneRecord(state.activeSessions[project]), [provider]: sessionId };
488
+ // Re-insert so key order is recency order: eviction drops the oldest threads.
489
+ delete state.activeSessions[project];
490
+ state.activeSessions[project] = bucket;
491
+ evictIdleThreadSessions(state);
463
492
  return sessionId;
464
493
  }
465
494
 
495
+ // Keep at most MAX_THREAD_SESSIONS thread pointers per speaker, dropping the
496
+ // least recently written first. Channel keys are never evicted, and neither is
497
+ // a thread whose lane is running or has work queued. An evicted thread simply
498
+ // starts a fresh session if it is ever spoken to again.
499
+ function evictIdleThreadSessions(state, limit = MAX_THREAD_SESSIONS) {
500
+ if (!isRecord(state?.activeSessions)) return 0;
501
+ const threads = Object.keys(state.activeSessions).filter((key) => key.includes(THREAD_KEY_MARK));
502
+ let excess = threads.length - Math.max(1, limit);
503
+ let evicted = 0;
504
+ for (const key of threads) {
505
+ if (excess <= 0) break;
506
+ const lane = peekLane(state, key);
507
+ if (lane && (laneBusy(lane) || lane.messageQueue.length)) continue;
508
+ delete state.activeSessions[key];
509
+ excess -= 1;
510
+ evicted += 1;
511
+ }
512
+ return evicted;
513
+ }
514
+
466
515
  function clearProviderSession(state, providerId, projectName) {
467
516
  const project = getProjectKey(state, projectName);
468
517
  const provider = normalizeProviderId(providerId, null);
@@ -539,7 +588,7 @@ function backfillDefaults(bucket) {
539
588
  function resolveProviderSettings(state, providerId) {
540
589
  const provider = normalizeProviderId(providerId || getActiveProvider(state), null);
541
590
  if (!provider) return providerDefaults();
542
- const roomKey = state && state.roomKey ? String(state.roomKey) : "";
591
+ const roomKey = activeRoomKey() || "";
543
592
  const room = roomKey && isRecord(savedState.rooms)
544
593
  && isRecord(savedState.rooms[roomKey])
545
594
  && isRecord(savedState.rooms[roomKey].providerSettings)
@@ -568,7 +617,7 @@ function getProviderSettings(state, providerId) {
568
617
  // gets the same assistant. The room's bucket is seeded from the speaker who
569
618
  // first pins it, then diverges — mutations by callers land on the room object
570
619
  // and persist with savedState.rooms.
571
- const roomKey = state && state.roomKey ? String(state.roomKey) : "";
620
+ const roomKey = activeRoomKey() || "";
572
621
  if (roomKey) {
573
622
  if (!isRecord(savedState.rooms)) savedState.rooms = {};
574
623
  if (!isRecord(savedState.rooms[roomKey])) savedState.rooms[roomKey] = {};
@@ -670,28 +719,17 @@ function createUserState(userId) {
670
719
  userId: String(userId),
671
720
  channelId: currentChannelId(),
672
721
  currentSession: saved.currentSession || rootSession(),
673
- runningProcess: null,
674
- preparingRun: false,
675
- cancelRequested: false,
676
- typingHeartbeat: null,
677
- statusMessageId: null,
678
- // Channel the statusMessageId was minted on. Under unified identity the
679
- // owner's Telegram + Kazee turns share ONE state object, but a message id
680
- // is only valid on the channel that created it — editing a Telegram id on
681
- // Kazee 500s. Guard every edit-in-place against this so a foreign-channel
682
- // id is discarded (fresh send) instead of edited.
683
- statusMessageChannel: null,
684
- streamBuffer: "",
685
- streamInterval: null,
686
722
  activeSessions: normalizeActiveSessions(saved.activeSessions),
687
723
  quarantinedSessions: boundQuarantinedSessions(saved.quarantinedSessions),
688
724
  providerSettings: normalizeProviderSettings(saved.providerSettings),
689
725
  usageBySession: boundUsageBySession(saved.usageBySession),
690
- messageQueue: [],
691
726
  isFirstMessage: true,
692
- lastInputWasVoice: false,
693
727
  settings,
694
728
  legacyProviderState: cloneRecord(saved.legacyProviderState),
729
+ // The durable compaction journal stays one slot per speaker even though
730
+ // compaction runs per lane: commitProviderCompaction is synchronous from
731
+ // prepare to cleanup (no await), so two lanes can never hold it at once,
732
+ // and the persisted recovery format stays unchanged.
695
733
  pendingCompaction: isRecord(saved.pendingCompaction) ? structuredClone(saved.pendingCompaction) : null,
696
734
  completedCompactions: cloneRecord(saved.completedCompactions),
697
735
  onboardingStep: null,
@@ -702,10 +740,18 @@ function createUserState(userId) {
702
740
  pendingClaudeAuthLabel: null,
703
741
  pendingCodexAuthProcess: null,
704
742
  pendingCodexAuthLabel: null,
705
- isCompacting: false,
706
743
  lastCompactedAt: saved.lastCompactedAt || 0,
707
744
  };
708
745
  attachRuntimeAccessors(state);
746
+ // /voice clone arms the NEXT voice note of this conversation. The flag lives
747
+ // on the lane; this view keeps callers that only hold the state (the button
748
+ // handler in actions.js) on that same single copy.
749
+ Object.defineProperty(state, "pendingVoiceClone", {
750
+ configurable: true,
751
+ enumerable: false,
752
+ get() { return laneFor(state).pendingVoiceClone; },
753
+ set(value) { laneFor(state).pendingVoiceClone = value || null; },
754
+ });
709
755
  state.isFirstMessage = !getProviderSession(state, getActiveProvider(state), getProjectKey(state));
710
756
  return state;
711
757
  }
@@ -765,7 +811,6 @@ function getUserState(userId) {
765
811
  }
766
812
  }
767
813
  state.channelId = currentChannelId() || state.channelId;
768
- state.roomKey = currentRoomKey() || null;
769
814
  try { seedConversationContinuity(state); } catch (e) {}
770
815
  return state;
771
816
  }
@@ -784,39 +829,244 @@ function resetSettings(state = currentState()) {
784
829
  attachRuntimeAccessors(state);
785
830
  }
786
831
 
787
- // Synchronous single-flight run-lock. Returns true and arms the lock if no turn
788
- // is running or preparing for this user; false if the caller must queue. There
789
- // must be NO await between calling this and the work it guards, so two turns for
790
- // one canonical user (owner's Telegram + Kazee sharing one state under unified
791
- // identity) can never both acquire and then race the shared state + JSON files.
792
- // Compaction owns the same admission boundary across its summary/seed gap.
793
- function acquireRunLock(state = currentState()) {
794
- if (state.runningProcess || state.preparingRun || state.isCompacting) return false;
795
- state.preparingRun = true;
796
- return true;
832
+ // ── Conversation lanes ───────────────────────────────────────────────
833
+ // A lane is one conversation's run slot. The channel's main timeline and every
834
+ // chat thread (each its own conversationKey) get a lane, so they can work at
835
+ // the same time, while turns INSIDE one conversation stay strictly one at a
836
+ // time: the same provider session must never run twice. Everything that
837
+ // belongs to a running turn lives on its lane, never on the speaker's state:
838
+ // the lock and queue, the /stop flag, the live preview, the typing heartbeat
839
+ // and the voice flags. Lanes are created on demand and pruned once idle.
840
+ //
841
+ // Slots: a speaker may have OC_MAX_PARALLEL_TURNS (default 4) lanes running at
842
+ // once, the whole bot OC_MAX_PARALLEL_TURNS_GLOBAL (default 8). Over a cap the
843
+ // message waits in its own lane's queue; whenever any lane frees, the waiting
844
+ // lanes are admitted oldest waiting message first (see waitingLanes()).
845
+ // Compaction and side chats never take a slot of their own: compaction runs in
846
+ // its lane (a compacting lane counts as one running lane, and it never waits
847
+ // for a slot: it is the tail of the lane's turn or the lane's own /compact),
848
+ // and a side chat is a read-only answer beside its lane's running turn.
849
+
850
+ const laneStates = new Set();
851
+
852
+ function freshLane(key) {
853
+ return {
854
+ key,
855
+ threadId: parseConversationKey(key).threadId,
856
+ // Lock + queue. `settling` holds the lane after the provider exited while
857
+ // the turn's tail (memory review, auto-compaction) finishes, so the next
858
+ // queued message never overtakes it.
859
+ runningProcess: null,
860
+ preparingRun: false,
861
+ settling: false,
862
+ isCompacting: false,
863
+ messageQueue: [],
864
+ waitingForSlot: false,
865
+ activeRunContext: null,
866
+ // The run that holds the lane (runner.executeAdmittedRun): only it may
867
+ // free the lane. Cleared when /stop's backstop frees a stuck run's lane,
868
+ // so that run's late unwind cannot free a lane another run has claimed.
869
+ ownerToken: null,
870
+ // /stop names the run it stops (see cancelFlagFor).
871
+ cancelRequested: false,
872
+ // Live preview of the running turn. previewRunId names the run that owns
873
+ // statusMessageId, so a run can only ever edit or settle its own message.
874
+ // statusMessageChannel is the channel the id was minted on: a message id
875
+ // is only valid on the channel that created it, so a foreign-channel id is
876
+ // discarded (fresh send) instead of edited.
877
+ statusMessageId: null,
878
+ statusMessageChannel: null,
879
+ streamBuffer: "",
880
+ streamInterval: null,
881
+ previewRunId: null,
882
+ // Typing heartbeat of the turn (see runner.startTypingHeartbeat).
883
+ typingHeartbeat: null,
884
+ typingTick: null,
885
+ typingTarget: null,
886
+ thinkingPhase: false,
887
+ // Voice: the reply to a voice note is spoken in the conversation it came from.
888
+ lastInputWasVoice: false,
889
+ voiceLat: null,
890
+ pendingVoiceClone: null,
891
+ };
892
+ }
893
+
894
+ function lanesOf(state) {
895
+ if (!state || typeof state !== "object") throw new TypeError("state is required");
896
+ // Not enumerable: lanes hold live processes and timers, and must never be
897
+ // cloned, spread into a prompt view, or serialized with the state.
898
+ if (!(state.lanes instanceof Map)) {
899
+ Object.defineProperty(state, "lanes", { value: new Map(), configurable: true, writable: true, enumerable: false });
900
+ }
901
+ return state.lanes;
902
+ }
903
+
904
+ // The lane of the current turn: its conversation key (a thread is its own),
905
+ // else the state's project outside a chat turn (CLI, boot, tests).
906
+ function currentLaneKey(state = currentState()) {
907
+ return currentConversationKey() || projectNameFrom(state?.currentSession) || "Workspace";
908
+ }
909
+
910
+ // The lane a captured run belongs to: fixed at admission, never re-read.
911
+ function runLaneKey(runContext) {
912
+ return runContext?.laneKey || runContext?.project?.name || null;
913
+ }
914
+
915
+ function laneFor(state = currentState(), laneKey = null) {
916
+ const key = laneKey ? String(laneKey) : currentLaneKey(state);
917
+ const lanes = lanesOf(state);
918
+ let lane = lanes.get(key);
919
+ if (!lane) {
920
+ lane = freshLane(key);
921
+ lanes.set(key, lane);
922
+ laneStates.add(state);
923
+ }
924
+ return lane;
925
+ }
926
+
927
+ function laneForRun(state, runContext) {
928
+ return laneFor(state, runLaneKey(runContext));
929
+ }
930
+
931
+ // Look a lane up without creating it (status, /stop, eviction).
932
+ function peekLane(state, laneKey = null) {
933
+ if (!state || !(state.lanes instanceof Map)) return null;
934
+ return state.lanes.get(laneKey ? String(laneKey) : currentLaneKey(state)) || null;
935
+ }
936
+
937
+ function laneBusy(lane) {
938
+ return !!(lane && (lane.runningProcess || lane.preparingRun || lane.settling || lane.isCompacting));
797
939
  }
798
940
 
799
- // /stop arms state.cancelRequested with the id of the run it stops (true only
941
+ function laneIsIdle(lane) {
942
+ return !laneBusy(lane)
943
+ && !lane.messageQueue.length
944
+ && !lane.activeRunContext
945
+ && !lane.cancelRequested
946
+ && !lane.streamInterval
947
+ && !lane.statusMessageId
948
+ && !lane.typingHeartbeat
949
+ && !lane.lastInputWasVoice
950
+ && !lane.pendingVoiceClone;
951
+ }
952
+
953
+ // Drop lanes with nothing left on them (threads are many and mostly quiet).
954
+ function pruneIdleLanes(state) {
955
+ if (!state || !(state.lanes instanceof Map)) return;
956
+ for (const [key, lane] of state.lanes) {
957
+ if (laneIsIdle(lane)) state.lanes.delete(key);
958
+ }
959
+ if (!state.lanes.size) laneStates.delete(state);
960
+ }
961
+
962
+ function statesWithLanes() {
963
+ const all = new Set(laneStates);
964
+ for (const state of userStates.values()) all.add(state);
965
+ return all;
966
+ }
967
+
968
+ function capSetting(name, fallback) {
969
+ const raw = config?.[name] ?? process.env[name];
970
+ const value = parseInt(raw, 10);
971
+ return Number.isSafeInteger(value) && value >= 1 ? value : fallback;
972
+ }
973
+
974
+ function maxParallelTurns() { return capSetting("OC_MAX_PARALLEL_TURNS", 4); }
975
+ function maxParallelTurnsGlobal() { return capSetting("OC_MAX_PARALLEL_TURNS_GLOBAL", 8); }
976
+
977
+ function runningLanes(state) {
978
+ if (!state || !(state.lanes instanceof Map)) return [];
979
+ return [...state.lanes.values()].filter(laneBusy);
980
+ }
981
+
982
+ function globalRunningLaneCount() {
983
+ let count = 0;
984
+ for (const state of statesWithLanes()) count += runningLanes(state).length;
985
+ return count;
986
+ }
987
+
988
+ // Why a new lane of this speaker cannot start now, or null when a slot is free.
989
+ function slotBlocker(state) {
990
+ const mine = runningLanes(state).length;
991
+ if (mine >= maxParallelTurns()) return { scope: "speaker", running: mine };
992
+ const all = globalRunningLaneCount();
993
+ if (all >= maxParallelTurnsGlobal()) return { scope: "global", running: all };
994
+ return null;
995
+ }
996
+
997
+ // Synchronous single-flight admission into a lane. There must be NO await
998
+ // between calling this and the work it guards, so two turns of one
999
+ // conversation can never both acquire and then race its session. Returns
1000
+ // { ok: true } with the lane armed (preparingRun), or { ok: false, reason }:
1001
+ // "busy" (the conversation is running or compacting: queue behind it),
1002
+ // "waiting" (the conversation already waits for a slot: queue in order) or
1003
+ // "cap" (a parallel-turn cap is reached: wait for a slot). `running` is the
1004
+ // number of conversations holding the slots the message is waiting for.
1005
+ function admitLane(state = currentState(), laneKey = null) {
1006
+ const lane = laneFor(state, laneKey);
1007
+ if (laneBusy(lane)) return { ok: false, reason: "busy", lane };
1008
+ const blocker = slotBlocker(state);
1009
+ if (lane.messageQueue.length) return { ok: false, reason: "waiting", lane, running: blocker?.running || 0 };
1010
+ if (blocker) {
1011
+ lane.waitingForSlot = true;
1012
+ return { ok: false, reason: "cap", lane, running: blocker.running, scope: blocker.scope };
1013
+ }
1014
+ // A freed slot goes to the lanes already waiting for one, oldest first: a
1015
+ // newcomer queues behind them (the runner then admits in order).
1016
+ if (waitingLanes().some((waiting) => waiting.lane !== lane && !slotBlocker(waiting.state))) {
1017
+ lane.waitingForSlot = true;
1018
+ return { ok: false, reason: "cap", lane, running: runningLanes(state).length, scope: "queue" };
1019
+ }
1020
+ lane.preparingRun = true;
1021
+ lane.waitingForSlot = false;
1022
+ return { ok: true, lane };
1023
+ }
1024
+
1025
+ function acquireRunLock(state = currentState(), laneKey = null) {
1026
+ return admitLane(state, laneKey).ok;
1027
+ }
1028
+
1029
+ // Lanes with queued work and no run, oldest waiting message first. These are
1030
+ // the lanes waiting for a slot (or, right after a run ends, for their own next
1031
+ // turn); the runner admits them in this order whenever a lane frees.
1032
+ function waitingLanes() {
1033
+ const waiting = [];
1034
+ for (const state of statesWithLanes()) {
1035
+ if (!(state.lanes instanceof Map)) continue;
1036
+ for (const lane of state.lanes.values()) {
1037
+ if (!laneBusy(lane) && lane.messageQueue.length) waiting.push({ state, lane });
1038
+ }
1039
+ }
1040
+ const order = ({ lane }) => [Number(lane.messageQueue[0]?.queuedAt) || 0, Number(lane.messageQueue[0]?.seq) || 0];
1041
+ return waiting.sort((a, b) => {
1042
+ const [ta, sa] = order(a);
1043
+ const [tb, sb] = order(b);
1044
+ return ta - tb || sa - sb;
1045
+ });
1046
+ }
1047
+
1048
+ // /stop arms lane.cancelRequested with the id of the run it stops (true only
800
1049
  // when no run context is known), and a run acts on the flag only when it names
801
1050
  // that run. A bare boolean let a stop outlive its run and cancel the next one.
802
- function cancelFlagFor(state = currentState()) {
803
- return state.activeRunContext?.runId || true;
1051
+ function cancelFlagFor(lane = laneFor(currentState())) {
1052
+ return lane?.activeRunContext?.runId || true;
804
1053
  }
805
1054
  function cancelTargets(flag, runContext) {
806
1055
  return flag === true || (!!flag && flag === runContext?.runId);
807
1056
  }
808
1057
 
809
- // True when ANY user has a turn running, admitted-and-preparing, compacting, or
810
- // waiting in its queue. Same busy predicate as acquireRunLock, but across every
811
- // user state rather than one — the Telegram poll-wedge backstop consults this
812
- // before a self-exit so a reboot never interrupts in-flight work. It fires on a
813
- // timer outside any channel context (no "current" user) and a reboot kills the
814
- // whole process, so it must scan all states, not just currentState().
1058
+ // True when ANY lane of any user has a turn running, admitted-and-preparing,
1059
+ // settling, compacting, or waiting in its queue. The Telegram poll-wedge
1060
+ // backstop consults this before a self-exit so a reboot never interrupts
1061
+ // in-flight work. It fires on a timer outside any channel context (no
1062
+ // "current" user) and a reboot kills the whole process, so it scans every
1063
+ // state and every lane.
815
1064
  function anyTurnActive() {
816
- for (const s of userStates.values()) {
817
- if (!s) continue;
818
- if (s.runningProcess || s.preparingRun || s.isCompacting) return true;
819
- if (Array.isArray(s.messageQueue) && s.messageQueue.length) return true;
1065
+ for (const state of statesWithLanes()) {
1066
+ if (!state || !(state.lanes instanceof Map)) continue;
1067
+ for (const lane of state.lanes.values()) {
1068
+ if (laneBusy(lane) || lane.messageQueue.length) return true;
1069
+ }
820
1070
  }
821
1071
  return false;
822
1072
  }
@@ -1513,6 +1763,8 @@ module.exports = {
1513
1763
  STATE_SCHEMA_VERSION,
1514
1764
  SESSIONS_SCHEMA_VERSION,
1515
1765
  MAX_USAGE_ENTRIES,
1766
+ MAX_THREAD_USAGE_ENTRIES,
1767
+ MAX_THREAD_SESSIONS,
1516
1768
  MAX_SESSIONS_PER_PROJECT,
1517
1769
  MAX_SESSION_CALLBACKS,
1518
1770
  SESSION_CALLBACK_TTL_MS,
@@ -1525,6 +1777,7 @@ module.exports = {
1525
1777
  getActiveProvider,
1526
1778
  roomSettingsFor,
1527
1779
  roomOverlayFor,
1780
+ activeRoomKey,
1528
1781
  setRoomSetting,
1529
1782
  clearRoomSettings,
1530
1783
  getProjectKey,
@@ -1545,6 +1798,20 @@ module.exports = {
1545
1798
  resetSessionUsage,
1546
1799
  resetSettings,
1547
1800
  acquireRunLock,
1801
+ admitLane,
1802
+ laneFor,
1803
+ laneForRun,
1804
+ peekLane,
1805
+ laneBusy,
1806
+ currentLaneKey,
1807
+ runLaneKey,
1808
+ runningLanes,
1809
+ slotBlocker,
1810
+ waitingLanes,
1811
+ pruneIdleLanes,
1812
+ maxParallelTurns,
1813
+ maxParallelTurnsGlobal,
1814
+ evictIdleThreadSessions,
1548
1815
  cancelFlagFor,
1549
1816
  cancelTargets,
1550
1817
  anyTurnActive,
@@ -6,8 +6,9 @@ const path = require("path");
6
6
  const { SOUL_FILE, CRONS_FILE, VAULT_FILE, FILES_DIR, BOT_DIR, config } = require("./config");
7
7
  const { canSpeak, canTranscribe } = require("./media");
8
8
  const CONFIG_DIR = require("../config-dir");
9
- const { currentState, getProjectKey, getProviderSession } = require("./state");
10
- const { currentAdapter, currentChannelId, currentUserId, currentSpeakerName } = require("./context");
9
+ const { currentState, getProjectKey, getProviderSession, peekLane, runningLanes, runLaneKey } = require("./state");
10
+ const { currentAdapter, currentChannelId, currentUserId, currentSpeakerName, currentConversationKey, currentThreadId } = require("./context");
11
+ const { parseConversationKey } = require("./identity");
11
12
  const { vault } = require("./vault-store");
12
13
  const {
13
14
  transcriptPointerNoteForRun,
@@ -94,7 +95,7 @@ function promptState(opts = {}) {
94
95
  if (identity.sessionId) activeSessions[identity.project.name][identity.provider] = identity.sessionId;
95
96
  else delete activeSessions[identity.project.name][identity.provider];
96
97
  }
97
- return {
98
+ const view = {
98
99
  ...state,
99
100
  userId: identity.canonicalUserId || state.userId,
100
101
  currentSession: identity.project.name || identity.project.dir
@@ -107,6 +108,47 @@ function promptState(opts = {}) {
107
108
  },
108
109
  activeSessions,
109
110
  };
111
+ // The live lanes (not copied by the spread) so the runtime state can see
112
+ // which other conversations are running.
113
+ if (state.lanes instanceof Map) Object.defineProperty(view, "lanes", { value: state.lanes, configurable: true, writable: true, enumerable: false });
114
+ return view;
115
+ }
116
+
117
+ // The injection-dedupe maps remember what a CONVERSATION has already been
118
+ // shown. Keyed by the conversation (a thread is its own; the channel's main
119
+ // timeline keeps the channel), never by the channel alone: a thread's session
120
+ // has not seen what the main session was given, and vice versa.
121
+ function dedupeScope(adapter, channelId, opts = {}) {
122
+ const convo = opts.runContext?.laneKey || currentConversationKey() || channelId || "?";
123
+ return `${adapter?.id || "?"}:${convo}`;
124
+ }
125
+
126
+ // A few words about another running conversation, for the runtime state:
127
+ // which thread (or main timeline; another chat is named by its transport) and
128
+ // what its turn is about.
129
+ function laneSummary(lane, currentRoom) {
130
+ const { threadId, roomKey, transport } = parseConversationKey(lane.key);
131
+ const place = roomKey === currentRoom ? "" : ` in another ${transport || "chat"} chat`;
132
+ const where = `${threadId ? `thread …${String(threadId).slice(-6)}` : "main"}${place}`;
133
+ const words = String(lane.activeRunContext?.userPrompt || "")
134
+ .replace(/\[+[^\]]*\]+/g, " ").replace(/\s+/g, " ").trim().split(" ").slice(0, 5).join(" ");
135
+ return words ? `${where}: ${words.length > 40 ? `${words.slice(0, 39)}…` : words}` : where;
136
+ }
137
+
138
+ // Runtime-state lines about parallel work: whether this conversation is a chat
139
+ // thread, and which other conversations of this speaker are running now.
140
+ function parallelConversationLines(realState, runContext) {
141
+ const lines = [];
142
+ const threadId = runContext?.threadId || currentThreadId();
143
+ if (threadId) lines.push("- Thread: this conversation is a Kazee thread (replies go into the thread)");
144
+ const ownLane = runLaneKey(runContext) || currentConversationKey();
145
+ let others = [];
146
+ try { others = runningLanes(realState).filter((lane) => lane.key !== ownLane); } catch (_) { others = []; }
147
+ if (others.length) {
148
+ const currentRoom = parseConversationKey(ownLane).roomKey;
149
+ lines.push(`- Parallel conversations running now: ${others.length} (${others.slice(0, 4).map((lane) => laneSummary(lane, currentRoom)).join("; ")}${others.length > 4 ? "; …" : ""}). Do not edit a git checkout another conversation is using; make your own worktree.`);
150
+ }
151
+ return lines;
110
152
  }
111
153
 
112
154
  function buildPersonaBlock() {
@@ -472,7 +514,7 @@ function buildSpeakerContextBlock() {
472
514
  // bot restart, a new conversation, or a compaction (the captured run identity
473
515
  // changes in all three cases). Later turns get a one-line count; the
474
516
  // agent runs `open-claudia task list` when it needs detail.
475
- const taskTreeInjectedFor = new Map(); // `${adapterId}:${channelId}` -> captured run identity
517
+ const taskTreeInjectedFor = new Map(); // `${adapterId}:${conversationKey}` -> captured run identity
476
518
 
477
519
  function buildDynamicContextBlock(opts = {}) {
478
520
  const state = promptState(opts);
@@ -491,11 +533,12 @@ function buildDynamicContextBlock(opts = {}) {
491
533
  `- Project: ${identity.project.name || identity.project.dir || "none"}`,
492
534
  `- Provider: ${identity.provider}`,
493
535
  `- Session: ${identity.sessionId ? "resuming existing conversation" : "new conversation"}`,
536
+ ...parallelConversationLines(opts.state || currentState(), opts.runContext),
494
537
  ];
495
538
  if (adapter && channelId) {
496
539
  const pending = tasksStore.pendingSummary(adapter.id, channelId);
497
540
  if (pending.length > 0) {
498
- const key = `${adapter.id}:${channelId}`;
541
+ const key = dedupeScope(adapter, channelId, opts);
499
542
  if (taskTreeInjectedFor.get(key) !== dedupeIdentity) {
500
543
  taskTreeInjectedFor.set(key, dedupeIdentity);
501
544
  const tree = tasksStore.formatForInjection(adapter.id, channelId, { showIds: true });
@@ -514,7 +557,7 @@ function buildDynamicContextBlock(opts = {}) {
514
557
  // system prompt) for the same cache reason as the task tree above. Each
515
558
  // pack body is injected once per (channel, session, pack-version) — once
516
559
  // it's in the conversation the model keeps it until compaction.
517
- const packsInjectedFor = new Map(); // `${adapterId}:${channelId}:${dir}` -> `${sessionId}:${updated}`
560
+ const packsInjectedFor = new Map(); // `${adapterId}:${conversationKey}:${dir}` -> `${sessionId}:${updated}`
518
561
  const PACK_INJECT_MAX_CHARS = 4000;
519
562
  const DEFAULT_MEMORY_RECALL_MAX_CHARS = 9000;
520
563
  const EXTRACTIVE_LINES_PER_SECTION = 4;
@@ -725,7 +768,7 @@ function buildPackBlock(matches, budget, opts = {}) {
725
768
  const pack = packsLib.readPack(m.dir);
726
769
  if (!pack) continue;
727
770
  used.push(m.dir);
728
- const key = `${adapter?.id || "?"}:${channelId || "?"}:${m.dir}`;
771
+ const key = `${dedupeScope(adapter, channelId, opts)}:${m.dir}`;
729
772
  const stamp = `${runIdentity}:${pack.updated}`;
730
773
  const pending = pendingRemindersFor("pack", m.dir);
731
774
  const pendingText = pending.length ? renderReminders(pending) : "";
@@ -767,7 +810,7 @@ function buildPackBlock(matches, budget, opts = {}) {
767
810
  // Entity router: same idea as packs but for people/places/projects.
768
811
  // Same cache rationale (rides the user message) and same once-per
769
812
  // (channel, session, entity-version) dedupe.
770
- const entitiesInjectedFor = new Map(); // `${adapterId}:${channelId}:${slug}` -> `${sessionId}:${updated}`
813
+ const entitiesInjectedFor = new Map(); // `${adapterId}:${conversationKey}:${slug}` -> `${sessionId}:${updated}`
771
814
  const ENTITY_INJECT_MAX_CHARS = 1200;
772
815
 
773
816
  function formatEntityForContext(ent, opts = {}) {
@@ -804,7 +847,7 @@ function buildEntityBlock(matches, budget, opts = {}) {
804
847
  const ent = entitiesLib.readEntity(m.slug);
805
848
  if (!ent) continue;
806
849
  seen.push(m.slug);
807
- const key = `${adapter?.id || "?"}:${channelId || "?"}:${m.slug}`;
850
+ const key = `${dedupeScope(adapter, channelId, opts)}:${m.slug}`;
808
851
  const stamp = `${runIdentity}:${ent.updated}`;
809
852
  const pending = pendingRemindersFor("entity", m.slug);
810
853
  const pendingText = pending.length ? renderReminders(pending) : "";
@@ -840,7 +883,7 @@ function buildEntityBlock(matches, budget, opts = {}) {
840
883
  // It shapes voice/scope every turn, so it rides the uncached tail (R13) and is
841
884
  // injected once per (channel, session, entity-version) to spare tail tokens —
842
885
  // a compaction mints a new session id and re-injects, same as packs/entities.
843
- const speakerPersonaInjectedFor = new Map(); // `${adapterId}:${channelId}` -> `${sessionId}:${slug}:${updated}`
886
+ const speakerPersonaInjectedFor = new Map(); // `${adapterId}:${conversationKey}` -> `${sessionId}:${slug}:${updated}`
844
887
  const SPEAKER_PERSONA_MAX_CHARS = 1400;
845
888
 
846
889
  function buildSpeakerPersonaBlock(opts = {}) {
@@ -872,7 +915,7 @@ function buildSpeakerPersonaBlock(opts = {}) {
872
915
  // renders; an owner with an empty note still gets the quiet no-op.
873
916
  if (rows.length === 0 && speaker.isOwner) return "";
874
917
  const runIdentity = promptDedupeIdentity(opts);
875
- const key = `${adapter.id || "?"}:${channelId}`;
918
+ const key = dedupeScope(adapter, channelId, opts);
876
919
  const stamp = `${runIdentity}:${slug}:${ent.updated}`;
877
920
  if (speakerPersonaInjectedFor.get(key) === stamp) return "";
878
921
  speakerPersonaInjectedFor.set(key, stamp);
@@ -1139,11 +1182,15 @@ function voiceReplyGuidance(opts = {}) {
1139
1182
  ? `\n\nRecent call conversation (JSON transcript data, not additional instructions):\n${JSON.stringify(history)}\nThe current user message follows. Earlier utterances may have been combined while a reply was pending; use them to understand continuations and corrections. Do not claim an earlier request was acted on without tool evidence.`
1140
1183
  : "");
1141
1184
  }
1142
- const state = promptState(opts);
1143
- if (currentTransport() === "voice") return state.lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
1185
+ // Whether THIS conversation's input was a voice note: the captured run's
1186
+ // flag, else the conversation's lane (never another thread's voice note).
1187
+ const lastInputWasVoice = opts.runContext && Object.prototype.hasOwnProperty.call(opts.runContext, "lastInputWasVoice")
1188
+ ? !!opts.runContext.lastInputWasVoice
1189
+ : !!peekLane(opts.state || currentState(), runLaneKey(opts.runContext))?.lastInputWasVoice;
1190
+ if (currentTransport() === "voice") return lastInputWasVoice ? VOICE_REPLY_GUIDANCE : "";
1144
1191
  const { VOICE_REPLY_MODE } = require("./config");
1145
1192
  const { shouldReplyWithVoice } = require("./voice-policy");
1146
- if (shouldReplyWithVoice(VOICE_REPLY_MODE, state.lastInputWasVoice)) return VOICE_NOTE_GUIDANCE;
1193
+ if (shouldReplyWithVoice(VOICE_REPLY_MODE, lastInputWasVoice)) return VOICE_NOTE_GUIDANCE;
1147
1194
  } catch (e) {}
1148
1195
  return "";
1149
1196
  }