thinkpool-pair 0.7.257 → 0.7.259
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/bridge.mjs +14 -4
- package/claude-session.mjs +26 -18
- package/codex-session.mjs +33 -8
- package/cross-terminal.mjs +3 -1
- package/hermes-session.mjs +36 -8
- package/model-prices.mjs +113 -0
- package/package.json +2 -1
- package/thinkpool-room-prompt.mjs +139 -6
package/bridge.mjs
CHANGED
|
@@ -119,6 +119,7 @@ import { classifyCodeEvent, unknownCodeEventNotice } from './code-event-contract
|
|
|
119
119
|
import { fetchPairControlDeliveryAuthority } from './pair-control-authority.mjs'
|
|
120
120
|
import { SIDE_HANDOFF_PROMPT, SIDE_ROLE_PROMPT, appendSideContext, assistantTextSince, sideContextBlock, sideSnapshot } from './side-lane.mjs'
|
|
121
121
|
import { planMeterLine } from './plan-meters.mjs'
|
|
122
|
+
import { priceForModel } from './model-prices.mjs'
|
|
122
123
|
import { makeThrottledTrack } from './presence.mjs'
|
|
123
124
|
import { resolveAnonKey, DEFAULT_SUPABASE_URL } from './supabase-key.mjs'
|
|
124
125
|
import { buildTerminalRolePrompt, HERMES_VISIBLE_WORKER_FALLBACK_RULE } from './thinkpool-room-prompt.mjs'
|
|
@@ -869,15 +870,24 @@ function recordRoomSpend (tokens) {
|
|
|
869
870
|
// TP_BUDGET_OFF (that only governs the cap ledger; usage accounting should still
|
|
870
871
|
// run). input = full input incl. cache; cached = cache-read slice; both mapped to
|
|
871
872
|
// pricing.js's rowCostEur formula. No model → skip (can't price/label it).
|
|
872
|
-
function recordCodeUsage (model, usage) {
|
|
873
|
+
function recordCodeUsage (model, usage, provider) {
|
|
873
874
|
if (!codeAuthToken || !room || !model) return
|
|
874
875
|
const u = usage || {}
|
|
875
876
|
const input = (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0)
|
|
876
877
|
const output = u.output_tokens || 0
|
|
877
878
|
const cached = u.cache_read_input_tokens || 0
|
|
878
879
|
if (!input && !output) return
|
|
879
|
-
|
|
880
|
-
|
|
880
|
+
// Persist the public rate resolved for this exact runtime model. A private
|
|
881
|
+
// model can lack a public rate; its token row is still written and Settings
|
|
882
|
+
// marks only the estimate as pending rather than making one up.
|
|
883
|
+
priceForModel(model).then((rate) => budgetRpc('record_code_usage', {
|
|
884
|
+
p_room: room, p_model: model, p_input: input, p_output: output, p_cached: cached,
|
|
885
|
+
p_provider: provider || 'unknown',
|
|
886
|
+
p_input_usd_per_mtok: rate?.input ?? null,
|
|
887
|
+
p_output_usd_per_mtok: rate?.output ?? null,
|
|
888
|
+
p_cached_input_usd_per_mtok: rate?.cached ?? null,
|
|
889
|
+
p_rate_source: rate?.source ?? null,
|
|
890
|
+
})).catch(() => { /* usage accounting is never load-bearing for a turn */ })
|
|
881
891
|
}
|
|
882
892
|
// Throttled presence track() (presence.mjs) — a reconnect storm re-fires SUBSCRIBED and
|
|
883
893
|
// the handler re-track()s; without throttle that spirals into the per-client presence
|
|
@@ -2862,7 +2872,7 @@ function openStructured({ id, runtime = 'claude', model, models, effort, resume,
|
|
|
2862
2872
|
// Fold EVERY completed turn's output tokens into the room's durable monthly ledger
|
|
2863
2873
|
// (lanes + normal turns) — powers the Session-info "Session tokens" stat. (The cap that
|
|
2864
2874
|
// consumed this ledger was removed; the accounting stays.)
|
|
2865
|
-
if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage) }
|
|
2875
|
+
if (evt.kind === 'result' && evt.usage) { recordRoomSpend(evt.usage.output_tokens || 0); recordCodeUsage(evt.model, evt.usage, entry.provider) }
|
|
2866
2876
|
// FL-B1 (lane) — a flow lane that ENDS ITS TURN is done: a bypass lane runs its slice
|
|
2867
2877
|
// to completion in one turn, then narrates "done" and stops. Models often skip the
|
|
2868
2878
|
// explicit signal (FLOW_DONE / mark_flow_done), which left the slice stuck 'dispatched'
|
package/claude-session.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { sanitizeSession } from './transcript-sanitize.mjs'
|
|
|
21
21
|
import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
|
|
22
22
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
23
23
|
import { correctContext } from './context-windows.mjs'
|
|
24
|
-
import {
|
|
24
|
+
import { THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
25
25
|
import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
|
|
26
26
|
|
|
27
27
|
// The caret-pulled SDK's real version (^0.3.x auto-upgrades on restart). Resolved
|
|
@@ -183,28 +183,26 @@ const simplifyBlocks = (blocks = []) => blocks.map((b) => {
|
|
|
183
183
|
*/
|
|
184
184
|
const MODES = new Set(['default', 'acceptEdits', 'plan', 'bypassPermissions'])
|
|
185
185
|
|
|
186
|
-
//
|
|
186
|
+
// Periodic salience reminder. The full ThinkPool Code ruleset lives in
|
|
187
187
|
// `appendSystemPrompt` (baked once at session start), but on a long session — heavy
|
|
188
188
|
// tool output, and especially after auto-compaction — the model's attention drifts
|
|
189
189
|
// off a system prompt that sits behind the large host CLAUDE.md. The classic tell:
|
|
190
190
|
// the agent hands the room a host-local path (`open …`, "it's in ~/claude-shots/")
|
|
191
191
|
// or narrates instead of showing, forgetting it's driven from a phone. So we re-state
|
|
192
|
-
// the highest-drift rules
|
|
192
|
+
// the highest-drift rules in a <system-reminder> at session start, after recovery,
|
|
193
|
+
// and every fifth prompt. Ordinary turns get only the compact invariant and any
|
|
194
|
+
// route-specific guidance implied by the request.
|
|
193
195
|
// adjacent to where the model's attention actually is — the same trick the host
|
|
194
|
-
// harness uses to keep CLAUDE.md alive.
|
|
195
|
-
// A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended
|
|
196
|
-
//
|
|
196
|
+
// harness uses to keep CLAUDE.md alive. The shared capability router deliberately
|
|
197
|
+
// A live "ROOM NOW" tail (sibling lanes + active worktrees) is appended when it
|
|
198
|
+
// changes and on the same periodic refresh cadence.
|
|
197
199
|
const TP_ROOM_REMINDER = [
|
|
198
200
|
'You are Claude in a ThinkPool Code room, driven live from a phone or browser — NOT a local terminal. Keep using the room\'s features.',
|
|
199
|
-
|
|
200
|
-
THINKPOOL_DESIGN_DELIVERY_RULE,
|
|
201
|
-
'REMOTE USER: the people here may have no terminal and no access to this host — never ask them to run a local command, open a local file, or check something on the machine. Anything host-side, YOU run, and show the result.',
|
|
202
|
-
'SHOW, don\'t narrate: a picture of the result beats a wall of text on a phone — err toward more screenshots.',
|
|
201
|
+
THINKPOOL_RUNTIME_TURN_REMINDER,
|
|
203
202
|
'TERMINAL HIERARCHY: obey your authoritative TERMINAL ROLE. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Only conductor-capable roles fan worker slices out with spawn_terminal. Leaf, worker, Side, and managed Flow lanes work directly. Never use built-in invisible Task/Agent subagents or hijack a busy sibling.',
|
|
204
203
|
'PEER: before substantive work, check what the other lanes are doing (the ROOM NOW snapshot below, read_terminal for detail; list_sessions/read_session across rooms) — coordinate on shared files/branches instead of colliding.',
|
|
205
204
|
'WORKTREES: parallel lanes share one repo — before code edits run `git worktree list`; if linked worktrees exist, take your OWN worktree + branch, never the shared checkout or a branch another lane is on.',
|
|
206
205
|
'BUILD WORKFLOW (default, no magic word): right-size within your TERMINAL ROLE — a trivial ask or delegated slice you just do; a conductor-capable role with a genuinely decomposable build FIRST writes a short plan in chat, THEN fans worker slices into visible spawn_terminal lanes and verifies them. Worker/leaf/Side/managed Flow roles do not fan out. A requested separate conductor uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
|
|
207
|
-
'Verify before claiming done — show runtime evidence you produced, not "should work, go test it".',
|
|
208
206
|
].join(' ')
|
|
209
207
|
|
|
210
208
|
export function startClaudeSession({ cwd, model, effort: initialEffort = 'high', resume, env, mode: initialMode = 'default', onEvent, requestPermission, mcpServers, crossPostGate, crossRoomPostGate, didSpawnTarget = null, terminalRolePrompt, rolePrompt, blockSubagents = false, onSubmitPlan = null, onLaneDone = null, onReviewVerdict = null, reviewGate = null, lazy = false, roomContext = null, suggest = true, prepareCwd = null }) {
|
|
@@ -213,11 +211,15 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
213
211
|
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
214
212
|
// the room's STATE (2026-07-02 ask: "aware at all points that it's in a ThinkPool
|
|
215
213
|
// Code session"). A broken snapshot must never break a turn — fail-quiet to static.
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
214
|
+
const selectRoomContext = createRoomContextSelector(roomContext)
|
|
215
|
+
let userPromptNo = 0
|
|
216
|
+
let forceFullReminder = true
|
|
217
|
+
const roomReminder = (text, { promptIndex = 0, forceFull = false } = {}) => {
|
|
218
|
+
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull })
|
|
219
|
+
const live = selectRoomContext({ force: fullReminder })
|
|
219
220
|
const role = String(terminalRolePrompt || '').trim()
|
|
220
|
-
|
|
221
|
+
const guidance = fullReminder ? TP_ROOM_REMINDER : buildThinkPoolTurnGuidance({ text, promptIndex, forceFull })
|
|
222
|
+
return `<system-reminder>\n${fullReminder && role ? `${role}\n\n` : ''}${guidance}${live ? `\n\n${live}` : ''}\n</system-reminder>`
|
|
221
223
|
}
|
|
222
224
|
const ac = new AbortController()
|
|
223
225
|
let input = makeInputStream() // `let`: auto-restart swaps in a fresh stream
|
|
@@ -229,6 +231,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
229
231
|
// "No conversation found" (Max 2026-07-02). This is always a resumable id.
|
|
230
232
|
let persistedSessionId = resume || null
|
|
231
233
|
let lastTurnText = null // the most recent turn text, so a bad-resume recovery can re-deliver it
|
|
234
|
+
let lastTurnReminder = null
|
|
232
235
|
let closed = false
|
|
233
236
|
// Lazy boot (2026-07-02): a RESTORED-IDLE terminal returns a full session object but
|
|
234
237
|
// defers the expensive query() cold-start (MCP + settingSources, ~50s each) until its
|
|
@@ -852,6 +855,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
852
855
|
// old heuristic): trigger ('manual' for /compact vs 'auto') + the token
|
|
853
856
|
// count before compaction. Emit a real recap card the room can pin.
|
|
854
857
|
if (m.subtype === 'compact_boundary') {
|
|
858
|
+
forceFullReminder = true
|
|
855
859
|
emit({ kind: 'compaction', trigger: m.compact_metadata?.trigger || 'auto', preTokens: m.compact_metadata?.pre_tokens ?? null })
|
|
856
860
|
// Refresh the ctx% meter RIGHT AFTER compaction — it otherwise only updates at
|
|
857
861
|
// turn-end, so the mode row kept showing the stale PRE-compaction window
|
|
@@ -973,7 +977,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
973
977
|
restartTimer = null
|
|
974
978
|
if (closed) return
|
|
975
979
|
runQuery()
|
|
976
|
-
if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text: roomReminder() }]) }
|
|
980
|
+
if (replay != null) { turnActive = true; lastEvtTs = Date.now(); stalledSent = false; input.push([{ type: 'text', text: replay }, { type: 'text', text: lastTurnReminder || roomReminder(replay, { forceFull: true }) }]) }
|
|
977
981
|
}, 300)
|
|
978
982
|
break
|
|
979
983
|
}
|
|
@@ -1147,7 +1151,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1147
1151
|
if (replay != null) {
|
|
1148
1152
|
turnActive = true; lastEvtTs = Date.now(); stalledSent = false // re-arm liveness for the retried turn
|
|
1149
1153
|
// Match sendTurn's block shape: a slash command goes clean; a normal turn keeps the reminder.
|
|
1150
|
-
input.push(/^\s*\//.test(replay) ? [{ type: 'text', text: replay }] : [{ type: 'text', text: replay }, { type: 'text', text: roomReminder() }])
|
|
1154
|
+
input.push(/^\s*\//.test(replay) ? [{ type: 'text', text: replay }] : [{ type: 'text', text: replay }, { type: 'text', text: lastTurnReminder || roomReminder(replay, { forceFull: true }) }])
|
|
1151
1155
|
emitRaw({ kind: 'note', text: 'retrying the stalled turn on a fresh connection' })
|
|
1152
1156
|
}
|
|
1153
1157
|
}
|
|
@@ -1158,6 +1162,10 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1158
1162
|
// is queue-backed, so the pushed turn buffers and runs once the query is ready.
|
|
1159
1163
|
sendTurn(text) { if (!closed) { if (!started && prepareCwd) { try { const next = prepareCwd(); if (next) { cwd = next; opts.cwd = next } } catch { /* keep original cwd */ } } if (!started) runQuery(); turnActive = true; sawSuggestion = false; lastTurnText = String(text); if (sugTimer) { clearTimeout(sugTimer); sugTimer = null } lastEvtTs = Date.now(); stalledSent = false; stallRetried = false; forceStopped = false;
|
|
1160
1164
|
const t = String(text)
|
|
1165
|
+
const promptIndex = userPromptNo++
|
|
1166
|
+
const thisTurnForceFull = forceFullReminder
|
|
1167
|
+
forceFullReminder = /^\s*\/(?:compact|clear|reset)\b/i.test(t)
|
|
1168
|
+
lastTurnReminder = /^\s*\//.test(t) ? null : roomReminder(t, { promptIndex, forceFull: thisTurnForceFull })
|
|
1161
1169
|
// Arm the compaction window BEFORE the push: from here until the `compaction` milestone
|
|
1162
1170
|
// (or the turn's result) the SDK is allowed to be silent for minutes without the stall
|
|
1163
1171
|
// watchdog aborting it. See turn-stall.mjs — aborting a compaction is what produced
|
|
@@ -1171,7 +1179,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1171
1179
|
// recognized). So a slash command goes CLEAN; conversational turns keep the reminder.
|
|
1172
1180
|
// Normal turns never start with "/" (composeAgentStdin prepends the preamble), and the
|
|
1173
1181
|
// web already routes "/"-prefixed input as a command (pane.jsx), so this matches intent.
|
|
1174
|
-
input.push(/^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text:
|
|
1182
|
+
input.push(/^\s*\//.test(t) ? [{ type: 'text', text: t }] : [{ type: 'text', text: t }, { type: 'text', text: lastTurnReminder }])
|
|
1175
1183
|
} },
|
|
1176
1184
|
// Cold-boot the query WITHOUT sending a turn — the background warmer calls this on
|
|
1177
1185
|
// lazily-restored idle terminals so they're ready before the user clicks them.
|
package/codex-session.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import readline from 'node:readline'
|
|
|
30
30
|
import { buildCodexAppServerArgs, codexAppServerGate, createCodexAppServer } from './codex-app-server.mjs'
|
|
31
31
|
import { CodexEventMapper } from './codex-event-mapper.mjs'
|
|
32
32
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
33
|
-
import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE } from './thinkpool-room-prompt.mjs'
|
|
33
|
+
import { CODEX_THINKPOOL_FIRST_TURN_PREAMBLE, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
34
34
|
|
|
35
35
|
const DEFAULT_SANDBOX = 'workspace-write'
|
|
36
36
|
const SAFE_SANDBOXES = new Set(['read-only', 'workspace-write', 'danger-full-access'])
|
|
@@ -214,12 +214,14 @@ export const CODEX_ROOM_CASCADE_REMINDER = [
|
|
|
214
214
|
'THINKPOOL ROOM WORKFLOW: obey the authoritative TERMINAL ROLE above. Conductors are independent main terminals; Ensemble lanes are workers only. When a person explicitly asks to open, launch, start, or spawn a separate Cascade/conductor terminal, use open_main_terminal — NEVER spawn_terminal. Use spawn_terminal only for worker slices from a role permitted to delegate. If open_main_terminal is unavailable, say so; never substitute an Ensemble child. Leaf, worker, Side, and managed Flow lanes work directly and must not delegate. Never use hidden in-process subagents. Each worker owns its own worktree. For a genuinely decomposable task in a conductor-capable role, state a short plan in chat, use sliceType scaffold for mechanical work, feature/fix for builders, and review for adversarial verification; review must use a balanced model tier. Read results, verify the integrated outcome, and close every worker you spawned.',
|
|
215
215
|
].join(' ')
|
|
216
216
|
|
|
217
|
-
export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false }) {
|
|
217
|
+
export function buildCodexPrompt({ text, terminalRolePrompt, rolePrompt, roomContext, firstTurn = false, promptIndex = 0, forceFullReminder = false }) {
|
|
218
218
|
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
219
|
+
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: forceFullReminder })
|
|
219
220
|
const additions = [
|
|
220
221
|
firstTurn ? CODEX_THINKPOOL_FIRST_TURN_PREAMBLE : '',
|
|
221
|
-
terminalRolePrompt,
|
|
222
|
-
|
|
222
|
+
fullReminder ? terminalRolePrompt : '',
|
|
223
|
+
buildThinkPoolTurnGuidance({ text, promptIndex, forceFull: forceFullReminder }),
|
|
224
|
+
fullReminder ? (rolePrompt || CODEX_ROOM_CASCADE_REMINDER) : '',
|
|
223
225
|
context,
|
|
224
226
|
].map((v) => String(v || '').trim()).filter(Boolean)
|
|
225
227
|
if (!additions.length) return String(text ?? '')
|
|
@@ -395,6 +397,9 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
395
397
|
})
|
|
396
398
|
let sessionId = resumeUsable ? requestedResume : null
|
|
397
399
|
let turnNo = sessionId ? 1 : 0
|
|
400
|
+
let userPromptNo = 0
|
|
401
|
+
let forceFullReminder = true
|
|
402
|
+
const selectRoomContext = createRoomContextSelector(roomContext)
|
|
398
403
|
let child = null
|
|
399
404
|
let aborted = false
|
|
400
405
|
let ended = false
|
|
@@ -688,7 +693,16 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
688
693
|
// Claude receives these through the Agent SDK's system-reminder path.
|
|
689
694
|
// Codex is one-shot per turn, so give it the same lane identity and live
|
|
690
695
|
// room awareness in a compact envelope before the person's text.
|
|
691
|
-
const
|
|
696
|
+
const fullReminder = usesFullThinkPoolReminder({ promptIndex: next.promptIndex, forceFull: next.forceFullReminder })
|
|
697
|
+
const prompt = buildCodexPrompt({
|
|
698
|
+
text: next.text,
|
|
699
|
+
terminalRolePrompt,
|
|
700
|
+
rolePrompt,
|
|
701
|
+
roomContext: () => selectRoomContext({ force: fullReminder }),
|
|
702
|
+
firstTurn: turnNo === 0,
|
|
703
|
+
promptIndex: next.promptIndex,
|
|
704
|
+
forceFullReminder: next.forceFullReminder,
|
|
705
|
+
})
|
|
692
706
|
const usedAppServer = await runAppServer(prompt, next.options)
|
|
693
707
|
if (!usedAppServer) await runExec(prompt, next.options)
|
|
694
708
|
turnNo++
|
|
@@ -704,11 +718,22 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
704
718
|
get started() { return turnNo > 0 || turnActive },
|
|
705
719
|
sendTurn(text, options = {}) {
|
|
706
720
|
if (ended) return false
|
|
721
|
+
const promptIndex = userPromptNo++
|
|
722
|
+
const thisTurnForceFull = forceFullReminder
|
|
723
|
+
forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
|
|
707
724
|
if (!turnActive && turnNo === 0 && prepareCwd) {
|
|
708
725
|
try { cwd = prepareCwd() || cwd } catch { /* keep original cwd */ }
|
|
709
726
|
}
|
|
710
727
|
if (turnActive && appServer && activeTurnId) {
|
|
711
|
-
const
|
|
728
|
+
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: thisTurnForceFull })
|
|
729
|
+
const prompt = buildCodexPrompt({
|
|
730
|
+
text,
|
|
731
|
+
terminalRolePrompt,
|
|
732
|
+
rolePrompt,
|
|
733
|
+
roomContext: () => selectRoomContext({ force: fullReminder }),
|
|
734
|
+
promptIndex,
|
|
735
|
+
forceFullReminder: thisTurnForceFull,
|
|
736
|
+
})
|
|
712
737
|
const targetTurnId = activeTurnId
|
|
713
738
|
// Serialize authored follow-ups: parallel RPCs can complete out of order.
|
|
714
739
|
steerChain = steerChain.then(() => appServer.steer({ threadId: sessionId, turnId: targetTurnId, input: prompt, images: Array.isArray(options.images) ? options.images : [] }))
|
|
@@ -717,7 +742,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
717
742
|
// pre-write failure. Timeout/close after write is delivery-uncertain;
|
|
718
743
|
// replaying it as a new turn can execute the same instruction twice.
|
|
719
744
|
if (error?.delivery === 'rejected' || error?.delivery === 'not_sent') {
|
|
720
|
-
queue.push({ text, options })
|
|
745
|
+
queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
|
|
721
746
|
if (!turnActive && queue.length === 1) pump()
|
|
722
747
|
} else {
|
|
723
748
|
try { onEvent?.({ kind: 'error', message: 'Codex steering delivery is uncertain; the message was not replayed automatically.', recoverable: true }) } catch { /* noop */ }
|
|
@@ -725,7 +750,7 @@ export function startCodexSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
725
750
|
})
|
|
726
751
|
return true
|
|
727
752
|
}
|
|
728
|
-
queue.push({ text, options })
|
|
753
|
+
queue.push({ text, options, promptIndex, forceFullReminder: thisTurnForceFull })
|
|
729
754
|
// start the pump if idle
|
|
730
755
|
if (queue.length === 1) pump()
|
|
731
756
|
return true
|
package/cross-terminal.mjs
CHANGED
|
@@ -14,7 +14,9 @@ import { BLOCKED_REASON, projectLaneLifecycle } from './lane-lifecycle.mjs'
|
|
|
14
14
|
// Bounds — the pull tool is self-limiting (the agent only calls it when it needs
|
|
15
15
|
// to), but a runaway loop must still be capped (the cost-guard / C10 class).
|
|
16
16
|
export const PEEK = {
|
|
17
|
-
|
|
17
|
+
// Keep the implicit pull concise for consumer turns. Agents can request more
|
|
18
|
+
// explicitly when a diagnosis genuinely needs deeper sibling history.
|
|
19
|
+
defaultLines: 20,
|
|
18
20
|
maxLines: 200,
|
|
19
21
|
perTurnCap: 10, // read_terminal calls allowed per user turn (bridge resets it)
|
|
20
22
|
lineCap: 200, // per-line truncation
|
package/hermes-session.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { HERMES_PLAN_SAFE_MCP_TOOLS, HERMES_QUESTION_MCP_TOOL, hermesExactInvent
|
|
|
10
10
|
import { startCodexMcpHttp } from './codex-mcp-http.mjs'
|
|
11
11
|
import { autoAllow, classifyRisk } from './claude-session.mjs'
|
|
12
12
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
13
|
+
import { buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
13
14
|
|
|
14
15
|
export const HERMES_COMMAND = 'thinkpool'
|
|
15
16
|
export const HERMES_ACP_PROTOCOL_VERSION = 1
|
|
@@ -34,6 +35,20 @@ const modelList = (state) => (state?.availableModels || []).map((item) => ({ val
|
|
|
34
35
|
const mcpDescriptor = (url) => ({ type: 'http', name: 'thinkpool', url, headers: [] })
|
|
35
36
|
const createAcpClient = (options) => new AcpClient(options)
|
|
36
37
|
|
|
38
|
+
export function buildHermesPromptText({ text, firstTurn = false, terminalRolePrompt, rolePrompt, roomContext, promptIndex = 0, forceFullReminder = false } = {}) {
|
|
39
|
+
const context = typeof roomContext === 'function' ? roomContext() : roomContext
|
|
40
|
+
const inThinkPoolRoom = !!(terminalRolePrompt || rolePrompt || context)
|
|
41
|
+
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull: forceFullReminder })
|
|
42
|
+
const preamble = [
|
|
43
|
+
firstTurn || fullReminder ? terminalRolePrompt : '',
|
|
44
|
+
inThinkPoolRoom ? buildThinkPoolTurnGuidance({ text, promptIndex, forceFull: forceFullReminder }) : '',
|
|
45
|
+
firstTurn || fullReminder ? rolePrompt : '',
|
|
46
|
+
context,
|
|
47
|
+
].filter(Boolean).join('\n\n')
|
|
48
|
+
const body = String(text ?? '')
|
|
49
|
+
return preamble ? `${preamble}\n\n${body}` : body
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
const permissionOutcome = (decision, options = []) => {
|
|
38
53
|
if (decision === 'deny' || !decision) return { outcome: 'cancelled' }
|
|
39
54
|
const preferred = decision === 'always'
|
|
@@ -65,6 +80,9 @@ export function startHermesSession({
|
|
|
65
80
|
let started = false
|
|
66
81
|
let crashed = false
|
|
67
82
|
let firstTurn = true
|
|
83
|
+
let userPromptNo = 0
|
|
84
|
+
let forceFullReminder = true
|
|
85
|
+
const selectRoomContext = createRoomContextSelector(roomContext)
|
|
68
86
|
let stderrTail = ''
|
|
69
87
|
let promptChain = Promise.resolve()
|
|
70
88
|
let activeTurnId = 0
|
|
@@ -379,21 +397,28 @@ export function startHermesSession({
|
|
|
379
397
|
await boot()
|
|
380
398
|
}
|
|
381
399
|
|
|
382
|
-
function promptBlocks(text, options = {}) {
|
|
400
|
+
function promptBlocks(text, options = {}, { promptIndex = 0, forceFull = false } = {}) {
|
|
383
401
|
const rawText = String(text)
|
|
384
402
|
// Hermes handles /steer, /queue, /compact and /reset only when the slash is
|
|
385
403
|
// the first character of a pure-text prompt.
|
|
386
404
|
if (/^\s*\//.test(rawText)) return [{ type: 'text', text: rawText }]
|
|
387
|
-
const
|
|
388
|
-
const
|
|
389
|
-
|
|
405
|
+
const fullReminder = usesFullThinkPoolReminder({ promptIndex, forceFull })
|
|
406
|
+
const blocks = [{ type: 'text', text: buildHermesPromptText({
|
|
407
|
+
text: rawText,
|
|
408
|
+
firstTurn,
|
|
409
|
+
terminalRolePrompt,
|
|
410
|
+
rolePrompt,
|
|
411
|
+
roomContext: () => selectRoomContext({ force: fullReminder }),
|
|
412
|
+
promptIndex,
|
|
413
|
+
forceFullReminder: forceFull,
|
|
414
|
+
}) }]
|
|
390
415
|
for (const imagePath of (Array.isArray(options.images) ? options.images : [])) {
|
|
391
416
|
try { blocks.push(imageBlock(imagePath)) } catch { /* quoted host path remains in text */ }
|
|
392
417
|
}
|
|
393
418
|
return blocks
|
|
394
419
|
}
|
|
395
420
|
|
|
396
|
-
async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId } = {}) {
|
|
421
|
+
async function runPrompt(text, options = {}, { steering = false, turnId = activeTurnId, promptIndex = 0, forceFull = false } = {}) {
|
|
397
422
|
try { await boot() }
|
|
398
423
|
catch (error) {
|
|
399
424
|
if (abortedTurns.has(turnId)) return { stopReason: 'cancelled' }
|
|
@@ -405,7 +430,7 @@ export function startHermesSession({
|
|
|
405
430
|
try {
|
|
406
431
|
result = await client.request('session/prompt', {
|
|
407
432
|
sessionId,
|
|
408
|
-
prompt: promptBlocks(body, options),
|
|
433
|
+
prompt: promptBlocks(body, options, { promptIndex, forceFull }),
|
|
409
434
|
messageId: randomUUID(),
|
|
410
435
|
}, 0)
|
|
411
436
|
} catch (error) {
|
|
@@ -442,6 +467,9 @@ export function startHermesSession({
|
|
|
442
467
|
get models() { return [] },
|
|
443
468
|
sendTurn(text, options = {}) {
|
|
444
469
|
if (ended) return false
|
|
470
|
+
const promptIndex = userPromptNo++
|
|
471
|
+
const thisTurnForceFull = forceFullReminder
|
|
472
|
+
forceFullReminder = /^\s*\/(?:compact|reset|clear)\b/i.test(String(text || ''))
|
|
445
473
|
// A crash never replays work by itself. A later explicit turn is the
|
|
446
474
|
// authority to launch a fresh ACP process and resume the same native
|
|
447
475
|
// session id; this is the recovery path the old permanent latch blocked.
|
|
@@ -449,14 +477,14 @@ export function startHermesSession({
|
|
|
449
477
|
// A busy prompt is a genuine ACP /steer call and may run concurrently.
|
|
450
478
|
if (turnActive) {
|
|
451
479
|
const turnId = activeTurnId
|
|
452
|
-
void runPrompt(text, options, { steering: true, turnId }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
|
|
480
|
+
void runPrompt(text, options, { steering: true, turnId, promptIndex, forceFull: thisTurnForceFull }).catch((error) => emit({ kind: 'error', message: `Hermes steering failed: ${error?.message || error}`, recoverable: true }))
|
|
453
481
|
return true
|
|
454
482
|
}
|
|
455
483
|
const turnId = ++activeTurnId
|
|
456
484
|
// Claim the turn synchronously, before the cold safety probe/import. The
|
|
457
485
|
// room can now show Thinking + Stop for the whole accepted lifecycle.
|
|
458
486
|
turnActive = true
|
|
459
|
-
promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId })).catch((error) => {
|
|
487
|
+
promptChain = promptChain.then(() => runPrompt(text, options, { steering: false, turnId, promptIndex, forceFull: thisTurnForceFull })).catch((error) => {
|
|
460
488
|
turnActive = false
|
|
461
489
|
emit({ kind: 'error', message: `Hermes turn failed: ${error?.message || error}`, recoverable: true })
|
|
462
490
|
})
|
package/model-prices.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Public model-price resolver for the bridge's usage ledger.
|
|
2
|
+
// Runtime model catalogs tell us what ran, but not what a provider bills for
|
|
3
|
+
// it. LiteLLM maintains a public catalog; cache its validated data locally and
|
|
4
|
+
// persist the resolved rate beside each turn. A failed refresh never blocks a
|
|
5
|
+
// turn or drops token accounting.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import os from 'node:os'
|
|
9
|
+
import path from 'node:path'
|
|
10
|
+
|
|
11
|
+
const CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
|
|
12
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
|
13
|
+
const CACHE_MAX_BYTES = 4 * 1024 * 1024
|
|
14
|
+
const cachePath = () => process.env.TP_MODEL_PRICE_CACHE || path.join(process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'), 'model-prices.json')
|
|
15
|
+
|
|
16
|
+
// First-party runtimes remain priced while offline or before the catalog loads.
|
|
17
|
+
const FALLBACKS = Object.freeze({
|
|
18
|
+
'gpt-5.6-sol': { input: 5, cached: 0.5, output: 30 },
|
|
19
|
+
'gpt-5.6-terra': { input: 2.5, cached: 0.25, output: 15 },
|
|
20
|
+
'gpt-5.6-luna': { input: 1, cached: 0.1, output: 6 },
|
|
21
|
+
'gpt-5.4': { input: 2.5, cached: 0.25, output: 15 },
|
|
22
|
+
'gpt-5.4-mini': { input: 0.75, cached: 0.075, output: 4.5 },
|
|
23
|
+
'gpt-5.4-nano': { input: 0.2, cached: 0.02, output: 1.25 },
|
|
24
|
+
'claude-opus-4-8': { input: 5, cached: 0.5, output: 25 },
|
|
25
|
+
'claude-fable-5': { input: 10, cached: 1, output: 50 },
|
|
26
|
+
'claude-sonnet-5': { input: 2, cached: 0.2, output: 10 },
|
|
27
|
+
'claude-haiku-4-5': { input: 1, cached: 0.1, output: 5 },
|
|
28
|
+
'glm-5.2': { input: 1.4, cached: 0.26, output: 4.4 },
|
|
29
|
+
'glm-4.6': { input: 0.6, cached: 0.11, output: 2.2 },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function cleanRate(raw) {
|
|
33
|
+
// Catalog rates are per token; normalize at the persistence boundary so a
|
|
34
|
+
// decimal like 0.0000001 does not become 0.09999999999999999 in Settings.
|
|
35
|
+
const perMillion = (value) => Math.round(Number(value) * 1_000_000 * 1_000_000) / 1_000_000
|
|
36
|
+
const input = perMillion(raw?.input_cost_per_token)
|
|
37
|
+
const output = perMillion(raw?.output_cost_per_token)
|
|
38
|
+
const cached = perMillion(raw?.cache_read_input_token_cost ?? raw?.cache_read_input_tokens_cost)
|
|
39
|
+
if (!Number.isFinite(input) || input < 0 || !Number.isFinite(output) || output < 0) return null
|
|
40
|
+
return { input, output, cached: Number.isFinite(cached) && cached >= 0 ? cached : null }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function modelPriceKeys(model) {
|
|
44
|
+
const raw = String(model || '').trim()
|
|
45
|
+
if (!raw) return []
|
|
46
|
+
const withoutMarker = raw.replace(/\[[^\]]+\]/g, '')
|
|
47
|
+
const withoutPrefix = withoutMarker.replace(/^[^:/]+:/, '')
|
|
48
|
+
const tail = withoutPrefix.includes('/') ? withoutPrefix.slice(withoutPrefix.lastIndexOf('/') + 1) : withoutPrefix
|
|
49
|
+
return [...new Set([raw, withoutMarker, withoutPrefix, tail].filter(Boolean))]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readCache() {
|
|
53
|
+
try {
|
|
54
|
+
const file = cachePath()
|
|
55
|
+
if ((Number(fs.statSync(file).size) || 0) > CACHE_MAX_BYTES) return null
|
|
56
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
57
|
+
return parsed?.models && typeof parsed.models === 'object' ? parsed : null
|
|
58
|
+
} catch { return null }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function writeCache(models) {
|
|
62
|
+
try {
|
|
63
|
+
const file = cachePath()
|
|
64
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
65
|
+
fs.writeFileSync(file, JSON.stringify({ fetchedAt: Date.now(), models }), { mode: 0o600 })
|
|
66
|
+
} catch { /* cache is opportunistic */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function rateFromCatalog(model, models) {
|
|
70
|
+
for (const key of modelPriceKeys(model)) {
|
|
71
|
+
const rate = cleanRate(models?.[key])
|
|
72
|
+
if (rate) return rate
|
|
73
|
+
}
|
|
74
|
+
for (const key of modelPriceKeys(model)) {
|
|
75
|
+
const fallback = FALLBACKS[key.toLowerCase()]
|
|
76
|
+
if (fallback) return fallback
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createModelPriceResolver({ fetchImpl = globalThis.fetch } = {}) {
|
|
82
|
+
let cached = readCache()
|
|
83
|
+
let refresh = null
|
|
84
|
+
const refreshCatalog = async () => {
|
|
85
|
+
if (refresh) return refresh
|
|
86
|
+
refresh = (async () => {
|
|
87
|
+
let timer
|
|
88
|
+
try {
|
|
89
|
+
const controller = new AbortController()
|
|
90
|
+
timer = setTimeout(() => controller.abort(), 4_000)
|
|
91
|
+
const response = await fetchImpl(CATALOG_URL, { signal: controller.signal })
|
|
92
|
+
if (!response?.ok) return cached?.models || null
|
|
93
|
+
const models = await response.json()
|
|
94
|
+
if (!models || typeof models !== 'object') return cached?.models || null
|
|
95
|
+
cached = { fetchedAt: Date.now(), models }
|
|
96
|
+
writeCache(models)
|
|
97
|
+
return models
|
|
98
|
+
} catch { return cached?.models || null }
|
|
99
|
+
finally { clearTimeout(timer); refresh = null }
|
|
100
|
+
})()
|
|
101
|
+
return refresh
|
|
102
|
+
}
|
|
103
|
+
return async (model) => {
|
|
104
|
+
const current = rateFromCatalog(model, cached?.models)
|
|
105
|
+
if (current) return { ...current, source: cached?.models ? 'litellm-cache' : 'bridge-fallback' }
|
|
106
|
+
const stale = !cached || (Date.now() - Number(cached.fetchedAt || 0)) > CACHE_TTL_MS
|
|
107
|
+
const models = stale ? await refreshCatalog() : cached?.models
|
|
108
|
+
const rate = rateFromCatalog(model, models)
|
|
109
|
+
return rate ? { ...rate, source: models ? 'litellm' : 'bridge-fallback' } : null
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const priceForModel = createModelPriceResolver()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "thinkpool-pair",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.259",
|
|
4
4
|
"description": "Share a local coding-agent CLI (Claude Code, Codex, Gemini, Aider, …) into a ThinkPool Code room, live.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"supabase-key.mjs",
|
|
81
81
|
"provider.mjs",
|
|
82
82
|
"providers.mjs",
|
|
83
|
+
"model-prices.mjs",
|
|
83
84
|
"README.md"
|
|
84
85
|
],
|
|
85
86
|
"scripts": {
|
|
@@ -1,21 +1,154 @@
|
|
|
1
1
|
// Shared delivery contract for structured agents running inside ThinkPool Code.
|
|
2
|
-
// Claude receives this
|
|
3
|
-
//
|
|
2
|
+
// Claude receives this in its system prompt; all runtimes receive a compact
|
|
3
|
+
// per-turn invariant plus the full contract at session start, after a reset, and
|
|
4
|
+
// every fifth user prompt. Keep runtime-specific
|
|
4
5
|
// orchestration rules in their own drivers — these are the cross-runtime truths
|
|
5
|
-
// about
|
|
6
|
-
|
|
6
|
+
// about available capabilities and how finished work reaches the people.
|
|
7
|
+
// One canonical intent → capability router for every structured runtime. Tool
|
|
8
|
+
// schemas still carry their detailed argument contracts; this compact registry
|
|
9
|
+
// tells a fresh agent WHEN ThinkPool expects each capability without requiring
|
|
10
|
+
// the people in the room to know a tool name or summon word.
|
|
11
|
+
export const THINKPOOL_CAPABILITY_ROUTES = Object.freeze([
|
|
12
|
+
Object.freeze({
|
|
13
|
+
id: 'room-awareness',
|
|
14
|
+
tools: Object.freeze(['read_terminal']),
|
|
15
|
+
rule: 'When the request depends on another lane, a sibling may overlap the work, or a person refers to another terminal, inspect it with read_terminal before acting.',
|
|
16
|
+
}),
|
|
17
|
+
Object.freeze({
|
|
18
|
+
id: 'cross-room-awareness',
|
|
19
|
+
tools: Object.freeze(['list_sessions', 'read_session']),
|
|
20
|
+
rule: 'When work depends on another ThinkPool room, use list_sessions then read_session instead of asking the people to relay host-side state.',
|
|
21
|
+
}),
|
|
22
|
+
Object.freeze({
|
|
23
|
+
id: 'visible-handoff',
|
|
24
|
+
tools: Object.freeze(['post_to_terminal', 'post_to_session']),
|
|
25
|
+
rule: 'When the people want a handoff, read the target first, then use post_to_terminal or post_to_session; let the room approval contract handle consent.',
|
|
26
|
+
}),
|
|
27
|
+
Object.freeze({
|
|
28
|
+
id: 'work-routing',
|
|
29
|
+
tools: Object.freeze(['spawn_terminal', 'open_main_terminal', 'close_terminal']),
|
|
30
|
+
rule: 'For genuinely decomposable work in a conductor-capable role, open visible worker slices with spawn_terminal, collect and verify them, then close_terminal. An explicitly requested separate Cascade/conductor uses open_main_terminal, never spawn_terminal.',
|
|
31
|
+
}),
|
|
32
|
+
Object.freeze({
|
|
33
|
+
id: 'room-question',
|
|
34
|
+
tools: Object.freeze(['request_user_input']),
|
|
35
|
+
rule: 'When a missing choice genuinely blocks useful progress, use request_user_input so the question is answerable in the room; otherwise make a safe in-scope assumption and continue.',
|
|
36
|
+
}),
|
|
37
|
+
Object.freeze({
|
|
38
|
+
id: 'visual-proof',
|
|
39
|
+
tools: Object.freeze(['preview_start', 'preview_capture', 'preview_inspect', 'preview_stop']),
|
|
40
|
+
rule: 'For built visual work, run the build, use preview_start, preview_capture at desktop and mobile, preview_inspect when rendered state matters, and preview_stop when finished. Surface PNG evidence only when no interactive source-backed Design card/popup is displayed.',
|
|
41
|
+
}),
|
|
42
|
+
Object.freeze({
|
|
43
|
+
id: 'external-research',
|
|
44
|
+
tools: Object.freeze(['research']),
|
|
45
|
+
rule: 'For current external facts that materially benefit from multi-source verification, offer the metered research lane and call research only after the people agree.',
|
|
46
|
+
}),
|
|
47
|
+
Object.freeze({
|
|
48
|
+
id: 'flow-completion',
|
|
49
|
+
tools: Object.freeze(['submit_flow_plan', 'mark_flow_done', 'submit_flow_review', 'read_review_file', 'run_review_check']),
|
|
50
|
+
rule: 'Managed Flow roles use only their exposed completion and immutable-review tools: submit_flow_plan, mark_flow_done, submit_flow_review, read_review_file, and run_review_check.',
|
|
51
|
+
}),
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
export const THINKPOOL_ROUTED_TOOLS = Object.freeze([
|
|
55
|
+
...new Set(THINKPOOL_CAPABILITY_ROUTES.flatMap((route) => route.tools)),
|
|
56
|
+
])
|
|
57
|
+
|
|
58
|
+
export function renderThinkPoolCapabilityRoutes(routes = THINKPOOL_CAPABILITY_ROUTES) {
|
|
59
|
+
return routes.map((route) => `${route.id}: ${route.rule}`).join(' ')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const THINKPOOL_AGENT_CONTRACT = [
|
|
63
|
+
'THINKPOOL-FIRST OPERATING CONTRACT (authoritative): ThinkPool room capabilities are your normal operating surface, not optional enrichment. Before acting on every request, infer which exposed ThinkPool capabilities materially improve room visibility, coordination, delivery, or verification and use them without waiting for the people to know a tool name, magic word, or workflow.',
|
|
64
|
+
`DEFAULT ROUTING: ${renderThinkPoolCapabilityRoutes()}`,
|
|
65
|
+
'DEFAULT DOES NOT MEAN GRATUITOUS: use only capabilities exposed to your current role and only when relevant. Honor explicit steering such as single-lane, no research, do not contact another room, or do not use a named ThinkPool feature. Metered and side-effecting capabilities still obey their stated offer, approval, and consent contracts.',
|
|
66
|
+
].join(' ')
|
|
67
|
+
|
|
68
|
+
export const THINKPOOL_DESIGN_INTERACTION_RULE = 'DESIGN EDITING MODEL: the user edits through a source-backed mockup card\'s Work on design action, which opens the fullscreen mockup/Design popup; never ask them to locate or edit HTML. Popup edits are optimistic drafts only. Apply sends the bounded batch to the producing lane, that lane edits the canonical authored HTML, and a successful desktop+mobile render publishes the next verified revision back into the popup. Never call an in-popup draft saved or live. A preview_capture card is visual evidence, not an editable Design artifact.'
|
|
69
|
+
|
|
70
|
+
export const THINKPOOL_DESIGN_DELIVERY_RULE = `THINKPOOL DESIGN (mandatory for authored HTML): save the editable HTML source in the workspace, then use the $TP_MOCKUP_OUTBOX render helper so the room receives a source-backed Thinkpool Design card with both desktop (1440×900) and mobile (390×844) previews. The interactive Design card/popup is the visible result: the renders remain required verification inputs, but when that card is displayed do not also surface duplicate inline PNGs. Surface desktop/mobile PNGs only when no interactive source-backed Design artifact is available. ${THINKPOOL_DESIGN_INTERACTION_RULE} When the requested Design surface is an existing product page or route, first build and capture the actual route at both viewports, then read its current source, styles, copy, fonts, and assets. Derive the editable Design artifact from that evidence and compare both artifact captures against the real route before delivery. Preserve the real page faithfully except for explicitly proposed edits—never hand-recreate it from memory, simplify it, replace it with generic mockup content, or label an approximation as the product. If a faithful editable artifact cannot be produced, surface the actual route captures and say that Design editing is unavailable for that surface. Never paste raw HTML into chat, send an .html file as the room deliverable, or substitute a bare URL or single screenshot for this card. A shareable browser URL may accompany the card, but never replaces it.`
|
|
7
71
|
|
|
8
72
|
export const THINKPOOL_REMOTE_DELIVERY_RULES = Object.freeze([
|
|
9
73
|
'REMOTE USER (authoritative default for ThinkPool Code rooms): unless a person explicitly says they are at the host machine, assume everyone driving this room is remote — possibly on a phone — with no terminal and no access to the host filesystem. Never ask them to run a local command, open a local file, or "go check" something on the machine. Anything host-side, you run yourself and show the result in the room. Only suggest actions they can actually do from the room UI or a browser.',
|
|
10
74
|
'LINKS & ARTIFACTS: a local filesystem path, file:// URL, localhost/127.0.0.1 address, or host-only preview is useless to a remote room. Every link you surface must be reachable by the people in the room.',
|
|
11
75
|
'Whenever you produce HTML or shareable markup — a demo, mockup, preview, report, or page — publish it to a browser-renderable GitHub-shareable URL, normally GitHub Pages (or an equivalent URL that actually renders; raw.githubusercontent.com serves HTML as plain text). Give the room that shareable URL in addition to the Thinkpool Design card, never instead of it and never only as a local path. If you cannot publish it, say so instead of falling back to a host-only path.',
|
|
12
|
-
'SHOW VISUAL WORK: whenever you build, change, or fix anything visual
|
|
76
|
+
'SHOW VISUAL WORK: whenever you build, change, or fix anything visual, show the result in the room. A source-backed Thinkpool Design card/popup is already the interactive visible result, so do not additionally surface duplicate PNGs. When no interactive Design artifact is available, capture and surface desktop/mobile PNG evidence inline.',
|
|
13
77
|
'BRIDGE PREVIEWS: for a built web UI, use preview_start (default root: dist), then preview_capture for exact desktop 1440x900 and mobile 390x844 PNGs, and preview_inspect when DOM text or selector geometry helps. Run the project build first and stop the preview server with preview_stop when done.',
|
|
14
78
|
THINKPOOL_DESIGN_DELIVERY_RULE,
|
|
15
79
|
'VERIFY BEFORE CLAIMING: run or serve what you changed, observe it, and show the evidence in the room — the PNG, passing test output, or real response. If something could not be verified, say exactly what remains unverified.',
|
|
16
80
|
])
|
|
17
81
|
|
|
18
|
-
//
|
|
82
|
+
// Compact enough to repeat adjacent to every normal user turn. The full remote
|
|
83
|
+
// delivery contract is still installed at session boot where the runtime offers
|
|
84
|
+
// a durable system channel; this reminder keeps the high-drift decisions salient
|
|
85
|
+
// after long tool runs, resumes, and compaction without duplicating the manual.
|
|
86
|
+
export const THINKPOOL_RUNTIME_TURN_REMINDER = [
|
|
87
|
+
THINKPOOL_AGENT_CONTRACT,
|
|
88
|
+
'REMOTE DELIVERY: assume the people are remote unless they explicitly say otherwise. Run host-side work yourself; never hand them a local path, file:// or localhost URL, or ask them to use the host terminal. Surface reachable links and inline evidence in the room.',
|
|
89
|
+
'VISUAL DELIVERY: build first and verify exact desktop and mobile renders. If a source-backed Thinkpool Design card/popup is displayed, do not separately surface its PNGs; if no interactive Design artifact is available, surface the desktop/mobile PNG evidence inline. Stop bridge previews when finished.',
|
|
90
|
+
THINKPOOL_DESIGN_INTERACTION_RULE,
|
|
91
|
+
'VERIFY BEFORE CLAIMING: run or serve what changed and show the real response, passing output, or rendered evidence; state exactly what remains unverified.',
|
|
92
|
+
].join(' ')
|
|
93
|
+
|
|
94
|
+
// Repeating the complete router on every turn made a durable operating contract
|
|
95
|
+
// compete with the user's actual request. The model still gets a small invariant
|
|
96
|
+
// on ordinary turns, while periodic and recovery turns refresh the complete map.
|
|
97
|
+
export const THINKPOOL_FULL_REMINDER_INTERVAL = 5
|
|
98
|
+
|
|
99
|
+
export const THINKPOOL_RUNTIME_SALIENCE_REMINDER = 'THINKPOOL: prefer relevant exposed room tools unless the user explicitly opts out; obey the durable terminal role and consent rules. Assume the people are remote, do host work yourself, and deliver reachable verified results.'
|
|
100
|
+
|
|
101
|
+
const ROUTE_TRIGGERS = Object.freeze({
|
|
102
|
+
'room-awareness': /\b(other|another|sibling|peer)\s+(lane|terminal)|\bread_terminal\b/i,
|
|
103
|
+
'cross-room-awareness': /\b(other|another|cross[- ]?room|cross[- ]?session)\s+(room|session)|\b(list_sessions|read_session)\b/i,
|
|
104
|
+
'visible-handoff': /\b(hand[ -]?off|tell|send|post)\b.{0,40}\b(lane|terminal|room|session|agent)\b|\b(post_to_terminal|post_to_session)\b/i,
|
|
105
|
+
'work-routing': /\b(parallel|delegate|worker|sub[- ]?terminal|conductor|cascade|spawn_terminal|open_main_terminal|close_terminal)\b/i,
|
|
106
|
+
'room-question': /\b(request_user_input)\b/i,
|
|
107
|
+
'visual-proof': /\b(ui|ux|visual|design|frontend|html|css|page|route|mockup|screenshot|responsive|desktop|mobile|preview)\b/i,
|
|
108
|
+
'external-research': /\b(research|latest|current|look up|browse|web search|online source|verify online)\b/i,
|
|
109
|
+
'flow-completion': /\b(flow|submit_flow_plan|mark_flow_done|submit_flow_review|read_review_file|run_review_check)\b/i,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
const DESIGN_ROUTE_REMINDER = 'DESIGN ROUTE: authored HTML must produce a source-backed Thinkpool Design card/popup with verified desktop and mobile renders. The popup is where users edit; do not duplicate its PNGs inline. Use inline PNG evidence only when no interactive Design artifact is available.'
|
|
113
|
+
|
|
114
|
+
export function usesFullThinkPoolReminder({ promptIndex = 0, forceFull = false } = {}) {
|
|
115
|
+
const index = Math.max(0, Number.isFinite(Number(promptIndex)) ? Math.trunc(Number(promptIndex)) : 0)
|
|
116
|
+
return !!forceFull || index % THINKPOOL_FULL_REMINDER_INTERVAL === 0
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildThinkPoolTurnGuidance({ text = '', promptIndex = 0, forceFull = false } = {}) {
|
|
120
|
+
if (usesFullThinkPoolReminder({ promptIndex, forceFull })) return THINKPOOL_RUNTIME_TURN_REMINDER
|
|
121
|
+
const body = String(text || '')
|
|
122
|
+
const routed = THINKPOOL_CAPABILITY_ROUTES
|
|
123
|
+
.filter((route) => ROUTE_TRIGGERS[route.id]?.test(body))
|
|
124
|
+
.map((route) => `${route.id}: ${route.rule}`)
|
|
125
|
+
if (ROUTE_TRIGGERS['visual-proof'].test(body)) routed.push(DESIGN_ROUTE_REMINDER)
|
|
126
|
+
return [THINKPOOL_RUNTIME_SALIENCE_REMINDER, ...routed].join(' ')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function roomContextFingerprint(value) {
|
|
130
|
+
return String(value || '')
|
|
131
|
+
// Relative ages change while the underlying lane state does not. Ignore
|
|
132
|
+
// that clock churn when deciding whether a room snapshot is new information.
|
|
133
|
+
.replace(/ · (?:\d+[smhd] ago|no activity)(?=\s+—)/g, ' · age')
|
|
134
|
+
.trim()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createRoomContextSelector(roomContext) {
|
|
138
|
+
let previousFingerprint = null
|
|
139
|
+
return ({ force = false } = {}) => {
|
|
140
|
+
let context = null
|
|
141
|
+
try { context = typeof roomContext === 'function' ? roomContext() : roomContext } catch { context = null }
|
|
142
|
+
const value = String(context || '').trim()
|
|
143
|
+
if (!value) return ''
|
|
144
|
+
const fingerprint = roomContextFingerprint(value)
|
|
145
|
+
const changed = fingerprint !== previousFingerprint
|
|
146
|
+
previousFingerprint = fingerprint
|
|
147
|
+
return force || changed ? value : ''
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// One authoritative terminal-identity preamble for every structured runtime.
|
|
19
152
|
// The bridge derives this from durable structural metadata — never from the text
|
|
20
153
|
// of the task handed to the model. That distinction matters because a spawned
|
|
21
154
|
// lane can be asked to "act as conductor", but it is still a sub-terminal owned
|