@openmarket/rooms-client 0.1.0
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/LICENSE +201 -0
- package/README.md +25 -0
- package/dist/agent/armed-mode.d.ts +187 -0
- package/dist/agent/armed-mode.js +306 -0
- package/dist/agent/clients/common.d.ts +70 -0
- package/dist/agent/clients/common.js +46 -0
- package/dist/agent/lane-draft.d.ts +15 -0
- package/dist/agent/lane-draft.js +43 -0
- package/dist/agent/lane-prompts.d.ts +102 -0
- package/dist/agent/lane-prompts.js +120 -0
- package/dist/agent/library-context.d.ts +38 -0
- package/dist/agent/library-context.js +57 -0
- package/dist/agent/library-model.d.ts +41 -0
- package/dist/agent/library-model.js +173 -0
- package/dist/agent/room-context.d.ts +151 -0
- package/dist/agent/room-context.js +251 -0
- package/dist/agent/sidebar-model.d.ts +86 -0
- package/dist/agent/sidebar-model.js +162 -0
- package/dist/agent/topic-inbox.d.ts +119 -0
- package/dist/agent/topic-inbox.js +266 -0
- package/dist/agent/topic-view-batcher.d.ts +54 -0
- package/dist/agent/topic-view-batcher.js +115 -0
- package/dist/client.d.ts +421 -0
- package/dist/client.js +1428 -0
- package/dist/doc-reconcile.d.ts +100 -0
- package/dist/doc-reconcile.js +110 -0
- package/dist/doc-uri.d.ts +29 -0
- package/dist/doc-uri.js +55 -0
- package/dist/endpoints.d.ts +9 -0
- package/dist/endpoints.js +13 -0
- package/dist/jwt.d.ts +10 -0
- package/dist/jwt.js +51 -0
- package/dist/library-publisher.d.ts +52 -0
- package/dist/library-publisher.js +49 -0
- package/dist/merge3.d.ts +50 -0
- package/dist/merge3.js +280 -0
- package/dist/names.d.ts +6 -0
- package/dist/names.js +35 -0
- package/dist/shared/emoji-data.d.ts +22 -0
- package/dist/shared/emoji-data.js +8834 -0
- package/dist/shared/mentions.d.ts +6 -0
- package/dist/shared/mentions.js +32 -0
- package/dist/shared/rooms-protocol.d.ts +1183 -0
- package/dist/shared/rooms-protocol.js +160 -0
- package/dist/social-client.d.ts +361 -0
- package/dist/social-client.js +686 -0
- package/dist/social-types.d.ts +338 -0
- package/dist/social-types.js +1 -0
- package/dist/suggestion-attribution.d.ts +10 -0
- package/dist/suggestion-attribution.js +56 -0
- package/dist/topic-uri.d.ts +26 -0
- package/dist/topic-uri.js +40 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/dist/ws-client.d.ts +115 -0
- package/dist/ws-client.js +491 -0
- package/package.json +180 -0
- package/src/agent/armed-mode.ts +368 -0
- package/src/agent/clients/common.ts +91 -0
- package/src/agent/lane-draft.ts +47 -0
- package/src/agent/lane-prompts.ts +267 -0
- package/src/agent/library-context.ts +97 -0
- package/src/agent/library-model.ts +210 -0
- package/src/agent/room-context.ts +351 -0
- package/src/agent/sidebar-model.ts +235 -0
- package/src/agent/topic-inbox.ts +297 -0
- package/src/agent/topic-view-batcher.ts +134 -0
- package/src/client.ts +2331 -0
- package/src/doc-reconcile.ts +160 -0
- package/src/doc-uri.ts +83 -0
- package/src/endpoints.ts +14 -0
- package/src/jwt.ts +59 -0
- package/src/library-publisher.ts +93 -0
- package/src/merge3.ts +326 -0
- package/src/names.ts +44 -0
- package/src/shared/emoji-data.ts +8868 -0
- package/src/shared/mentions.ts +32 -0
- package/src/shared/rooms-protocol.ts +1339 -0
- package/src/social-client.ts +1287 -0
- package/src/social-types.ts +376 -0
- package/src/suggestion-attribution.ts +83 -0
- package/src/topic-uri.ts +64 -0
- package/src/types.ts +83 -0
- package/src/version.ts +2 -0
- package/src/ws-client.ts +611 -0
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mentionsHandleExplicit as mentionsAgentExplicit,
|
|
3
|
+
normalizeHandle,
|
|
4
|
+
} from "../shared/mentions.js";
|
|
5
|
+
import type { RoomLedgerEntry } from "../types.js";
|
|
6
|
+
import { RoomLedgerEntryType } from "../types.js";
|
|
7
|
+
|
|
8
|
+
export { mentionsAgentExplicit, normalizeHandle };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ARMED MODE rules of engagement (RoE), the guardrails for autonomous in-room
|
|
12
|
+
* posting under the user's identity. These are the named, easy-to-change
|
|
13
|
+
* defaults. The whole point of armed mode is that the agent posts on its OWN
|
|
14
|
+
* initiative as the user, so every gate here is a SAFETY limit, not a nicety:
|
|
15
|
+
* each one independently blocks an autonomous post, and a post happens only when
|
|
16
|
+
* ALL of them allow it (see `canPostAutonomously`).
|
|
17
|
+
*
|
|
18
|
+
* Defaults DISARMED: with nothing armed there is zero autonomous posting and the
|
|
19
|
+
* TUI behaves exactly as before. Arming is in-memory per session, never
|
|
20
|
+
* persisted (a fresh launch is always disarmed).
|
|
21
|
+
*/
|
|
22
|
+
export const ARMED_RULES = {
|
|
23
|
+
/** Minimum gap between two autonomous posts in the SAME room. */
|
|
24
|
+
cooldownMs: 30_000,
|
|
25
|
+
/** Max autonomous posts per room in any rolling 60-minute window. When hit
|
|
26
|
+
* the loop PAUSES (skips) until the window frees; it does NOT disarm. */
|
|
27
|
+
hourlyCap: 10,
|
|
28
|
+
/** Rolling window the hourly cap is measured over. */
|
|
29
|
+
hourlyWindowMs: 60 * 60_000,
|
|
30
|
+
/** Max autonomous posts in a row with no intervening human message. When hit
|
|
31
|
+
* the loop PAUSES until a human speaks; it does NOT disarm. */
|
|
32
|
+
chainLimit: 3,
|
|
33
|
+
/** Hard per-armed-session TOKEN budget across all armed turns in a room.
|
|
34
|
+
* Token spend is read from turn-reported usage when the provider supplies it.
|
|
35
|
+
* Exceeding this AUTO-DISARMS the room (this is the cost guard). */
|
|
36
|
+
tokenBudget: 200_000,
|
|
37
|
+
/** Fallback hard cap on autonomous posts per armed session, used as the cost
|
|
38
|
+
* guard when the provider reports no token usage (most providers don't on
|
|
39
|
+
* streaming turns). Exceeding this ALSO auto-disarms. So the cost guard is
|
|
40
|
+
* always effective: whichever of tokens / posts trips first disarms. */
|
|
41
|
+
maxPostsPerSession: 40,
|
|
42
|
+
/** Settle window before deciding, so a burst of messages is judged once with
|
|
43
|
+
* full context (reuses the room-monitor settle idea). */
|
|
44
|
+
settleMs: 2_500,
|
|
45
|
+
/** Hard cap on an autonomous post's length. Longer drafts are skipped, not
|
|
46
|
+
* truncated (a half-sentence posted as the user is worse than silence). */
|
|
47
|
+
maxPostChars: 600,
|
|
48
|
+
/** Consecutive turn/post errors that auto-disarm the room (kill switch). */
|
|
49
|
+
maxConsecutiveErrors: 3,
|
|
50
|
+
} as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Per-verb grants for armed mode. Some verbs are too consequential to ride the
|
|
54
|
+
* default armed toolset (which is otherwise market/research reads only): they
|
|
55
|
+
* are OFF unless the operator explicitly grants them for the session. `secrets_send`
|
|
56
|
+
* lets an armed agent seal-and-post autonomously; default DENY, since an
|
|
57
|
+
* untrusted @mention must never be able to make the agent exfiltrate a sealed
|
|
58
|
+
* value. Grants are in-memory per session, never persisted (a fresh launch
|
|
59
|
+
* grants nothing).
|
|
60
|
+
*/
|
|
61
|
+
export const ARMED_GRANTABLE_VERBS = ["secrets_send"] as const;
|
|
62
|
+
export type ArmedGrantableVerb = (typeof ARMED_GRANTABLE_VERBS)[number];
|
|
63
|
+
|
|
64
|
+
const armedVerbGrants = new Set<ArmedGrantableVerb>();
|
|
65
|
+
|
|
66
|
+
export function grantArmedVerb(verb: ArmedGrantableVerb): void {
|
|
67
|
+
armedVerbGrants.add(verb);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function revokeArmedVerb(verb: ArmedGrantableVerb): void {
|
|
71
|
+
armedVerbGrants.delete(verb);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isArmedVerbGranted(verb: ArmedGrantableVerb): boolean {
|
|
75
|
+
return armedVerbGrants.has(verb);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The extra verb names an armed turn may advertise given the current grants.
|
|
79
|
+
* Appended to `armedSafeToolNames()` at the armed call site. */
|
|
80
|
+
export function grantedArmedVerbNames(): string[] {
|
|
81
|
+
return ARMED_GRANTABLE_VERBS.filter((verb) => armedVerbGrants.has(verb));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Test/lifecycle helper: drop every grant (a fresh session grants nothing). */
|
|
85
|
+
export function clearArmedVerbGrants(): void {
|
|
86
|
+
armedVerbGrants.clear();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mutable per-room armed state. Lives only in memory and only while the room is
|
|
91
|
+
* armed; `disarm` drops it. Caps are tracked here and checked BEFORE every
|
|
92
|
+
* autonomous post.
|
|
93
|
+
*/
|
|
94
|
+
export interface ArmedRoomState {
|
|
95
|
+
/** Room id (e.g. "room:btc") this state guards. */
|
|
96
|
+
readonly roomId: string;
|
|
97
|
+
/** Timestamps (ms) of autonomous posts this session, used for the cooldown
|
|
98
|
+
* and the rolling hourly cap. */
|
|
99
|
+
postTimes: number[];
|
|
100
|
+
/** Autonomous posts in a row with no intervening human message. Reset to 0
|
|
101
|
+
* whenever a human speaks in the room. */
|
|
102
|
+
consecutiveAutoPosts: number;
|
|
103
|
+
/** Total autonomous posts this armed session (cost-guard fallback counter). */
|
|
104
|
+
postsThisSession: number;
|
|
105
|
+
/** Total tokens spent by armed turns this session (cost guard, when the
|
|
106
|
+
* provider reports usage; otherwise stays 0 and the post counter governs). */
|
|
107
|
+
tokensThisSession: number;
|
|
108
|
+
/** Consecutive turn/post errors; resets on any clean turn. At
|
|
109
|
+
* `maxConsecutiveErrors` the room auto-disarms. */
|
|
110
|
+
consecutiveErrors: number;
|
|
111
|
+
/** The highest room seq this loop has already evaluated, so re-entry doesn't
|
|
112
|
+
* re-trigger on already-seen messages. */
|
|
113
|
+
lastEvaluatedSeq: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A fresh armed state for a newly armed room. */
|
|
117
|
+
export function makeArmedRoomState(roomId: string, lastSeq = 0): ArmedRoomState {
|
|
118
|
+
return {
|
|
119
|
+
roomId,
|
|
120
|
+
postTimes: [],
|
|
121
|
+
consecutiveAutoPosts: 0,
|
|
122
|
+
postsThisSession: 0,
|
|
123
|
+
tokensThisSession: 0,
|
|
124
|
+
consecutiveErrors: 0,
|
|
125
|
+
lastEvaluatedSeq: lastSeq,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether a single incoming entry is a qualifying TRIGGER: a NEW human POST in
|
|
131
|
+
* the room that @mentions the user's own handle. Agent posts, system entries,
|
|
132
|
+
* the user's own posts, and non-mentions never trigger. Pure.
|
|
133
|
+
*/
|
|
134
|
+
export function isTriggeringEntry(
|
|
135
|
+
entry: RoomLedgerEntry,
|
|
136
|
+
ownHandle: string,
|
|
137
|
+
ownUserId: string,
|
|
138
|
+
): boolean {
|
|
139
|
+
if (entry.type !== RoomLedgerEntryType.POST) return false;
|
|
140
|
+
if (entry.isAgent) return false;
|
|
141
|
+
if (!entry.text?.trim()) return false;
|
|
142
|
+
// Never trigger on our own message (we post as this handle/userId).
|
|
143
|
+
const fromHandle = normalizeHandle(entry.handle).toLowerCase();
|
|
144
|
+
const me = normalizeHandle(ownHandle).toLowerCase();
|
|
145
|
+
if (entry.userId === ownUserId) return false;
|
|
146
|
+
if (me.length > 0 && fromHandle === me) return false;
|
|
147
|
+
// Must EXPLICITLY @mention the user's own handle. A bare-substring match (the
|
|
148
|
+
// old mentionsAgent) fired on ordinary words for short handles like "may" /
|
|
149
|
+
// "btc"; armed posting has no human review, so it triggers ONLY on an `@handle`
|
|
150
|
+
// token. Empty handle never matches (mentionsAgentExplicit guards it).
|
|
151
|
+
return mentionsAgentExplicit(entry.text, ownHandle);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The newest qualifying trigger among entries strictly newer than
|
|
156
|
+
* `afterSeq`, or null if none. Used by the loop to decide whether the latest
|
|
157
|
+
* batch of room messages contains a reason to act. Pure.
|
|
158
|
+
*/
|
|
159
|
+
export function findTrigger(
|
|
160
|
+
entries: ReadonlyArray<RoomLedgerEntry>,
|
|
161
|
+
ownHandle: string,
|
|
162
|
+
ownUserId: string,
|
|
163
|
+
afterSeq: number,
|
|
164
|
+
): RoomLedgerEntry | null {
|
|
165
|
+
let found: RoomLedgerEntry | null = null;
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (entry.seq <= afterSeq) continue;
|
|
168
|
+
if (isTriggeringEntry(entry, ownHandle, ownUserId)) found = entry;
|
|
169
|
+
}
|
|
170
|
+
return found;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Autonomous posts inside the rolling hourly window as of `now`. */
|
|
174
|
+
export function postsInWindow(state: ArmedRoomState, now = Date.now()): number {
|
|
175
|
+
const cutoff = now - ARMED_RULES.hourlyWindowMs;
|
|
176
|
+
return state.postTimes.filter((t) => t > cutoff).length;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Remaining cooldown (ms) before the next autonomous post is allowed; 0 when
|
|
180
|
+
* free. */
|
|
181
|
+
export function cooldownRemaining(state: ArmedRoomState, now = Date.now()): number {
|
|
182
|
+
const last = state.postTimes.at(-1);
|
|
183
|
+
if (last === undefined) return 0;
|
|
184
|
+
return Math.max(0, ARMED_RULES.cooldownMs - (now - last));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type CapBlock = "cooldown" | "hourly" | "chain" | "budget";
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Which cap (if any) blocks an autonomous post RIGHT NOW, or null when every
|
|
191
|
+
* cap allows it. Order matters only for the reported reason; all are hard. Pure
|
|
192
|
+
* and exported so each gate is unit-testable in isolation.
|
|
193
|
+
*
|
|
194
|
+
* - cooldown: < cooldownMs since the last autonomous post.
|
|
195
|
+
* - hourly: >= hourlyCap autonomous posts in the rolling window (PAUSE).
|
|
196
|
+
* - chain: >= chainLimit autonomous posts with no human between (PAUSE).
|
|
197
|
+
* - budget: token spend OR post count over the session cost guard (DISARM).
|
|
198
|
+
*/
|
|
199
|
+
export function capBlocking(state: ArmedRoomState, now = Date.now()): CapBlock | null {
|
|
200
|
+
if (budgetExceeded(state)) return "budget";
|
|
201
|
+
if (cooldownRemaining(state, now) > 0) return "cooldown";
|
|
202
|
+
if (postsInWindow(state, now) >= ARMED_RULES.hourlyCap) return "hourly";
|
|
203
|
+
if (state.consecutiveAutoPosts >= ARMED_RULES.chainLimit) return "chain";
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Whether the session cost guard is spent: token spend over budget OR (the
|
|
209
|
+
* provider-agnostic fallback) post count over the per-session max. Either
|
|
210
|
+
* tripping AUTO-DISARMS the room. Pure.
|
|
211
|
+
*/
|
|
212
|
+
export function budgetExceeded(state: ArmedRoomState): boolean {
|
|
213
|
+
if (state.tokensThisSession >= ARMED_RULES.tokenBudget) return true;
|
|
214
|
+
if (state.postsThisSession >= ARMED_RULES.maxPostsPerSession) return true;
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type ContentVerdict = { ok: true; text: string } | { ok: false; reason: string };
|
|
219
|
+
|
|
220
|
+
// Secret/credential shapes that must never be posted as the user. Matches the
|
|
221
|
+
// agent logger's redaction intent: provider API keys, bearer tokens, OM keys,
|
|
222
|
+
// and long hex/base64 secrets. A draft containing any of these is dropped whole.
|
|
223
|
+
const SECRET_PATTERNS: ReadonlyArray<RegExp> = [
|
|
224
|
+
/sk-[A-Za-z0-9_-]{16,}/, // OpenAI / Anthropic style keys
|
|
225
|
+
/sk-ant-[A-Za-z0-9_-]{8,}/,
|
|
226
|
+
/sk-or-[A-Za-z0-9_-]{8,}/,
|
|
227
|
+
/\bom_[A-Za-z0-9]{16,}\b/, // OpenMarket api keys
|
|
228
|
+
/\bBearer\s+[A-Za-z0-9._-]{12,}/i,
|
|
229
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}/, // Slack tokens
|
|
230
|
+
/\bgh[pousr]_[A-Za-z0-9]{20,}/, // GitHub tokens
|
|
231
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
232
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{6,}/, // JWTs
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
// Any URL/link: armed posting blocks links outright (a posted link as the user
|
|
236
|
+
// is both a spam and an exfiltration vector). Covers explicit schemes, www
|
|
237
|
+
// forms, and bare domains with or without a path.
|
|
238
|
+
const LINK_PATTERN =
|
|
239
|
+
/\b(?:https?:\/\/|www\.)\S+|\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,24}(?:\/\S*)?\b/i;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* CONTENT GUARD applied to a candidate autonomous post BEFORE it is sent.
|
|
243
|
+
* Rejects: empty/whitespace, anything matching a secret/credential pattern,
|
|
244
|
+
* anything containing a link/URL, and anything over the length cap. On pass,
|
|
245
|
+
* returns the trimmed text to post. Pure and exported for unit testing: this
|
|
246
|
+
* is the last line of defense before text goes out as the user.
|
|
247
|
+
*/
|
|
248
|
+
export function guardAutonomousContent(raw: string): ContentVerdict {
|
|
249
|
+
const text = raw.trim();
|
|
250
|
+
if (text.length === 0) return { ok: false, reason: "empty" };
|
|
251
|
+
if (text.length > ARMED_RULES.maxPostChars) {
|
|
252
|
+
return { ok: false, reason: `too long (${text.length} > ${ARMED_RULES.maxPostChars})` };
|
|
253
|
+
}
|
|
254
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
255
|
+
if (pattern.test(text)) return { ok: false, reason: "looks like it contains a secret" };
|
|
256
|
+
}
|
|
257
|
+
if (LINK_PATTERN.test(text)) return { ok: false, reason: "contains a link" };
|
|
258
|
+
return { ok: true, text };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export type PostDecision =
|
|
262
|
+
| { post: true; text: string }
|
|
263
|
+
| { post: false; reason: string; disarm: boolean };
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* THE single gate in front of every autonomous post. Returns `{ post: true }`
|
|
267
|
+
* ONLY when ALL hold:
|
|
268
|
+
* 1. the room is armed (the caller only invokes this for an armed room, and
|
|
269
|
+
* passes its live state),
|
|
270
|
+
* 2. there is a qualifying trigger (own-handle @mention in a new human msg),
|
|
271
|
+
* 3. every cap allows it (cooldown AND hourly AND chain AND budget), and
|
|
272
|
+
* 4. the content guard passes.
|
|
273
|
+
* Any failure returns `{ post: false }` with a reason; `disarm` is true only
|
|
274
|
+
* when the failure is the cost-guard (budget), the one cap that auto-disarms
|
|
275
|
+
* rather than pausing. Pure, so the full && chain is unit-testable end to end
|
|
276
|
+
* without a TUI or network.
|
|
277
|
+
*/
|
|
278
|
+
export function canPostAutonomously(input: {
|
|
279
|
+
state: ArmedRoomState;
|
|
280
|
+
trigger: RoomLedgerEntry | null;
|
|
281
|
+
candidate: string;
|
|
282
|
+
now?: number;
|
|
283
|
+
}): PostDecision {
|
|
284
|
+
const now = input.now ?? Date.now();
|
|
285
|
+
if (!input.trigger) return { post: false, reason: "no trigger", disarm: false };
|
|
286
|
+
const cap = capBlocking(input.state, now);
|
|
287
|
+
if (cap !== null) {
|
|
288
|
+
return { post: false, reason: `cap:${cap}`, disarm: cap === "budget" };
|
|
289
|
+
}
|
|
290
|
+
const verdict = guardAutonomousContent(input.candidate);
|
|
291
|
+
if (!verdict.ok) return { post: false, reason: `content:${verdict.reason}`, disarm: false };
|
|
292
|
+
return { post: true, text: verdict.text };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Record a completed autonomous post against the caps: stamp the time (cooldown
|
|
297
|
+
* + hourly), bump the consecutive-chain and session counters. Call AFTER a
|
|
298
|
+
* successful `post()`. Mutates `state`.
|
|
299
|
+
*/
|
|
300
|
+
export function recordAutonomousPost(state: ArmedRoomState, now = Date.now()): void {
|
|
301
|
+
state.postTimes.push(now);
|
|
302
|
+
// Keep the time list from growing without bound on a long session; only the
|
|
303
|
+
// rolling window matters for the cap.
|
|
304
|
+
const cutoff = now - ARMED_RULES.hourlyWindowMs;
|
|
305
|
+
state.postTimes = state.postTimes.filter((t) => t > cutoff);
|
|
306
|
+
state.consecutiveAutoPosts += 1;
|
|
307
|
+
state.postsThisSession += 1;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Record token spend from an armed turn against the session cost guard. Reads
|
|
311
|
+
* `totalTokens` from a turn's `usage` (pi-ai shape) when present; a no-op when
|
|
312
|
+
* the provider reported nothing (the post counter is the fallback guard). */
|
|
313
|
+
export function recordArmedTurnUsage(
|
|
314
|
+
state: ArmedRoomState,
|
|
315
|
+
usage: Record<string, unknown> | undefined,
|
|
316
|
+
): void {
|
|
317
|
+
const total = readTotalTokens(usage);
|
|
318
|
+
if (total > 0) state.tokensThisSession += total;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function readTotalTokens(usage: Record<string, unknown> | undefined): number {
|
|
322
|
+
if (!usage) return 0;
|
|
323
|
+
const direct = usage.totalTokens;
|
|
324
|
+
if (typeof direct === "number" && Number.isFinite(direct) && direct > 0) return direct;
|
|
325
|
+
// Fall back to summing input + output if a client reports those instead.
|
|
326
|
+
const input = typeof usage.input === "number" ? usage.input : 0;
|
|
327
|
+
const output = typeof usage.output === "number" ? usage.output : 0;
|
|
328
|
+
const sum = input + output;
|
|
329
|
+
return Number.isFinite(sum) && sum > 0 ? sum : 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A human (non-agent) POST that is NOT our own resets the consecutive-chain
|
|
333
|
+
* counter, freeing a chain-limit pause. Pure. */
|
|
334
|
+
export function isHumanResetEntry(
|
|
335
|
+
entry: RoomLedgerEntry,
|
|
336
|
+
ownHandle: string,
|
|
337
|
+
ownUserId: string,
|
|
338
|
+
): boolean {
|
|
339
|
+
if (entry.type !== RoomLedgerEntryType.POST) return false;
|
|
340
|
+
if (entry.isAgent) return false;
|
|
341
|
+
if (entry.userId === ownUserId) return false;
|
|
342
|
+
const fromHandle = normalizeHandle(entry.handle).toLowerCase();
|
|
343
|
+
const me = normalizeHandle(ownHandle).toLowerCase();
|
|
344
|
+
if (me.length > 0 && fromHandle === me) return false;
|
|
345
|
+
return Boolean(entry.text?.trim());
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Note a turn/post error against the kill switch; returns true once the room
|
|
349
|
+
* should auto-disarm (>= maxConsecutiveErrors in a row). Mutates `state`. */
|
|
350
|
+
export function noteArmedError(state: ArmedRoomState): boolean {
|
|
351
|
+
state.consecutiveErrors += 1;
|
|
352
|
+
return state.consecutiveErrors >= ARMED_RULES.maxConsecutiveErrors;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Reset the consecutive-error counter after a clean armed turn. */
|
|
356
|
+
export function clearArmedErrors(state: ArmedRoomState): void {
|
|
357
|
+
state.consecutiveErrors = 0;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function countConsecutiveAgentPosts(entries: RoomLedgerEntry[]): number {
|
|
361
|
+
let count = 0;
|
|
362
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
363
|
+
const entry = entries[index];
|
|
364
|
+
if (!entry || entry.type !== RoomLedgerEntryType.POST || !entry.isAgent) break;
|
|
365
|
+
count += 1;
|
|
366
|
+
}
|
|
367
|
+
return count;
|
|
368
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getProviders, type KnownProvider } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
export type SurfaceId = "cli" | "telegram" | "discord" | "slack" | "webui";
|
|
4
|
+
|
|
5
|
+
export interface AgentToolCall {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
args: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AgentMessage =
|
|
12
|
+
| { role: "user"; content: string }
|
|
13
|
+
| { role: "assistant"; content: string; toolCalls?: AgentToolCall[] }
|
|
14
|
+
| { role: "tool"; content: string; toolCallId: string; name: string };
|
|
15
|
+
|
|
16
|
+
export interface ToolDispatchResult {
|
|
17
|
+
isError?: true;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LlmTool {
|
|
22
|
+
name: string;
|
|
23
|
+
description: string;
|
|
24
|
+
inputSchema: Record<string, unknown>;
|
|
25
|
+
handler: (args: unknown) => Promise<ToolDispatchResult>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** OM-level provider for any OpenAI-compatible /v1/chat/completions endpoint
|
|
29
|
+
* (Ollama, vLLM, LM Studio, llama.cpp, custom proxies). Not a pi-ai
|
|
30
|
+
* KnownProvider: the model object is constructed at turn time from the
|
|
31
|
+
* credential's stored base URL instead of pi-ai's catalog. */
|
|
32
|
+
export const OPENAI_COMPATIBLE_PROVIDER = "openai-compatible" as const;
|
|
33
|
+
|
|
34
|
+
export type LlmProvider = KnownProvider | typeof OPENAI_COMPATIBLE_PROVIDER;
|
|
35
|
+
|
|
36
|
+
// Provider-resolution precedence (authProviderCandidates): these are tried
|
|
37
|
+
// before any store.order / generic provider. Invariant: an active profile for
|
|
38
|
+
// an earlier provider here shadows a later-configured non-primary one (groq,
|
|
39
|
+
// openai-compatible, …). The headless/env path short-circuits this, but an
|
|
40
|
+
// interactive switch via `om init` to a non-primary provider only takes effect
|
|
41
|
+
// once the shadowing primary profile is removed. Changing that is a product
|
|
42
|
+
// decision (explicit active-provider selection), not a per-provider concern.
|
|
43
|
+
export const PRIMARY_LLM_PROVIDERS: readonly LlmProvider[] = [
|
|
44
|
+
"anthropic",
|
|
45
|
+
"openai-codex",
|
|
46
|
+
"openai",
|
|
47
|
+
"google",
|
|
48
|
+
"openrouter",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
let knownProviderSet: Set<string> | null = null;
|
|
52
|
+
|
|
53
|
+
export function isLlmProvider(value: string): value is LlmProvider {
|
|
54
|
+
if (value === OPENAI_COMPATIBLE_PROVIDER) return true;
|
|
55
|
+
if (knownProviderSet === null) {
|
|
56
|
+
knownProviderSet = new Set(getProviders());
|
|
57
|
+
}
|
|
58
|
+
return knownProviderSet.has(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type TurnEvent =
|
|
62
|
+
| { kind: "assistant.text"; delta: string }
|
|
63
|
+
| { kind: "tool.call"; id: string; name: string; args: unknown }
|
|
64
|
+
| { kind: "tool.result"; id: string; name: string; result: ToolDispatchResult }
|
|
65
|
+
| { kind: "turn.done"; usage?: Record<string, unknown> }
|
|
66
|
+
| { kind: "turn.error"; code?: string; message: string };
|
|
67
|
+
|
|
68
|
+
export interface RunLlmTurnInput {
|
|
69
|
+
apiKey: string;
|
|
70
|
+
system: string;
|
|
71
|
+
messages: AgentMessage[];
|
|
72
|
+
tools: LlmTool[];
|
|
73
|
+
model: string;
|
|
74
|
+
signal?: AbortSignal | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type RunLlmTurn = (input: RunLlmTurnInput) => AsyncIterable<TurnEvent>;
|
|
78
|
+
|
|
79
|
+
export function stringifyToolContent(value: unknown): string {
|
|
80
|
+
if (value === undefined) return "";
|
|
81
|
+
if (typeof value === "string") return value;
|
|
82
|
+
return JSON.stringify(value, null, 2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseToolArguments(raw: unknown): unknown {
|
|
86
|
+
if (raw === undefined || raw === null) return {};
|
|
87
|
+
if (typeof raw !== "string") return raw;
|
|
88
|
+
const trimmed = raw.trim();
|
|
89
|
+
if (!trimmed) return {};
|
|
90
|
+
return JSON.parse(trimmed) as unknown;
|
|
91
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The private-lane DRAFT contract, shared by every surface that runs a lane
|
|
2
|
+
// co-pilot (the TUI and the /rooms GUI): the lane turn may end with a final
|
|
3
|
+
// `DRAFT: <message>` line, which the surface stages into the PUBLIC composer
|
|
4
|
+
// for the user to send. Pure logic, browser-safe.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The exact prompt line teaching the model to write drafts in the user's own
|
|
8
|
+
* voice (Persona). Describes the CHARACTER of the voice only (the persona
|
|
9
|
+
* string is a style description, never example messages), so the model matches
|
|
10
|
+
* register without parroting canned lines.
|
|
11
|
+
*/
|
|
12
|
+
export function personaVoiceDirective(persona: string): string {
|
|
13
|
+
return `Write as the user, in their voice. Their style: ${persona.trim()}. Match this voice in any DRAFT you produce.`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type LaneTurnOutcome = { kind: "stage-draft"; draft: string } | { kind: "stay-in-lane" };
|
|
17
|
+
|
|
18
|
+
export function planLaneTurnOutcome(answer: string, aborted: boolean): LaneTurnOutcome {
|
|
19
|
+
if (aborted) return { kind: "stay-in-lane" };
|
|
20
|
+
const draft = extractPublicDraft(answer);
|
|
21
|
+
return draft ? { kind: "stage-draft", draft } : { kind: "stay-in-lane" };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function extractPublicDraft(answer: string): string | null {
|
|
25
|
+
const lines = answer.split(/\r?\n/);
|
|
26
|
+
let lastLine: string | undefined;
|
|
27
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
28
|
+
const line = lines[i];
|
|
29
|
+
if (line && line.trim().length > 0) {
|
|
30
|
+
lastLine = line;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const match = lastLine?.match(/^\s*(?:public\s+)?draft\s*:\s*(.+?)\s*$/i);
|
|
35
|
+
const draft = match?.[1]?.trim();
|
|
36
|
+
return draft ? stripWrappingQuotes(draft) : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stripWrappingQuotes(value: string): string {
|
|
40
|
+
if (value.length < 2) return value;
|
|
41
|
+
const first = value[0];
|
|
42
|
+
const last = value[value.length - 1];
|
|
43
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
44
|
+
return value.slice(1, -1).trim();
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|