@inetafrica/open-claudia 3.3.2 → 3.4.1
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 +18 -0
- package/channels/kazee/adapter.js +1 -0
- package/channels/spaces/adapter.js +11 -2
- package/channels/telegram/adapter.js +28 -9
- package/core/access.js +125 -80
- package/core/actions.js +42 -0
- package/core/approvals.js +6 -0
- package/core/config.js +2 -1
- package/core/context.js +26 -2
- package/core/enforcer.js +85 -3
- package/core/fsutil.js +42 -1
- package/core/grants.js +221 -0
- package/core/handlers.js +49 -0
- package/core/intro-flow.js +17 -16
- package/core/mandates.js +10 -1
- package/core/people.js +157 -134
- package/core/providers/claude.js +3 -0
- package/core/relationship.js +45 -4
- package/core/router.js +1 -0
- package/core/scheduler.js +23 -2
- package/core/state.js +77 -3
- package/package.json +2 -2
- package/setup.js +2 -5
- package/test-provider-claude.js +1 -0
- package/test-provider-gateway.js +1 -0
- package/test-provider-language.js +25 -1
- package/web.js +5 -2
package/core/people.js
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
// on first read from the existing auth.json (the bootstrap owner) and
|
|
9
9
|
// any KAZEE_OWNER_USER_ID in env.
|
|
10
10
|
|
|
11
|
-
const fs = require("fs");
|
|
12
11
|
const { PEOPLE_FILE, AUTH_FILE, config } = require("./config");
|
|
13
12
|
const { channelKey, setIdentityMapping, canonicalForChannel } = require("./identity");
|
|
14
13
|
const { slugify } = require("./entities");
|
|
14
|
+
const { atomicWriteFileSync, readJsonWithFallback, withFileLock } = require("./fsutil");
|
|
15
15
|
|
|
16
16
|
function nowIso() { return new Date().toISOString(); }
|
|
17
17
|
|
|
@@ -19,18 +19,31 @@ function nextId() {
|
|
|
19
19
|
return `person_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// An empty roster is a real state (fresh pod), but it is also what a corrupt
|
|
23
|
+
// read used to return — and the two are not interchangeable. Nobody in the
|
|
24
|
+
// roster means relationship.speakerFor finds no person and classifies every
|
|
25
|
+
// speaker "external", so a single torn write silently demoted the OWNER and
|
|
26
|
+
// started guarding them in their own pod. Recovering from `<file>.bak` makes
|
|
27
|
+
// that self-healing rather than silent.
|
|
22
28
|
function load() {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if (!Array.isArray(raw)) return [];
|
|
26
|
-
return raw;
|
|
27
|
-
} catch (e) { return []; }
|
|
29
|
+
const raw = readJsonWithFallback(PEOPLE_FILE, []);
|
|
30
|
+
return Array.isArray(raw) ? raw : [];
|
|
28
31
|
}
|
|
29
32
|
|
|
30
33
|
function save(list) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
atomicWriteFileSync(PEOPLE_FILE, `${JSON.stringify(list, null, 2)}\n`, { backup: true });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Read-modify-write under the store lock. Every mutation goes through this so
|
|
38
|
+
// the bot process and the CLI cannot each append to the same list and have one
|
|
39
|
+
// of the two renames win outright.
|
|
40
|
+
function mutate(fn) {
|
|
41
|
+
return withFileLock(PEOPLE_FILE, () => {
|
|
42
|
+
const list = load();
|
|
43
|
+
const result = fn(list);
|
|
44
|
+
save(list);
|
|
45
|
+
return result;
|
|
46
|
+
});
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
function findById(id) {
|
|
@@ -58,105 +71,115 @@ function findByHandle(adapter, channelId) {
|
|
|
58
71
|
function list() { return load(); }
|
|
59
72
|
|
|
60
73
|
function add({ name, isOwner = false, bio = null }) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
return mutate((all) => {
|
|
75
|
+
const p = {
|
|
76
|
+
id: nextId(),
|
|
77
|
+
name: String(name || "").trim(),
|
|
78
|
+
isOwner: !!isOwner,
|
|
79
|
+
bio: bio ? String(bio) : null,
|
|
80
|
+
handles: [],
|
|
81
|
+
notes: [],
|
|
82
|
+
primaryChannel: null,
|
|
83
|
+
entitySlug: null,
|
|
84
|
+
createdAt: nowIso(),
|
|
85
|
+
updatedAt: nowIso(),
|
|
86
|
+
};
|
|
87
|
+
if (!p.name) throw new Error("person name is required");
|
|
88
|
+
all.push(p);
|
|
89
|
+
return p;
|
|
90
|
+
});
|
|
78
91
|
}
|
|
79
92
|
|
|
80
93
|
function update(id, patch) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
return mutate((all) => {
|
|
95
|
+
const idx = all.findIndex((p) => p.id === id);
|
|
96
|
+
if (idx < 0) return null;
|
|
97
|
+
all[idx] = { ...all[idx], ...patch, updatedAt: nowIso() };
|
|
98
|
+
return all[idx];
|
|
99
|
+
});
|
|
87
100
|
}
|
|
88
101
|
|
|
89
102
|
function remove(id) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
103
|
+
return mutate((all) => {
|
|
104
|
+
const idx = all.findIndex((p) => p.id === id);
|
|
105
|
+
if (idx < 0) return null;
|
|
106
|
+
const [removed] = all.splice(idx, 1);
|
|
107
|
+
return removed;
|
|
108
|
+
});
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
// Attach a channel handle to a person. Also writes the channel→canonical
|
|
99
112
|
// link in identities.json so the rest of the bot resolves the chat to
|
|
100
113
|
// the same canonical user id as any other handle on this person.
|
|
101
114
|
function linkHandle(personId, { adapter, channelId, displayName = null, approvedBy = null }) {
|
|
102
|
-
const all = load();
|
|
103
|
-
const p = all.find((x) => x.id === personId);
|
|
104
|
-
if (!p) throw new Error(`person not found: ${personId}`);
|
|
105
115
|
const a = String(adapter || "").toLowerCase();
|
|
106
116
|
const c = String(channelId || "");
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
const p = mutate((all) => {
|
|
118
|
+
const person = all.find((x) => x.id === personId);
|
|
119
|
+
if (!person) throw new Error(`person not found: ${personId}`);
|
|
120
|
+
if (!a || !c) throw new Error("adapter and channelId are required");
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(person.handles)) person.handles = [];
|
|
123
|
+
const conflict = all.find((other) =>
|
|
124
|
+
other.id !== person.id &&
|
|
125
|
+
(other.handles || []).some((h) => h.adapter === a && String(h.channelId) === c));
|
|
126
|
+
if (conflict) throw new Error(`handle ${a}:${c} already linked to ${conflict.name} (${conflict.id})`);
|
|
127
|
+
|
|
128
|
+
const canonical = canonicalForChannel(a, c);
|
|
129
|
+
const existing = person.handles.find((h) => h.adapter === a && String(h.channelId) === c);
|
|
130
|
+
if (!existing) {
|
|
131
|
+
person.handles.push({
|
|
132
|
+
adapter: a,
|
|
133
|
+
channelId: c,
|
|
134
|
+
canonicalUserId: canonical,
|
|
135
|
+
displayName,
|
|
136
|
+
approvedBy,
|
|
137
|
+
addedAt: nowIso(),
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
existing.canonicalUserId = canonical;
|
|
141
|
+
existing.displayName = displayName || existing.displayName;
|
|
142
|
+
}
|
|
130
143
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
144
|
+
if (!person.primaryChannel) person.primaryChannel = { adapter: a, channelId: c };
|
|
145
|
+
person.updatedAt = nowIso();
|
|
146
|
+
return person;
|
|
147
|
+
});
|
|
134
148
|
|
|
135
149
|
// Mirror into identities.json so canonicalForChannel resolves cross-handle.
|
|
150
|
+
// Re-pointing a channel at a new canonical strands everything that channel
|
|
151
|
+
// already accumulated — settings, sessions, usage — under the old key, which
|
|
152
|
+
// is how the owner ended up with orphan state buckets. state.linkIdentity is
|
|
153
|
+
// setIdentityMapping plus that migration; required lazily because state.js
|
|
154
|
+
// pulls people.js in on the identity path.
|
|
136
155
|
if (p.handles.length > 1) {
|
|
137
156
|
const primary = p.handles[0];
|
|
138
157
|
const primaryCanonical = primary.canonicalUserId || canonicalForChannel(primary.adapter, primary.channelId);
|
|
139
|
-
try {
|
|
158
|
+
try {
|
|
159
|
+
require("./state").linkIdentity(a, c, primaryCanonical);
|
|
160
|
+
} catch (e) {
|
|
161
|
+
try { setIdentityMapping(a, c, primaryCanonical); } catch (e2) {}
|
|
162
|
+
}
|
|
140
163
|
}
|
|
141
164
|
|
|
142
165
|
return p;
|
|
143
166
|
}
|
|
144
167
|
|
|
145
168
|
function unlinkHandle(personId, { adapter, channelId }) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
return mutate((all) => {
|
|
170
|
+
const p = all.find((x) => x.id === personId);
|
|
171
|
+
if (!p) return null;
|
|
172
|
+
const a = String(adapter || "").toLowerCase();
|
|
173
|
+
const c = String(channelId || "");
|
|
174
|
+
const before = (p.handles || []).length;
|
|
175
|
+
p.handles = (p.handles || []).filter((h) => !(h.adapter === a && String(h.channelId) === c));
|
|
176
|
+
if (p.primaryChannel && p.primaryChannel.adapter === a && String(p.primaryChannel.channelId) === c) {
|
|
177
|
+
p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
|
|
178
|
+
}
|
|
179
|
+
if (p.handles.length === before) return null;
|
|
180
|
+
p.updatedAt = nowIso();
|
|
181
|
+
return p;
|
|
182
|
+
});
|
|
160
183
|
}
|
|
161
184
|
|
|
162
185
|
// Remove every handle for a given adapter across ALL people records — used when
|
|
@@ -166,71 +189,71 @@ function unlinkHandle(personId, { adapter, channelId }) {
|
|
|
166
189
|
function unlinkAdapterHandles(adapter) {
|
|
167
190
|
const a = String(adapter || "").toLowerCase();
|
|
168
191
|
if (!a) return [];
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
192
|
+
return mutate((all) => {
|
|
193
|
+
const affected = [];
|
|
194
|
+
for (const p of all) {
|
|
195
|
+
const before = (p.handles || []).length;
|
|
196
|
+
if (!before) continue;
|
|
197
|
+
p.handles = p.handles.filter((h) => h.adapter !== a);
|
|
198
|
+
if (p.handles.length === before) continue;
|
|
199
|
+
if (p.primaryChannel && p.primaryChannel.adapter === a) {
|
|
200
|
+
p.primaryChannel = p.handles[0] ? { adapter: p.handles[0].adapter, channelId: p.handles[0].channelId } : null;
|
|
201
|
+
}
|
|
202
|
+
p.updatedAt = nowIso();
|
|
203
|
+
affected.push(p.id);
|
|
178
204
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
if (affected.length) save(all);
|
|
183
|
-
return affected;
|
|
205
|
+
return affected;
|
|
206
|
+
});
|
|
184
207
|
}
|
|
185
208
|
|
|
186
209
|
function setPrimary(personId, { adapter, channelId }) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
210
|
+
return mutate((all) => {
|
|
211
|
+
const p = all.find((x) => x.id === personId);
|
|
212
|
+
if (!p) return null;
|
|
213
|
+
const a = String(adapter || "").toLowerCase();
|
|
214
|
+
const c = String(channelId || "");
|
|
215
|
+
const has = (p.handles || []).some((h) => h.adapter === a && String(h.channelId) === c);
|
|
216
|
+
if (!has) throw new Error(`person ${p.id} has no handle ${a}:${c}`);
|
|
217
|
+
p.primaryChannel = { adapter: a, channelId: c };
|
|
218
|
+
p.updatedAt = nowIso();
|
|
219
|
+
return p;
|
|
220
|
+
});
|
|
198
221
|
}
|
|
199
222
|
|
|
200
223
|
function note(personId, text, { by = null } = {}) {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
224
|
+
return mutate((all) => {
|
|
225
|
+
const p = all.find((x) => x.id === personId);
|
|
226
|
+
if (!p) return null;
|
|
227
|
+
if (!Array.isArray(p.notes)) p.notes = [];
|
|
228
|
+
const entry = { at: nowIso(), by, text: String(text || "").trim() };
|
|
229
|
+
if (!entry.text) throw new Error("note text is required");
|
|
230
|
+
p.notes.push(entry);
|
|
231
|
+
p.updatedAt = nowIso();
|
|
232
|
+
return entry;
|
|
233
|
+
});
|
|
211
234
|
}
|
|
212
235
|
|
|
213
236
|
function removeNote(personId, index) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
237
|
+
return mutate((all) => {
|
|
238
|
+
const p = all.find((x) => x.id === personId);
|
|
239
|
+
if (!p || !Array.isArray(p.notes)) return null;
|
|
240
|
+
if (index < 0 || index >= p.notes.length) return null;
|
|
241
|
+
const [removed] = p.notes.splice(index, 1);
|
|
242
|
+
p.updatedAt = nowIso();
|
|
243
|
+
return removed;
|
|
244
|
+
});
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
// Bind a person to their persona pack (entities/<slug>.md). Explicit link so a
|
|
225
248
|
// person can point at a differently-named pack; setEntitySlug(id, null) clears.
|
|
226
249
|
function setEntitySlug(personId, slug) {
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
250
|
+
return mutate((all) => {
|
|
251
|
+
const p = all.find((x) => x.id === personId);
|
|
252
|
+
if (!p) return null;
|
|
253
|
+
p.entitySlug = slug ? slugify(slug) : null;
|
|
254
|
+
p.updatedAt = nowIso();
|
|
255
|
+
return p;
|
|
256
|
+
});
|
|
234
257
|
}
|
|
235
258
|
|
|
236
259
|
// Which persona pack belongs to this person: the explicit pointer if set,
|
|
@@ -252,8 +275,8 @@ function seedOwnerFromLegacy() {
|
|
|
252
275
|
if (hasOwnerRecord()) return null;
|
|
253
276
|
let legacyOwner = null;
|
|
254
277
|
try {
|
|
255
|
-
const auth =
|
|
256
|
-
legacyOwner = (auth.authorized || []).find((a) => a.isOwner === true) || null;
|
|
278
|
+
const auth = readJsonWithFallback(AUTH_FILE, {});
|
|
279
|
+
legacyOwner = ((auth && auth.authorized) || []).find((a) => a.isOwner === true) || null;
|
|
257
280
|
} catch (e) {}
|
|
258
281
|
if (!legacyOwner) {
|
|
259
282
|
// Pods configured with only KAZEE_OWNER_USER_ID (empty auth.json) DO have
|
package/core/providers/claude.js
CHANGED
|
@@ -6,7 +6,10 @@ const { createJsonlDecoder } = require("./events");
|
|
|
6
6
|
const { createClaudeParserState, normalizeClaudeEvent } = require("./claude-events");
|
|
7
7
|
const { createClaudeHookTransport } = require("./claude-hook");
|
|
8
8
|
|
|
9
|
+
// claude-fable-5-1 requires Claude Code >= 2.1.251; older CLIs reject it with an
|
|
10
|
+
// API 400 rather than falling back, so a pod on a stale image will error on it.
|
|
9
11
|
const CLAUDE_MODELS = Object.freeze([
|
|
12
|
+
"claude-fable-5-1",
|
|
10
13
|
"claude-fable-5",
|
|
11
14
|
"claude-opus-5",
|
|
12
15
|
"claude-opus-4-8",
|
package/core/relationship.js
CHANGED
|
@@ -40,6 +40,12 @@ function speakerFor(adapterType, channelId, userId) {
|
|
|
40
40
|
let ownerByCanonical = false;
|
|
41
41
|
try { ownerByCanonical = require("./identity").channelIsOwner(adapterType, speakerId); } catch (e) {}
|
|
42
42
|
|
|
43
|
+
// The principal (WHO) and the room (WHERE) are carried separately from here
|
|
44
|
+
// on: a capability grant is issued for the pair, so collapsing them — as
|
|
45
|
+
// channelId alone does on every transport where a room holds many people —
|
|
46
|
+
// would make one member's grant cover the whole room.
|
|
47
|
+
const room = require("./identity").conversationKeyFor(adapterType, channelId);
|
|
48
|
+
|
|
43
49
|
if (ownerByCanonical || (person && person.isOwner)) {
|
|
44
50
|
let ownerPerson = person && person.isOwner ? person : null;
|
|
45
51
|
if (!ownerPerson) { try { ownerPerson = (people.owners() || [])[0] || null; } catch (e) {} }
|
|
@@ -47,7 +53,7 @@ function speakerFor(adapterType, channelId, userId) {
|
|
|
47
53
|
return {
|
|
48
54
|
person: ownerPerson || person || null, name, isOwner: true, relationship: "owner",
|
|
49
55
|
slug: null, mandate: "", style: "", entityName: name,
|
|
50
|
-
adapterType: String(adapterType), channelId: String(channelId),
|
|
56
|
+
adapterType: String(adapterType), channelId: String(channelId), speakerId, room,
|
|
51
57
|
};
|
|
52
58
|
}
|
|
53
59
|
|
|
@@ -57,7 +63,7 @@ function speakerFor(adapterType, channelId, userId) {
|
|
|
57
63
|
return {
|
|
58
64
|
person: null, name: "", isOwner: false, relationship: "external",
|
|
59
65
|
slug: null, mandate: "", style: "", entityName: "",
|
|
60
|
-
adapterType: String(adapterType), channelId: String(channelId),
|
|
66
|
+
adapterType: String(adapterType), channelId: String(channelId), speakerId, room,
|
|
61
67
|
};
|
|
62
68
|
}
|
|
63
69
|
const entities = require("./entities");
|
|
@@ -76,7 +82,7 @@ function speakerFor(adapterType, channelId, userId) {
|
|
|
76
82
|
mandate: require("./mandates").activeMandateText(String(sections.Mandate || "")).trim(),
|
|
77
83
|
style: String(sections.Style || "").trim(),
|
|
78
84
|
entityName: ent ? ent.name : person.name,
|
|
79
|
-
adapterType: String(adapterType), channelId: String(channelId),
|
|
85
|
+
adapterType: String(adapterType), channelId: String(channelId), speakerId, room,
|
|
80
86
|
};
|
|
81
87
|
} catch (e) {
|
|
82
88
|
// Fail closed: an error resolving the speaker is treated as external, not
|
|
@@ -85,6 +91,7 @@ function speakerFor(adapterType, channelId, userId) {
|
|
|
85
91
|
person: null, name: "", isOwner: false, relationship: "external",
|
|
86
92
|
slug: null, mandate: "", style: "", entityName: "",
|
|
87
93
|
adapterType: String(adapterType || ""), channelId: String(channelId || ""),
|
|
94
|
+
speakerId: String(userId || channelId || ""), room: "",
|
|
88
95
|
};
|
|
89
96
|
}
|
|
90
97
|
}
|
|
@@ -173,6 +180,40 @@ async function ownerTarget() {
|
|
|
173
180
|
return configuredOwnerTarget();
|
|
174
181
|
}
|
|
175
182
|
|
|
183
|
+
// EVERY reachable owner, not just the first. Approval is an owner-set decision:
|
|
184
|
+
// resolving only owners()[0] meant a pod with two owners silently posted every
|
|
185
|
+
// card to one of them, so the other could never approve anything and a card
|
|
186
|
+
// went unanswered whenever that one owner was away. Deduped by send target, so
|
|
187
|
+
// two owners sharing a channel get one card. The apr: handler is gated on
|
|
188
|
+
// isChatOwner (not on the record's approverChannel), so any owner may decide
|
|
189
|
+
// whichever copy they see, and decide() is idempotent.
|
|
190
|
+
async function ownerTargets() {
|
|
191
|
+
const out = [];
|
|
192
|
+
const seen = new Set();
|
|
193
|
+
const push = (t, person) => {
|
|
194
|
+
if (!t) return;
|
|
195
|
+
const key = `${t.adapter}:${t.channelId}`;
|
|
196
|
+
if (seen.has(key)) return;
|
|
197
|
+
seen.add(key);
|
|
198
|
+
out.push({ ...t, person: person || t.person || null });
|
|
199
|
+
};
|
|
200
|
+
try {
|
|
201
|
+
const people = require("./people");
|
|
202
|
+
for (const owner of people.owners() || []) {
|
|
203
|
+
const handles = owner.handles || [];
|
|
204
|
+
const primary = owner.primaryChannel
|
|
205
|
+
? handles.find((h) => h.adapter === owner.primaryChannel.adapter &&
|
|
206
|
+
String(h.channelId) === String(owner.primaryChannel.channelId))
|
|
207
|
+
: null;
|
|
208
|
+
const handle = primary || handles[0];
|
|
209
|
+
if (!handle) continue;
|
|
210
|
+
try { push(await resolveHandleSendTarget(handle), owner); } catch (e) { /* skip this owner */ }
|
|
211
|
+
}
|
|
212
|
+
} catch (e) { /* fall through to the env fallback */ }
|
|
213
|
+
if (out.length === 0) push(await configuredOwnerTarget(), null);
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
176
217
|
// Env/legacy-seeded kazee owner handles store the owner's USER id, but kazee
|
|
177
218
|
// sends address a chat-document id — posting buttons to the raw user id 404s
|
|
178
219
|
// and escalation silently dies. Resolve those handles to the 1:1 DM the same
|
|
@@ -282,4 +323,4 @@ function ensureContact(envelope) {
|
|
|
282
323
|
}
|
|
283
324
|
}
|
|
284
325
|
|
|
285
|
-
module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, configuredOwnerTarget, ensureContact };
|
|
326
|
+
module.exports = { speakerFor, currentSpeaker, guardActive, isCurrentSpeakerGuarded, ownerTarget, ownerTargets, configuredOwnerTarget, ensureContact };
|
package/core/router.js
CHANGED
|
@@ -95,6 +95,7 @@ function scope(envelope, fn) {
|
|
|
95
95
|
conversationKey: conversationKeyFor(envelope.adapter?.type, envelope.channelId),
|
|
96
96
|
inboundText: envelope.text || "",
|
|
97
97
|
speakerName: envelope.from?.name || envelope.from?.username || "",
|
|
98
|
+
isGroup: envelope.isGroup === true || envelope.surface === "group",
|
|
98
99
|
}, fn);
|
|
99
100
|
}
|
|
100
101
|
|
package/core/scheduler.js
CHANGED
|
@@ -33,7 +33,7 @@ const {
|
|
|
33
33
|
getProviderSettings,
|
|
34
34
|
getUserState,
|
|
35
35
|
} = require("./state");
|
|
36
|
-
const { canonicalForChannel } = require("./identity");
|
|
36
|
+
const { canonicalForChannel, conversationKeyFor } = require("./identity");
|
|
37
37
|
const { send: defaultSend } = require("./io");
|
|
38
38
|
const { providerStatus: defaultProviderStatus } = require("./providers");
|
|
39
39
|
const { redactSensitive } = require("./redact");
|
|
@@ -257,6 +257,11 @@ function createSchedulerService(dependencies = {}) {
|
|
|
257
257
|
attachSession: lineage.attachSession,
|
|
258
258
|
fallbackFrom: null,
|
|
259
259
|
switchedFrom: null,
|
|
260
|
+
// The room's current selection exists but is not runnable, so this run
|
|
261
|
+
// reverts to the provider the job was created under. That revert used
|
|
262
|
+
// to be silent, which is why a Codex room reported a Claude quota
|
|
263
|
+
// error with nothing to explain where Claude came from.
|
|
264
|
+
revertedFrom: currentProvider && currentProvider !== job.provider ? currentProvider : null,
|
|
260
265
|
fresh: !lineage.sessionId,
|
|
261
266
|
};
|
|
262
267
|
}
|
|
@@ -288,6 +293,8 @@ function createSchedulerService(dependencies = {}) {
|
|
|
288
293
|
}
|
|
289
294
|
if (target.fallbackFrom) {
|
|
290
295
|
lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
|
|
296
|
+
} else if (target.revertedFrom) {
|
|
297
|
+
lines.push(`[This room currently selects ${target.revertedFrom}, but that provider is not runnable right now, so this occurrence reverted to the provider it was scheduled under (${target.provider}).]`);
|
|
291
298
|
} else if (target.switchedFrom) {
|
|
292
299
|
lines.push(`[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`);
|
|
293
300
|
}
|
|
@@ -477,6 +484,12 @@ function createSchedulerService(dependencies = {}) {
|
|
|
477
484
|
// shared session. Matches captured project.name (projectDescriptor(job).name)
|
|
478
485
|
// so sameCapturedProject / getProjectKey resolve the right thread mid-run.
|
|
479
486
|
conversationKey: projectDescriptor(job).name,
|
|
487
|
+
// …but the thread is not the room. A job belongs to the room it was
|
|
488
|
+
// created in and must answer under THAT room's provider; without this the
|
|
489
|
+
// room overlay can't resolve and the run silently falls back to the
|
|
490
|
+
// creator's personal default — which is how a Codex room kept firing
|
|
491
|
+
// wakeups on Claude.
|
|
492
|
+
roomKey: conversationKeyFor(adapter.type, job.channelId),
|
|
480
493
|
// Rides only on triage-approval wakeups; the enforcer's reply vet honours
|
|
481
494
|
// the owner's one-off "handle this request" grant through it.
|
|
482
495
|
triageApprovalId: job.triageApprovalId || "",
|
|
@@ -536,7 +549,15 @@ function createSchedulerService(dependencies = {}) {
|
|
|
536
549
|
const advisory = job.check
|
|
537
550
|
? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway` : `🔔 Watch "${job.label}" triggered`)
|
|
538
551
|
: `Wakeup: ${job.label}`;
|
|
539
|
-
|
|
552
|
+
// Say so when the run is NOT on the provider this room is set to: an
|
|
553
|
+
// unexplained error from a provider the room never selected is worse than
|
|
554
|
+
// the outage itself.
|
|
555
|
+
const providerNote = target.revertedFrom
|
|
556
|
+
? `\n⚠️ ${target.revertedFrom} is unavailable — running on ${target.provider} instead.`
|
|
557
|
+
: target.fallbackFrom
|
|
558
|
+
? `\n⚠️ ${target.fallbackFrom} is unavailable — running on fallback ${target.provider}.`
|
|
559
|
+
: "";
|
|
560
|
+
try { await send(`${advisory}${providerNote}`); } catch (_) { /* advisory only */ }
|
|
540
561
|
let result;
|
|
541
562
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
542
563
|
catch (error) {
|