@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,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;
|