@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/scheduler.js
CHANGED
|
@@ -32,8 +32,10 @@ const {
|
|
|
32
32
|
getProviderSession,
|
|
33
33
|
getProviderSettings,
|
|
34
34
|
getUserState,
|
|
35
|
+
peekLane,
|
|
36
|
+
laneBusy,
|
|
35
37
|
} = require("./state");
|
|
36
|
-
const { canonicalForChannel, conversationKeyFor } = require("./identity");
|
|
38
|
+
const { canonicalForChannel, conversationKeyFor, parseConversationKey } = require("./identity");
|
|
37
39
|
const { send: defaultSend } = require("./io");
|
|
38
40
|
const { providerStatus: defaultProviderStatus } = require("./providers");
|
|
39
41
|
const { redactSensitive } = require("./redact");
|
|
@@ -74,15 +76,17 @@ function uniqueId(prefix = "occ") {
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
// ── watch-check environment ────────────────────────────────────────────────
|
|
77
|
-
// A watch's `check` runs a shell command
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
79
|
+
// A watch's `check` runs a shell command in the bot's process tree. Whose
|
|
80
|
+
// environment it gets depends on who set the watch:
|
|
81
|
+
// • a staff member's check gets an ALLOWLIST — the shell essentials and the
|
|
82
|
+
// Open Claudia config dir, nothing else — plus OC_GUARDED_RUN=1 so hooks
|
|
83
|
+
// and CLIs below it know they run for a non-owner. A denylist was not
|
|
84
|
+
// enough: any credential whose name matched no pattern (DATABASE_URL,
|
|
85
|
+
// MONGO_URI, …) reached a staff check (Phase 0 review, MEDIUM);
|
|
86
|
+
// • the owner's check keeps the bot's environment as it always had, so an
|
|
87
|
+
// owner watch that reads a credential from env keeps working.
|
|
88
|
+
// Either way a short-lived, creator-scoped run is stamped on top (below) so
|
|
89
|
+
// the check's reusable tools attribute to whoever created the watch.
|
|
86
90
|
const WATCH_CHECK_ENV_KEEP = new Set([
|
|
87
91
|
"PATH", "HOME", "USER", "LOGNAME", "SHELL",
|
|
88
92
|
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ",
|
|
@@ -91,34 +95,29 @@ const WATCH_CHECK_ENV_KEEP = new Set([
|
|
|
91
95
|
"OPEN_CLAUDIA_CONFIG_DIR",
|
|
92
96
|
"SystemRoot", "ComSpec", "PATHEXT", "WINDIR",
|
|
93
97
|
]);
|
|
94
|
-
|
|
95
|
-
let KNOWN_SECRET_ENV_KEYS = null;
|
|
96
|
-
function knownSecretEnvKeys() {
|
|
97
|
-
if (KNOWN_SECRET_ENV_KEYS) return KNOWN_SECRET_ENV_KEYS;
|
|
98
|
-
const set = new Set();
|
|
99
|
-
try { for (const key of require("./providers/env").ALWAYS_STRIPPED_KEYS) set.add(key); } catch (_) {}
|
|
100
|
-
KNOWN_SECRET_ENV_KEYS = set;
|
|
101
|
-
return set;
|
|
102
|
-
}
|
|
103
|
-
function isSecretCheckEnvKey(key) {
|
|
104
|
-
if (WATCH_CHECK_ENV_KEEP.has(key)) return false;
|
|
105
|
-
if (knownSecretEnvKeys().has(key)) return true;
|
|
106
|
-
return WATCH_CHECK_SECRET_PATTERN.test(key);
|
|
107
|
-
}
|
|
108
|
-
function buildWatchCheckEnv(source, stamp = {}) {
|
|
98
|
+
function buildWatchCheckEnv(source, stamp = {}, { owner = false } = {}) {
|
|
109
99
|
const env = {};
|
|
110
100
|
for (const [key, value] of Object.entries(source || {})) {
|
|
111
101
|
if (value == null) continue;
|
|
112
|
-
if (
|
|
102
|
+
if (!owner && !WATCH_CHECK_ENV_KEEP.has(key)) continue;
|
|
113
103
|
env[key] = String(value);
|
|
114
104
|
}
|
|
115
105
|
for (const [key, value] of Object.entries(stamp)) {
|
|
116
106
|
if (value == null || value === "") continue;
|
|
117
107
|
env[key] = String(value);
|
|
118
108
|
}
|
|
109
|
+
if (owner) delete env.OC_GUARDED_RUN;
|
|
110
|
+
else env.OC_GUARDED_RUN = "1";
|
|
119
111
|
return env;
|
|
120
112
|
}
|
|
121
113
|
|
|
114
|
+
// The chat thread a job answers in: stored on the job when it was created in a
|
|
115
|
+
// thread, else read off its conversation key (older jobs, CLI-made jobs).
|
|
116
|
+
function jobThreadId(job) {
|
|
117
|
+
if (!job) return null;
|
|
118
|
+
return job.threadId || parseConversationKey(job.project).threadId || null;
|
|
119
|
+
}
|
|
120
|
+
|
|
122
121
|
// The principal a watch-check's short-lived run acts for: the job's creator,
|
|
123
122
|
// resolved from its stored channel/user the same way runPrincipal resolves a
|
|
124
123
|
// turn's speaker. Owner → the check attributes as the owner (unchanged); a
|
|
@@ -146,6 +145,7 @@ function watchCheckPrincipal(job) {
|
|
|
146
145
|
adapterId: job && job.adapter ? String(job.adapter) : null,
|
|
147
146
|
adapterType,
|
|
148
147
|
channelId,
|
|
148
|
+
threadId: jobThreadId(job),
|
|
149
149
|
userId,
|
|
150
150
|
room,
|
|
151
151
|
};
|
|
@@ -248,7 +248,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
248
248
|
}
|
|
249
249
|
// Poll a watch's `check`: exit 0 → the watched condition is met, wake the
|
|
250
250
|
// agent; non-zero → still idle, skip silently. Runs in the job's project
|
|
251
|
-
// dir with
|
|
251
|
+
// dir with its creator's env (buildWatchCheckEnv: an allowlist for staff)
|
|
252
252
|
// and a short-lived run is registered for the job's creator so reusable
|
|
253
253
|
// tools attribute correctly — output discarded (exit code is the contract),
|
|
254
254
|
// hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
|
|
@@ -256,13 +256,14 @@ function createSchedulerService(dependencies = {}) {
|
|
|
256
256
|
const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
|
|
257
257
|
const attest = require("./speaker-attest");
|
|
258
258
|
const checkRunId = uniqueId("watch-check");
|
|
259
|
+
const principal = watchCheckPrincipal(job);
|
|
259
260
|
let runAttest = "";
|
|
260
|
-
try { runAttest = attest.registerRun(checkRunId,
|
|
261
|
+
try { runAttest = attest.registerRun(checkRunId, principal); } catch (_) { runAttest = ""; }
|
|
261
262
|
let lb = null;
|
|
262
263
|
try { lb = require("./loopback").info(); } catch (_) { lb = null; }
|
|
263
264
|
const stamp = { OC_RUN_ID: checkRunId, OC_RUN_ATTEST: runAttest };
|
|
264
265
|
if (lb && lb.url) { stamp.OC_SEND_URL = lb.url; stamp.OC_SEND_TOKEN = lb.token; }
|
|
265
|
-
const env = buildWatchCheckEnv(process.env, stamp);
|
|
266
|
+
const env = buildWatchCheckEnv(process.env, stamp, { owner: principal.owner === true });
|
|
266
267
|
let settled = false;
|
|
267
268
|
const finish = (code, timedOut) => {
|
|
268
269
|
if (settled) return;
|
|
@@ -308,8 +309,26 @@ function createSchedulerService(dependencies = {}) {
|
|
|
308
309
|
return { name, dir: dir || workspace || process.cwd() };
|
|
309
310
|
}
|
|
310
311
|
|
|
312
|
+
// The session of the main timeline of the room a thread job belongs to.
|
|
313
|
+
function roomMainSession(state, job) {
|
|
314
|
+
if (!jobThreadId(job)) return null;
|
|
315
|
+
const threadRoom = parseConversationKey(job.project).threadId ? parseConversationKey(job.project).roomKey : null;
|
|
316
|
+
const roomKey = threadRoom || (job.adapterType && job.channelId ? conversationKeyFor(job.adapterType, job.channelId) : null);
|
|
317
|
+
if (!roomKey || roomKey === job.project) return null;
|
|
318
|
+
return getProviderSession(state, job.provider, roomKey) || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
311
321
|
function resolveCompactionLineage(state, job) {
|
|
312
322
|
let sessionId = job.sessionId || null;
|
|
323
|
+
// A thread job never resumes its room's MAIN session: a job made in a
|
|
324
|
+
// thread's first (forked) turn by an older build stored the main id, and
|
|
325
|
+
// resuming it would write the thread's wakeup into the main conversation,
|
|
326
|
+
// possibly beside a live main turn. Use the thread's own session instead.
|
|
327
|
+
const mainSession = sessionId ? roomMainSession(state, job) : null;
|
|
328
|
+
if (mainSession && sessionId === mainSession) {
|
|
329
|
+
const own = getProviderSession(state, job.provider, job.project);
|
|
330
|
+
sessionId = own && own !== mainSession ? own : null;
|
|
331
|
+
}
|
|
313
332
|
if (!sessionId) return { sessionId: null, attachSession: true };
|
|
314
333
|
const seen = new Set();
|
|
315
334
|
while (sessionId && !seen.has(sessionId)) {
|
|
@@ -716,6 +735,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
716
735
|
// shared session. Matches captured project.name (projectDescriptor(job).name)
|
|
717
736
|
// so sameCapturedProject / getProjectKey resolve the right thread mid-run.
|
|
718
737
|
conversationKey: projectDescriptor(job).name,
|
|
738
|
+
// A job made in a chat thread answers in that thread: its notice, its
|
|
739
|
+
// typing indicator and its reply all go back into the thread.
|
|
740
|
+
threadId: jobThreadId(job),
|
|
719
741
|
// …but the thread is not the room. A job belongs to the room it was
|
|
720
742
|
// created in and must answer under THAT room's provider; without this the
|
|
721
743
|
// room overlay can't resolve and the run silently falls back to the
|
|
@@ -730,7 +752,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
730
752
|
return runInChat(chat, async () => {
|
|
731
753
|
noteLegacyCreatorOnce(job);
|
|
732
754
|
const state = stateForUser(canonicalUserId);
|
|
733
|
-
|
|
755
|
+
// Busy means THIS job's conversation is running, compacting or has
|
|
756
|
+
// messages waiting; other conversations of the speaker may run meanwhile.
|
|
757
|
+
// The conversation it replies INTO counts too: a job whose project is not
|
|
758
|
+
// its chat's key (--project myrepo, a legacy basename) must not answer in
|
|
759
|
+
// a thread or timeline beside a live turn there.
|
|
760
|
+
const lane = peekLane(state, projectDescriptor(job).name);
|
|
761
|
+
const replyLane = peekLane(state, conversationKeyFor(adapter.type, job.channelId, jobThreadId(job)));
|
|
762
|
+
const occupied = (l) => laneBusy(l) || !!l?.messageQueue?.length;
|
|
763
|
+
if (occupied(lane) || occupied(replyLane)) {
|
|
734
764
|
try { return markBusy(job); }
|
|
735
765
|
catch (error) { return keepRetrying(job, error, busyDelayMs); }
|
|
736
766
|
}
|
|
@@ -954,7 +984,9 @@ function createSchedulerService(dependencies = {}) {
|
|
|
954
984
|
const sessionId = Object.prototype.hasOwnProperty.call(spec, "sessionId")
|
|
955
985
|
? spec.sessionId
|
|
956
986
|
: getProviderSession(state, provider, project);
|
|
957
|
-
|
|
987
|
+
// Fall back to the run of the job's own conversation only: another
|
|
988
|
+
// thread's running turn is not where this job came from.
|
|
989
|
+
const originRunId = spec.originRunId || (project ? peekLane(state, project)?.activeRunContext?.runId : null) || null;
|
|
958
990
|
const providerSettings = spec.providerSettings || getProviderSettings(state, provider);
|
|
959
991
|
return {
|
|
960
992
|
...spec,
|
|
@@ -1080,8 +1112,8 @@ module.exports = {
|
|
|
1080
1112
|
parseFallbacks,
|
|
1081
1113
|
terminalRunSucceeded,
|
|
1082
1114
|
buildWatchCheckEnv,
|
|
1083
|
-
isSecretCheckEnvKey,
|
|
1084
1115
|
watchCheckPrincipal,
|
|
1116
|
+
jobThreadId,
|
|
1085
1117
|
setAdapters,
|
|
1086
1118
|
initScheduler,
|
|
1087
1119
|
scheduleJob: service.scheduleJob,
|
package/core/side-chat.js
CHANGED
|
@@ -143,6 +143,23 @@ async function defaultDeliver(payload, store) {
|
|
|
143
143
|
return execute();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Side chats are per (speaker, conversation): a speaker's threads each get
|
|
147
|
+
// their own side answer, and /stop in one conversation cancels only its own.
|
|
148
|
+
// A bare canonical id (no lane) addresses every conversation of the speaker.
|
|
149
|
+
const LANE_SEP = "\u0000";
|
|
150
|
+
function sideChatKey(canonicalUserId, laneKey = null) {
|
|
151
|
+
return laneKey ? `${canonicalUserId}${LANE_SEP}${laneKey}` : String(canonicalUserId);
|
|
152
|
+
}
|
|
153
|
+
function keyMatches(key, canonicalUserId, laneKey = null) {
|
|
154
|
+
if (laneKey) return key === sideChatKey(canonicalUserId, laneKey);
|
|
155
|
+
const user = String(canonicalUserId);
|
|
156
|
+
return key === user || key.startsWith(`${user}${LANE_SEP}`);
|
|
157
|
+
}
|
|
158
|
+
function requestLaneKey(request) {
|
|
159
|
+
const runContext = request?.originalRunContext || request?.runContext || null;
|
|
160
|
+
return runContext?.laneKey || runContext?.project?.name || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
146
163
|
class SideChatCoordinator {
|
|
147
164
|
constructor(options = {}) {
|
|
148
165
|
this.runSideChat = options.runSideChat || ((request) => require("./runner").runSideChatRequest(request));
|
|
@@ -163,28 +180,39 @@ class SideChatCoordinator {
|
|
|
163
180
|
return this.modeReader() === "agent";
|
|
164
181
|
}
|
|
165
182
|
|
|
166
|
-
isActive(canonicalUserId) {
|
|
167
|
-
return this.activeByUser.
|
|
183
|
+
isActive(canonicalUserId, laneKey = null) {
|
|
184
|
+
return [...this.activeByUser.keys()].some((key) => keyMatches(key, canonicalUserId, laneKey));
|
|
168
185
|
}
|
|
169
186
|
|
|
170
|
-
attachProcess(canonicalUserId, proc) {
|
|
187
|
+
attachProcess(canonicalUserId, proc, laneKey = null) {
|
|
171
188
|
if (!proc) return;
|
|
172
189
|
let resolveDone;
|
|
173
190
|
const done = new Promise((resolve) => { resolveDone = resolve; });
|
|
174
191
|
const settle = () => resolveDone();
|
|
175
192
|
proc.once("close", settle);
|
|
176
193
|
proc.once("error", settle);
|
|
177
|
-
this.processByUser.set(
|
|
194
|
+
this.processByUser.set(sideChatKey(canonicalUserId, laneKey), { proc, done });
|
|
178
195
|
}
|
|
179
196
|
|
|
180
|
-
detachProcess(canonicalUserId, proc) {
|
|
181
|
-
const key =
|
|
197
|
+
detachProcess(canonicalUserId, proc, laneKey = null) {
|
|
198
|
+
const key = sideChatKey(canonicalUserId, laneKey);
|
|
182
199
|
const entry = this.processByUser.get(key);
|
|
183
200
|
if (!proc || entry?.proc === proc) this.processByUser.delete(key);
|
|
184
201
|
}
|
|
185
202
|
|
|
186
|
-
|
|
187
|
-
|
|
203
|
+
// Cancel the side chat of one conversation (laneKey), or of every
|
|
204
|
+
// conversation of the speaker when no lane is named.
|
|
205
|
+
async cancel(canonicalUserId, laneKey = null) {
|
|
206
|
+
if (!laneKey) {
|
|
207
|
+
const keys = new Set([...this.activeByUser.keys(), ...this.processByUser.keys()]
|
|
208
|
+
.filter((key) => keyMatches(key, canonicalUserId)));
|
|
209
|
+
const outcomes = await Promise.all([...keys].map((key) => this.cancelKey(key)));
|
|
210
|
+
return outcomes.some(Boolean);
|
|
211
|
+
}
|
|
212
|
+
return this.cancelKey(sideChatKey(canonicalUserId, laneKey));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async cancelKey(key) {
|
|
188
216
|
const active = this.activeByUser.get(key);
|
|
189
217
|
if (active) this.cancelledRequests.add(active.token);
|
|
190
218
|
const entry = this.processByUser.get(key);
|
|
@@ -208,8 +236,8 @@ class SideChatCoordinator {
|
|
|
208
236
|
}
|
|
209
237
|
|
|
210
238
|
async cancelAll() {
|
|
211
|
-
const
|
|
212
|
-
const outcomes = await Promise.all([...
|
|
239
|
+
const keys = new Set([...this.activeByUser.keys(), ...this.processByUser.keys()]);
|
|
240
|
+
const outcomes = await Promise.all([...keys].map((key) => this.cancelKey(key)));
|
|
213
241
|
return outcomes.filter(Boolean).length;
|
|
214
242
|
}
|
|
215
243
|
|
|
@@ -258,7 +286,7 @@ class SideChatCoordinator {
|
|
|
258
286
|
|
|
259
287
|
async respond(input = {}) {
|
|
260
288
|
const request = this.makePending(input);
|
|
261
|
-
const userKey =
|
|
289
|
+
const userKey = sideChatKey(request.canonicalUserId, requestLaneKey(request));
|
|
262
290
|
if (this.activeByUser.has(userKey)) {
|
|
263
291
|
try {
|
|
264
292
|
const delivery = await this.deliver({
|
|
@@ -343,4 +371,5 @@ module.exports = {
|
|
|
343
371
|
createSideChatRequest,
|
|
344
372
|
readRuntimeMode,
|
|
345
373
|
sideChatCoordinator,
|
|
374
|
+
sideChatKey,
|
|
346
375
|
};
|
package/core/speaker-attest.js
CHANGED
|
@@ -88,6 +88,8 @@ function principalOf(principal = {}) {
|
|
|
88
88
|
adapterId: text(principal.adapterId),
|
|
89
89
|
adapterType: text(principal.adapterType),
|
|
90
90
|
channelId: text(principal.channelId),
|
|
91
|
+
// The chat thread the run answers in (null: the channel's main timeline).
|
|
92
|
+
threadId: text(principal.threadId),
|
|
91
93
|
userId: text(principal.userId),
|
|
92
94
|
room: text(principal.room),
|
|
93
95
|
purpose: text(principal.purpose),
|