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