thinkpool-pair 0.7.354 → 0.7.356
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 +5 -0
- package/claude-session.mjs +11 -5
- package/code-event-contract.mjs +9 -0
- package/codex-event-mapper.mjs +4 -1
- package/context-contract.mjs +95 -0
- package/design-edit.mjs +116 -8
- package/design-source-contract.mjs +4 -0
- package/error-recovery.mjs +50 -0
- package/event-bounds.mjs +4 -0
- package/evidence-citations.mjs +56 -0
- package/flow-preview.mjs +4 -0
- package/hermes-event-mapper.mjs +8 -1
- package/lane-continuation.mjs +83 -0
- package/lane-lifecycle.mjs +7 -1
- package/package.json +8 -1
- package/provider-resilience.mjs +213 -0
- package/recap.mjs +13 -5
- package/repo-search.mjs +2 -0
- package/runtime-contract.mjs +93 -0
- package/runtime-registry.mjs +6 -0
- package/runtime-session.mjs +5 -0
- package/thinkpool-capabilities.json +5 -5
- package/thinkpool-room-prompt.mjs +17 -1
- package/viewport.mjs +18 -0
package/bridge.mjs
CHANGED
|
@@ -55,6 +55,7 @@ import { readCodexDefaultModel, readCodexModels, codexConfigForMode, codexThread
|
|
|
55
55
|
import { codexAccountUsageLine, codexCreditsReportLine, codexLimitReportLine } from './codex-commands.mjs'
|
|
56
56
|
import { withMcpSessionFactory } from './codex-mcp-http.mjs'
|
|
57
57
|
import { startStructuredSession } from './runtime-session.mjs'
|
|
58
|
+
import { admitRuntimeCapability } from './runtime-contract.mjs'
|
|
58
59
|
import { cleanTerminalName, modelTerminalNameInput } from './terminal-name.mjs'
|
|
59
60
|
import { defaultStructuredMode, normalizeStructuredEffort, shouldDeferStructuredRuntime, structuredModeForSlice, structuredModeLocked, structuredModesForLane, structuredRuntimeForCommand, structuredRuntimeMetadata, structuredRuntimeSupportsMode } from './runtime-registry.mjs'
|
|
60
61
|
import { commandCatalogForRuntime, commandHelpLine, reconcileCommandCatalog } from './command-catalog.mjs'
|
|
@@ -2263,6 +2264,10 @@ function worktreeSnapshot(cwd) {
|
|
|
2263
2264
|
function openStructured({ id, runtime = 'claude', model, models, effort, resume, log, commands, mode, spawnedBy, spawnDepth, cascadeRole, hop, sideParent, sideTask, pendingSideContexts, pendingWorkerCompletions, workerCompletionsInFlight, rolePrompt, sliceType, flowSessionId, flowTaskKey, flowRole, flowReviewTarget, flowReviewTargets, flowReviewSnapshots, flowReviewRound, dispatchBaseSha, revertTarget, cwd, managedWorktree, reviewSliceRoots, openedAt, defer, provider, carryRecap, lastUsage, receivedTurnCids }) {
|
|
2264
2265
|
if (sessions.has(id)) return
|
|
2265
2266
|
runtime = structuredRuntimeMetadata(runtime) ? runtime : 'claude'
|
|
2267
|
+
// Fail closed before exposing a native lane if its bridge semantic contract
|
|
2268
|
+
// was removed or mismatched. This does not replace native tool transports.
|
|
2269
|
+
const runtimeContract = admitRuntimeCapability({ runtime, capabilityId: 'terminal_interrupt', input: {} })
|
|
2270
|
+
if (!runtimeContract.ok) throw new TypeError(`Structured runtime contract rejected: ${runtimeContract.code}`)
|
|
2266
2271
|
mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
|
|
2267
2272
|
// No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
|
|
2268
2273
|
// codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
|
package/claude-session.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { reviewGatePreToolDecision } from './flow-review-gate.mjs'
|
|
|
22
22
|
import { crossPostNeedsCard } from './cross-terminal.mjs'
|
|
23
23
|
import { correctContext } from './context-windows.mjs'
|
|
24
24
|
import { normalizeClaudeCommandCatalog } from './claude-command-catalog.mjs'
|
|
25
|
+
import { evidenceForToolResult } from './evidence-citations.mjs'
|
|
25
26
|
import { THINKPOOL_CASCADE_RULE, THINKPOOL_REMOTE_DELIVERY_RULES, THINKPOOL_RUNTIME_AUTHORITY_RULE, THINKPOOL_RUNTIME_TURN_REMINDER, buildThinkPoolTurnGuidance, createRoomContextSelector, usesFullThinkPoolReminder } from './thinkpool-room-prompt.mjs'
|
|
26
27
|
import { stallDecision, stallEvent, isCompactTurn } from './turn-stall.mjs'
|
|
27
28
|
|
|
@@ -244,7 +245,7 @@ const TP_ROOM_REMINDER = [
|
|
|
244
245
|
'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 person-requested new or separate terminal uses open_main_terminal. Never plan-mode/ExitPlanMode; plans live in chat and lanes in the existing list.',
|
|
245
246
|
].join(' ')
|
|
246
247
|
|
|
247
|
-
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, admitStart = null }) {
|
|
248
|
+
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, admitStart = null, resilienceObserver = null }) {
|
|
248
249
|
// Per-turn reminder + live ROOM NOW tail. roomContext (bridge-supplied) returns the
|
|
249
250
|
// room's CURRENT state — sibling lanes, active git worktrees — or null. The static
|
|
250
251
|
// rules keep the agent aware of the room's FEATURES; the live tail keeps it aware of
|
|
@@ -297,7 +298,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
297
298
|
// never throws an ExitPlanMode card. Falls back to 'default' for unknown values.
|
|
298
299
|
let mode = MODES.has(initialMode) ? initialMode : 'default' // mirrors ⇧⇥ cycle
|
|
299
300
|
const alwaysAllow = new Set() // tool:risk signatures the user chose "don't ask again" for
|
|
300
|
-
const toolStart = new Map() // tool_use id →
|
|
301
|
+
const toolStart = new Map() // tool_use id → source metadata for duration/evidence
|
|
301
302
|
const effortLevels = new Set(['low', 'medium', 'high', 'xhigh', 'max'])
|
|
302
303
|
let effort = effortLevels.has(initialEffort) ? initialEffort : 'high'
|
|
303
304
|
// Live token count for the thinking indicator — mirrors Claude Code's
|
|
@@ -348,6 +349,9 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
348
349
|
const RESTART_MAX = 3
|
|
349
350
|
const RECOVERABLE = /connection closed|connection reset|econnreset|etimedout|socket hang up|fetch failed|network error|socket destroyed/i
|
|
350
351
|
let restartCount = 0
|
|
352
|
+
// Phase 0 measurement seam. The observer receives only a coarse recovery
|
|
353
|
+
// reason/count; it cannot alter this session's existing retry behavior.
|
|
354
|
+
const observeRecovery = (reason, detail = {}) => { try { resilienceObserver?.({ reason, restartCount, ...detail }) } catch { /* observer is strictly read-only */ } }
|
|
351
355
|
let restartTimer = null // the pending auto-restart backoff — cancelled by end()
|
|
352
356
|
// Force-stop a true wedge (item 3): no result, no error, just silence past
|
|
353
357
|
// FORCE_STOP_MS. As of the 2026-07-08 hardening we no longer just surface an error
|
|
@@ -428,7 +432,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
428
432
|
const ev = stallEvent(action, quiet)
|
|
429
433
|
if (ev) emitRaw(ev)
|
|
430
434
|
if (action === 'status') { stalledSent = true; return }
|
|
431
|
-
if (action === 'retry') { forceStopped = true; retryStalledTurn(quiet); return }
|
|
435
|
+
if (action === 'retry') { observeRecovery('stall_replay', { quietMs: quiet }); forceStopped = true; retryStalledTurn(quiet); return }
|
|
432
436
|
// 'giveup' — the one auto-retry ALSO stalled past FORCE_STOP_MS. Fall back to the
|
|
433
437
|
// pre-2026-07-08 behavior: force-stop the turn so between-turns updates unblock, and
|
|
434
438
|
// let the human resend. The wedged loop is left in place; if it later throws, the
|
|
@@ -949,7 +953,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
949
953
|
case 'assistant':
|
|
950
954
|
// Stamp tool-call start times so tool_result can report a duration.
|
|
951
955
|
for (const b of (m.message?.content || [])) {
|
|
952
|
-
if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, Date.now())
|
|
956
|
+
if (b?.type === 'tool_use' && b.id) toolStart.set(b.id, { at: Date.now(), name: b.name, input: b.input })
|
|
953
957
|
}
|
|
954
958
|
// parentToolUseId: non-null when this assistant message comes from a
|
|
955
959
|
// sub-agent (Task tool) — the universal nesting spine. Thread it so the
|
|
@@ -964,7 +968,8 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
964
968
|
if (b?.type === 'tool_result') {
|
|
965
969
|
const start = toolStart.get(b.tool_use_id)
|
|
966
970
|
if (start != null) toolStart.delete(b.tool_use_id)
|
|
967
|
-
|
|
971
|
+
const evidence = evidenceForToolResult(start?.name, b.content)
|
|
972
|
+
emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, ...(evidence ? { evidence } : {}), isError: !!b.is_error, durationMs: start?.at != null ? Date.now() - start.at : undefined, parentToolUseId: m.parent_tool_use_id || null })
|
|
968
973
|
}
|
|
969
974
|
}
|
|
970
975
|
break
|
|
@@ -1092,6 +1097,7 @@ export function startClaudeSession({ cwd, model, effort: initialEffort = 'high',
|
|
|
1092
1097
|
// fresh input stream is needed because the throw killed the old iterator.
|
|
1093
1098
|
if (RECOVERABLE.test(msg) && restartCount < RESTART_MAX) {
|
|
1094
1099
|
restartCount++
|
|
1100
|
+
observeRecovery('stream_reconnect', { failureClass: 'network' })
|
|
1095
1101
|
emit({ kind: 'note', text: `connection dropped — reconnecting (${restartCount}/${RESTART_MAX})` })
|
|
1096
1102
|
// End the OLD input stream before swapping — else its generator leaks
|
|
1097
1103
|
// (suspended forever) and a turn pushed into it after the throw is silently
|
package/code-event-contract.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/* Canonical ThinkPool Code event contract.
|
|
2
2
|
Keep this module browser-safe: the bridge and src/pages/code both consume it. */
|
|
3
3
|
|
|
4
|
+
import { recoveryCodeEvent } from './error-recovery.mjs'
|
|
5
|
+
|
|
4
6
|
export const EVENT_CLASS = Object.freeze({
|
|
5
7
|
VOLATILE_PRESENCE: 'volatile-presence',
|
|
6
8
|
REPLAYABLE_PROGRESS: 'replayable-progress',
|
|
@@ -60,6 +62,7 @@ export const CODE_EVENT_REGISTRY = Object.freeze({
|
|
|
60
62
|
image: E,
|
|
61
63
|
'needs-input': E,
|
|
62
64
|
'needs-resolved': E,
|
|
65
|
+
continuation: E,
|
|
63
66
|
'turn-done': E,
|
|
64
67
|
})
|
|
65
68
|
|
|
@@ -106,3 +109,9 @@ export function unknownCodeEventNotice (eventOrKind) {
|
|
|
106
109
|
}
|
|
107
110
|
|
|
108
111
|
export const isKnownCodeEventKind = (kind) => Object.hasOwn(CODE_EVENT_REGISTRY, kind)
|
|
112
|
+
|
|
113
|
+
// Normalize recovery reporting before it enters the durable room log. This is a
|
|
114
|
+
// projection only: adapters retain their existing retry and session behavior.
|
|
115
|
+
export function boundedRecoveryCodeEvent(input) {
|
|
116
|
+
return recoveryCodeEvent(input)
|
|
117
|
+
}
|
package/codex-event-mapper.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import fs from 'node:fs'
|
|
32
32
|
import { CODEX_COMMAND_CATALOG } from './codex-commands.mjs'
|
|
33
33
|
import { normalizeEditKind, summarizeTextDiff } from './edit-diff.mjs'
|
|
34
|
+
import { evidenceForToolResult } from './evidence-citations.mjs'
|
|
34
35
|
|
|
35
36
|
const safeMcpPart = (value) => String(value || 'unknown').replace(/[^a-zA-Z0-9_-]/g, '_')
|
|
36
37
|
const mcpToolName = (item) => `mcp__${safeMcpPart(item?.server)}__${safeMcpPart(item?.tool)}`
|
|
@@ -165,7 +166,9 @@ export class CodexEventMapper {
|
|
|
165
166
|
const query = webSearchQuery(it)
|
|
166
167
|
this._emit({ kind: 'tool_result', toolUseId: it.id, toolInput: { query }, content: [{ type: 'text', text: query || 'search completed' }], isError: false, durationMs, parentToolUseId: null })
|
|
167
168
|
} else if (it.type === 'mcp_tool_call') {
|
|
168
|
-
|
|
169
|
+
const content = [{ type: 'text', text: it.error?.message || resultText(it.result) }]
|
|
170
|
+
const evidence = evidenceForToolResult(mcpToolName(it), content)
|
|
171
|
+
this._emit({ kind: 'tool_result', toolUseId: it.id, content, ...(evidence ? { evidence } : {}), isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
|
|
169
172
|
}
|
|
170
173
|
// plan_update remains an additive future event shape. Unknown
|
|
171
174
|
// items are ignored rather than risking a malformed transcript row.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Deterministic, local-only description of the safe context ThinkPool supplies
|
|
2
|
+
// to a native runtime. This is deliberately not a transcript store.
|
|
3
|
+
|
|
4
|
+
import { createHash } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
export const CONTEXT_CONTRACT_VERSION = 1
|
|
7
|
+
export const CONTEXT_OMISSIONS = Object.freeze(['raw_pty', 'credentials', 'absolute_paths', 'unapproved_cross_room'])
|
|
8
|
+
export const CONTEXT_SOURCE_POLICY = Object.freeze([
|
|
9
|
+
Object.freeze({ kind: 'current_user_turn', maxBytes: 8192 }),
|
|
10
|
+
Object.freeze({ kind: 'room_now_delta', maxBytes: 4096 }),
|
|
11
|
+
Object.freeze({ kind: 'flow_slice_digest', maxBytes: 8192 }),
|
|
12
|
+
Object.freeze({ kind: 'approved_human_response', maxBytes: 4096 }),
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
const BY_KIND = new Map(CONTEXT_SOURCE_POLICY.map((source) => [source.kind, source]))
|
|
16
|
+
const HOST_PATH = /(?:\/home\/|\/users\/|\/private\/|\/tmp\/|[a-z]:\\|\\\\|(?:^|[\\/])\.\.(?:[\\/]|$))/i
|
|
17
|
+
const SECRET_KEY = /(secret|token|password|authorization|api.?key|private.?key)/i
|
|
18
|
+
const SECRET_VALUE = /(?:sk-[a-z0-9_-]{8,}|gsk_[a-z0-9_-]{8,}|AIza[a-z0-9_-]{8,}|gh[pousr]_[a-z0-9]{20,}|github_pat_[a-z0-9_]{20,}|xox[baprs]-[a-z0-9-]{10,}|AKIA[0-9A-Z]{16}|sbp_[a-z0-9]{20,}|eyJ[a-z0-9_-]{16,}\.[a-z0-9_-]{16,}\.[a-z0-9_-]{8,}|bearer\s+[a-z0-9._-]{8,})/i
|
|
19
|
+
const PROHIBITED = /(?:raw transcript|tool args?|chain[ -]of[ -]thought|hidden reasoning|system prompt|environment dump|provider key|BEGIN (?:RSA |OPENSSH )?PRIVATE KEY)/i
|
|
20
|
+
const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
|
|
21
|
+
const bytes = (value) => {
|
|
22
|
+
try {
|
|
23
|
+
const serialized = typeof value === 'string' ? value : JSON.stringify(value)
|
|
24
|
+
return new TextEncoder().encode(serialized ?? '').length
|
|
25
|
+
} catch {
|
|
26
|
+
return Infinity
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const stable = (value) => Array.isArray(value)
|
|
30
|
+
? value.map(stable)
|
|
31
|
+
: value && typeof value === 'object'
|
|
32
|
+
? Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]))
|
|
33
|
+
: value
|
|
34
|
+
|
|
35
|
+
export function isSafeContextValue(value, maxBytes = 8192) {
|
|
36
|
+
if (value == null || bytes(value) > maxBytes) return false
|
|
37
|
+
const visit = (node) => {
|
|
38
|
+
if (typeof node === 'string') return node.length <= 2048 && !HOST_PATH.test(node) && !SECRET_VALUE.test(node) && !PROHIBITED.test(node)
|
|
39
|
+
if (node == null || typeof node === 'number' || typeof node === 'boolean') return true
|
|
40
|
+
if (Array.isArray(node)) return node.length <= 64 && node.every(visit)
|
|
41
|
+
if (typeof node !== 'object') return false
|
|
42
|
+
return Object.entries(node).every(([key, child]) => !SECRET_KEY.test(key) && visit(child))
|
|
43
|
+
}
|
|
44
|
+
return visit(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function safeRef(ref) {
|
|
48
|
+
if (!ref || typeof ref !== 'object' || Array.isArray(ref)) return null
|
|
49
|
+
if (!SAFE_REF.test(String(ref.type || '')) || !SAFE_REF.test(String(ref.id || ''))) return null
|
|
50
|
+
return { type: String(ref.type), id: String(ref.id) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The visible room receives only lane identity/status aggregates, never terminal
|
|
54
|
+
// snippets or host worktree paths. This intentionally trades detail for privacy.
|
|
55
|
+
export function projectRoomContext(roomNow) {
|
|
56
|
+
const lines = String(roomNow || '').split('\n')
|
|
57
|
+
const lanes = []
|
|
58
|
+
let worktrees = 0
|
|
59
|
+
for (const line of lines) {
|
|
60
|
+
const lane = line.match(/^\s*-\s*([A-Za-z0-9._:@-]{4,160})\s+·\s+"([^"\n]{1,160})"\s+·\s+[^·\n]+\s+·\s+([^·\n]+)/)
|
|
61
|
+
if (lane && isSafeContextValue(lane[2], 512) && isSafeContextValue(lane[3], 128)) {
|
|
62
|
+
lanes.push({ ref: lane[1], title: lane[2].trim(), state: lane[3].trim() })
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (/^\s*-\s*\S+\s+[0-9a-f]{7,40}\s+\[/.test(line)) worktrees++
|
|
66
|
+
}
|
|
67
|
+
const projection = { lanes: lanes.slice(0, 32), worktreeCount: Math.min(worktrees, 999) }
|
|
68
|
+
return isSafeContextValue(projection, 4096) ? projection : { lanes: [], worktreeCount: 0 }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatRoomContextProjection(roomNow) {
|
|
72
|
+
const projection = projectRoomContext(roomNow)
|
|
73
|
+
if (!projection.lanes.length && !projection.worktreeCount) return ''
|
|
74
|
+
const laneText = projection.lanes.map((lane) => `${lane.ref} · ${lane.title} · ${lane.state}`).join('\n')
|
|
75
|
+
return ['ROOM NOW (safe projection):', laneText, projection.worktreeCount ? `Active git worktrees: ${projection.worktreeCount}` : ''].filter(Boolean).join('\n')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function buildContextManifest({ promptBundle = {}, sources = [] } = {}) {
|
|
79
|
+
const accepted = new Map()
|
|
80
|
+
for (const candidate of Array.isArray(sources) ? sources : []) {
|
|
81
|
+
const policy = BY_KIND.get(candidate?.kind)
|
|
82
|
+
if (!policy || accepted.has(policy.kind)) continue
|
|
83
|
+
const ref = candidate.ref == null ? null : safeRef(candidate.ref)
|
|
84
|
+
if (candidate.ref != null && !ref) continue
|
|
85
|
+
if (candidate.value != null && !isSafeContextValue(candidate.value, policy.maxBytes)) continue
|
|
86
|
+
accepted.set(policy.kind, ref ? { kind: policy.kind, ref, maxBytes: policy.maxBytes } : { kind: policy.kind, maxBytes: policy.maxBytes })
|
|
87
|
+
}
|
|
88
|
+
const manifest = {
|
|
89
|
+
version: CONTEXT_CONTRACT_VERSION,
|
|
90
|
+
promptBundle: { version: Number(promptBundle?.version) || 1, hash: /^[a-f0-9]{64}$/i.test(String(promptBundle?.hash || '')) ? String(promptBundle.hash).toLowerCase() : null },
|
|
91
|
+
sources: CONTEXT_SOURCE_POLICY.map((policy) => accepted.get(policy.kind)).filter(Boolean),
|
|
92
|
+
omissions: [...CONTEXT_OMISSIONS],
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze({ ...manifest, hash: createHash('sha256').update(JSON.stringify(stable(manifest))).digest('hex') })
|
|
95
|
+
}
|
package/design-edit.mjs
CHANGED
|
@@ -2,13 +2,24 @@ import crypto from 'node:crypto'
|
|
|
2
2
|
import fs from 'node:fs'
|
|
3
3
|
import os from 'node:os'
|
|
4
4
|
import path from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
DESIGN_SOURCE_MAP_RELATIVE,
|
|
7
|
+
DESIGN_SOURCE_MAP_VERSION,
|
|
8
|
+
MAX_DESIGN_SOURCE_ENTRIES,
|
|
9
|
+
MAX_DESIGN_SOURCE_MAP_BYTES,
|
|
10
|
+
} from './design-source-contract.mjs'
|
|
5
11
|
|
|
6
12
|
const MAX_SOURCE_BYTES = 2 * 1024 * 1024
|
|
7
13
|
const MAX_INTENT = 2_000
|
|
8
14
|
const MAX_TEXT = 4_000
|
|
9
15
|
const MAX_BATCH_EDITS = 8
|
|
16
|
+
const GENERATED_SOURCE_PARTS = new Set(['dist', 'build', '.next', '.git', 'coverage', 'node_modules'])
|
|
10
17
|
|
|
11
18
|
const clean = (value, max) => String(value ?? '').replace(/\0/g, '').trim().slice(0, max)
|
|
19
|
+
const isInside = (parent, child) => {
|
|
20
|
+
const rel = path.relative(parent, child)
|
|
21
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
|
|
22
|
+
}
|
|
12
23
|
|
|
13
24
|
// supabase-js does not copy the Auth session into an already-created Realtime
|
|
14
25
|
// client. Private tpdesign:* joins therefore need the bridge owner's JWT pinned
|
|
@@ -28,6 +39,52 @@ export function sourceRevision(source) {
|
|
|
28
39
|
return crypto.createHash('sha256').update(String(source)).digest('hex').slice(0, 24)
|
|
29
40
|
}
|
|
30
41
|
|
|
42
|
+
export function resolveDesignSourceMap(file, previewRoot, workspaceRoot) {
|
|
43
|
+
if (!file || !previewRoot || !workspaceRoot) return null
|
|
44
|
+
let sourceMapPath, expectedSourceMapPath, builtRoot, root, raw, parsed
|
|
45
|
+
try {
|
|
46
|
+
sourceMapPath = fs.realpathSync(file)
|
|
47
|
+
builtRoot = fs.realpathSync(previewRoot)
|
|
48
|
+
expectedSourceMapPath = fs.realpathSync(path.join(builtRoot, DESIGN_SOURCE_MAP_RELATIVE))
|
|
49
|
+
root = fs.realpathSync(workspaceRoot)
|
|
50
|
+
const stat = fs.statSync(sourceMapPath)
|
|
51
|
+
if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_DESIGN_SOURCE_MAP_BYTES) return null
|
|
52
|
+
if (sourceMapPath !== expectedSourceMapPath || !isInside(root, builtRoot) || !isInside(builtRoot, sourceMapPath)) return null
|
|
53
|
+
raw = fs.readFileSync(sourceMapPath, 'utf8')
|
|
54
|
+
parsed = JSON.parse(raw)
|
|
55
|
+
} catch { return null }
|
|
56
|
+
if (parsed?.version !== DESIGN_SOURCE_MAP_VERSION || !parsed.entries || typeof parsed.entries !== 'object' || Array.isArray(parsed.entries)) return null
|
|
57
|
+
const rows = Object.entries(parsed.entries)
|
|
58
|
+
if (rows.length > MAX_DESIGN_SOURCE_ENTRIES) return null
|
|
59
|
+
|
|
60
|
+
const files = new Map()
|
|
61
|
+
const entries = new Map()
|
|
62
|
+
for (const [sourceId, value] of rows) {
|
|
63
|
+
if (!/^[A-Za-z0-9_-]{8,80}$/.test(sourceId) || !value || typeof value !== 'object') return null
|
|
64
|
+
const relative = clean(value.file, 700)
|
|
65
|
+
if (!relative || path.isAbsolute(relative) || relative.split(/[\\/]/).some((part) => part === '..' || GENERATED_SOURCE_PARTS.has(part))) return null
|
|
66
|
+
let sourcePath = files.get(relative)
|
|
67
|
+
if (!sourcePath) {
|
|
68
|
+
try {
|
|
69
|
+
sourcePath = fs.realpathSync(path.resolve(root, relative))
|
|
70
|
+
const stat = fs.statSync(sourcePath)
|
|
71
|
+
if (!stat.isFile() || stat.size > MAX_SOURCE_BYTES || !isInside(root, sourcePath)) return null
|
|
72
|
+
} catch { return null }
|
|
73
|
+
files.set(relative, sourcePath)
|
|
74
|
+
}
|
|
75
|
+
const start = Number(value.start)
|
|
76
|
+
const end = Number(value.end)
|
|
77
|
+
const line = Number(value.line)
|
|
78
|
+
const column = Number(value.column)
|
|
79
|
+
const tag = clean(value.tag, 40).toLowerCase()
|
|
80
|
+
const fileHash = clean(value.fileHash, 64).toLowerCase()
|
|
81
|
+
if (![start, end, line, column].every(Number.isSafeInteger) || start < 0 || end <= start || line < 1 || column < 0) return null
|
|
82
|
+
if (!/^[a-z][a-z0-9:-]*$/.test(tag) || !/^[a-f0-9]{24}$/.test(fileHash)) return null
|
|
83
|
+
entries.set(sourceId, { sourceId, file: relative.split(path.sep).join('/'), sourcePath, start, end, line, column, tag, fileHash })
|
|
84
|
+
}
|
|
85
|
+
return { path: sourceMapPath, revision: sourceRevision(raw), buildId: clean(parsed.buildId, 80), entries }
|
|
86
|
+
}
|
|
87
|
+
|
|
31
88
|
export function resolveDesignSource(file, workspaceRoot) {
|
|
32
89
|
if (!file || !workspaceRoot) return null
|
|
33
90
|
let sourcePath, root
|
|
@@ -67,11 +124,21 @@ export function resolvePreviewDesignSource(manifest, workspaceRoot, box) {
|
|
|
67
124
|
const captureKey = clean(manifest.captureKey, 1000)
|
|
68
125
|
if (!route.startsWith('/') || !captureKey || captureKey !== JSON.stringify([previewRoot, route])) return null
|
|
69
126
|
const source = fs.readFileSync(sourcePath, 'utf8')
|
|
70
|
-
|
|
127
|
+
let builtRoot = null
|
|
128
|
+
try {
|
|
129
|
+
builtRoot = fs.realpathSync(path.resolve(root, previewRoot))
|
|
130
|
+
if (!isInside(root, builtRoot)) return null
|
|
131
|
+
} catch { return null }
|
|
132
|
+
const sourceMap = manifest.sourceMap ? resolveDesignSourceMap(manifest.sourceMap, builtRoot, root) : null
|
|
133
|
+
const revision = sourceRevision(sourceMap ? `${source}\0${sourceMap.revision}` : source)
|
|
71
134
|
const previewId = crypto.createHash('sha256')
|
|
72
135
|
.update(`${root}\0${captureKey}\0${revision}`)
|
|
73
136
|
.digest('base64url').slice(0, 32)
|
|
74
|
-
return {
|
|
137
|
+
return {
|
|
138
|
+
sourceKind: 'preview', previewId, revision, sourcePath, workspaceRoot: root, source, outbox,
|
|
139
|
+
route, previewRoot, captureKey, sourceMap, sourceMapPath: sourceMap?.path || null,
|
|
140
|
+
sourceMapDeclared: !!manifest.sourceMap,
|
|
141
|
+
}
|
|
75
142
|
}
|
|
76
143
|
|
|
77
144
|
// Older lane worktrees predate the explicit `source` manifest field and emit the
|
|
@@ -108,7 +175,7 @@ export function refreshDesignSource(record) {
|
|
|
108
175
|
if (record.sourceKind !== 'preview') return resolveDesignSource(record.sourcePath, record.workspaceRoot)
|
|
109
176
|
return resolvePreviewDesignSource({
|
|
110
177
|
sourceKind: 'preview', snapshot: record.sourcePath, route: record.route,
|
|
111
|
-
previewRoot: record.previewRoot, captureKey: record.captureKey,
|
|
178
|
+
previewRoot: record.previewRoot, captureKey: record.captureKey, sourceMap: record.sourceMapPath,
|
|
112
179
|
}, record.workspaceRoot, record.outbox)
|
|
113
180
|
}
|
|
114
181
|
|
|
@@ -168,10 +235,35 @@ function safeTarget(value) {
|
|
|
168
235
|
canReplaceImage: value.canReplaceImage === true,
|
|
169
236
|
nodeKey: clean(value.nodeKey, 80),
|
|
170
237
|
parentNodeKey: clean(value.parentNodeKey, 80),
|
|
238
|
+
sourceId: clean(value.sourceId, 80),
|
|
239
|
+
parentSourceId: clean(value.parentSourceId, 80),
|
|
171
240
|
rect,
|
|
172
241
|
}
|
|
173
242
|
}
|
|
174
243
|
|
|
244
|
+
export function resolveRequestSourceIdentity(record, target) {
|
|
245
|
+
if (!target?.sourceId) return { ok: true, identity: null }
|
|
246
|
+
const entry = record?.sourceMap?.entries?.get(target.sourceId)
|
|
247
|
+
if (!entry) return { ok: false, error: record?.sourceMapDeclared ? 'stale-source-identity' : 'source-identity-unavailable' }
|
|
248
|
+
if (target.tag && entry.tag !== target.tag) return { ok: false, error: 'source-identity-mismatch' }
|
|
249
|
+
let source
|
|
250
|
+
try { source = fs.readFileSync(entry.sourcePath, 'utf8') } catch { return { ok: false, error: 'stale-source-identity' } }
|
|
251
|
+
if (sourceRevision(source) !== entry.fileHash) return { ok: false, error: 'stale-source-identity' }
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
identity: {
|
|
255
|
+
sourceId: entry.sourceId,
|
|
256
|
+
file: entry.file,
|
|
257
|
+
start: entry.start,
|
|
258
|
+
end: entry.end,
|
|
259
|
+
line: entry.line,
|
|
260
|
+
column: entry.column,
|
|
261
|
+
tag: entry.tag,
|
|
262
|
+
fileHash: entry.fileHash,
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
175
267
|
function safeMovement(value) {
|
|
176
268
|
if (!value || typeof value !== 'object') return null
|
|
177
269
|
const fromIndex = Number(value.fromIndex)
|
|
@@ -211,7 +303,16 @@ export function validateDesignRequest(payload, registry, room = '') {
|
|
|
211
303
|
if (mode === 'move' && (!movement || movement.fromIndex === movement.toIndex)) return { ok: false, error: 'invalid-move' }
|
|
212
304
|
const asset = mode === 'image' ? safeAsset(payload.asset, room) : null
|
|
213
305
|
if (mode === 'image' && (!target.canReplaceImage || !asset)) return { ok: false, error: 'invalid-image' }
|
|
214
|
-
|
|
306
|
+
const source = resolveRequestSourceIdentity(record, target)
|
|
307
|
+
if (!source.ok) return source
|
|
308
|
+
return {
|
|
309
|
+
ok: true,
|
|
310
|
+
record,
|
|
311
|
+
request: {
|
|
312
|
+
previewId, revision: record.revision, mode, intent, target, movement, asset,
|
|
313
|
+
sourceIdentity: source.identity, cid: clean(payload.cid, 80),
|
|
314
|
+
},
|
|
315
|
+
}
|
|
215
316
|
}
|
|
216
317
|
|
|
217
318
|
export function validateDesignBatchRequest(payload, registry, room = '') {
|
|
@@ -226,7 +327,7 @@ export function validateDesignBatchRequest(payload, registry, room = '') {
|
|
|
226
327
|
for (let index = 0; index < payload.edits.length; index++) {
|
|
227
328
|
const verdict = validateDesignRequest({ ...payload.edits[index], previewId, revision, cid }, registry, room)
|
|
228
329
|
if (!verdict.ok) return { ...verdict, editIndex: index }
|
|
229
|
-
const targetKey = verdict.request.target.nodeKey || verdict.request.target.selector || verdict.request.target.xpath
|
|
330
|
+
const targetKey = verdict.request.target.sourceId || verdict.request.target.nodeKey || verdict.request.target.selector || verdict.request.target.xpath
|
|
230
331
|
if (targets.has(targetKey)) return { ok: false, error: 'duplicate-target', editIndex: index }
|
|
231
332
|
targets.add(targetKey)
|
|
232
333
|
record = verdict.record
|
|
@@ -276,6 +377,13 @@ function designAction(request) {
|
|
|
276
377
|
return `Apply this visual change to only the selected element unless the instruction explicitly requires its immediate context:\n${request.intent}`
|
|
277
378
|
}
|
|
278
379
|
|
|
380
|
+
function selectedElementBlock(request) {
|
|
381
|
+
const identity = request.sourceIdentity
|
|
382
|
+
? `\n\nBridge-verified application source:\n${JSON.stringify(request.sourceIdentity, null, 2)}`
|
|
383
|
+
: '\n\nApplication source identity: unavailable — selector/XPath fallback only.'
|
|
384
|
+
return `Selected element:\n${JSON.stringify(request.target, null, 2)}${identity}`
|
|
385
|
+
}
|
|
386
|
+
|
|
279
387
|
const designModeLabel = (mode) => ({ text: 'Text', prompt: 'Prompt', move: 'Move', image: 'Image' })[mode] || 'Edit'
|
|
280
388
|
|
|
281
389
|
function visibleDesignAction(request) {
|
|
@@ -307,10 +415,10 @@ export function designPrompt({ record, request, by, restore = false, priorRecord
|
|
|
307
415
|
const work = restore
|
|
308
416
|
? `Restore only the immediately previous Design Mode change by comparing the current artifact with this bridge-created verified backup: ${priorRecord?.backupPath || '(backup unavailable)'}. Do not overwrite unrelated changes.`
|
|
309
417
|
: batch
|
|
310
|
-
? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\
|
|
311
|
-
:
|
|
418
|
+
? batch.map((edit, index) => `Edit ${index + 1} of ${batch.length} · ${edit.mode}\n${selectedElementBlock(edit)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
|
|
419
|
+
: `${selectedElementBlock(request)}\n\n${designAction(request)}`
|
|
312
420
|
if (record.sourceKind === 'preview') {
|
|
313
|
-
return `A room member used ThinkPool Design Mode on a rendered application preview. This is an authorized source edit in your current workspace.\n\nRendered snapshot (read-only evidence): ${record.sourcePath}\nApplication workspace: ${record.workspaceRoot}\nPreview root: ${record.previewRoot}\nPreview route: ${record.route}\nExpected snapshot revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the rendered snapshot and confirm the selected element still matches before editing.\n- Do not edit the snapshot.
|
|
421
|
+
return `A room member used ThinkPool Design Mode on a rendered application preview. This is an authorized source edit in your current workspace.\n\nRendered snapshot (read-only evidence): ${record.sourcePath}\nApplication workspace: ${record.workspaceRoot}\nPreview root: ${record.previewRoot}\nPreview route: ${record.route}\nExpected snapshot revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the rendered snapshot and confirm the selected element still matches before editing.\n- Do not edit the snapshot. For an edit with bridge-verified application source, start at that exact relative file and range. For a selector/XPath fallback, locate the represented element in application source and do not call the match exact.\n- Before editing a bridge-verified target, confirm the current source still hashes to the supplied fileHash. If it does not, stop and request a fresh capture/reselection.\n- If fallback selector, text, or surrounding structure does not identify one application source location unambiguously, stop without editing and explain that the person must reselect it.\n${batch ? '- Apply the queued edits in order in one source pass, then build and capture once after the full batch.' : '- Apply only the requested change, then build and capture once.'}\n- Preserve the product page faithfully: keep its real copy, structure, fonts, assets, spacing, responsive behavior, and unrelated source unchanged.\n- Run the project build, then use preview_start with root ${record.previewRoot} and preview_capture with path ${record.route}, both viewports, and title ${JSON.stringify(record.title || record.slug || 'Design preview')}. The bridge correlates that capture to this request.\n- Do not claim the edit is verified until the room receives the new desktop and mobile capture.\n- If the build or capture fails, report the failure instead of substituting a screenshot-only result.`
|
|
314
422
|
}
|
|
315
423
|
return `A room member used ThinkPool Design Mode on the artifact below. This is an authorized source edit in your current workspace.\n\nArtifact source: ${record.sourcePath}\nWorkspace: ${record.workspaceRoot}\nExpected source revision: ${record.revision}\nRequested by: ${clean(by || 'A room member', 120)}\nOperation: ${restore ? 'restore the immediately previous verified Design Mode revision' : batch ? `${batch.length} queued edits in one batch` : request.mode}\n\n${work}\n\nRules:\n- Re-read the exact artifact source and confirm its revision/content still matches every selected element before editing.\n${batch ? '- Apply the queued edits in order in one source pass, then render once after the full batch.' : '- Apply only the requested change, then render the result once.'}\n- Edit the canonical source above; do not search for or edit a different plausible file.\n- Preserve the represented product page faithfully: keep its real copy, structure, fonts, assets, spacing, and responsive behavior except for the selected edits. Never replace it with generic mockup content.\n- Preserve unrelated content and styling.\n- Use the existing mockup render workflow to produce both desktop and mobile captures after the edit. Run it with TP_DESIGN_REQUEST_ID=${request.cid} and TP_DESIGN_PARENT_REVISION=${record.revision} in the command environment so the proof correlates to this request.\n- Do not claim this is live until both captures succeed and the room receives the manifest.\n- If any target is stale or ambiguous, stop without editing and explain that the person must reselect it.`
|
|
316
424
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// A safe recovery envelope. It records a bounded decision; it never performs a
|
|
2
|
+
// retry and therefore cannot alter native adapter transport behavior.
|
|
3
|
+
|
|
4
|
+
import { isSecretFreeRoomPayload, runtimeSupportsCapability } from './runtime-contract.mjs'
|
|
5
|
+
|
|
6
|
+
export const ERROR_RECOVERY_VERSION = 1
|
|
7
|
+
export const MAX_RECOVERY_ATTEMPTS = 3
|
|
8
|
+
const CODE = /^[a-z][a-z0-9_-]{0,79}$/
|
|
9
|
+
const SAFE_REF = /^[A-Za-z0-9][A-Za-z0-9._:@-]{0,159}$/
|
|
10
|
+
|
|
11
|
+
const policyFor = ({ runtime, capabilityId }) => runtimeSupportsCapability(runtime, capabilityId)
|
|
12
|
+
? Object.freeze({ maxAttempts: MAX_RECOVERY_ATTEMPTS })
|
|
13
|
+
: Object.freeze({ maxAttempts: 1 })
|
|
14
|
+
|
|
15
|
+
export function normalizeErrorRecovery({ runtime, capabilityId, code = 'runtime_error', retryable = false, attempt = 1, evidenceRefs = [] } = {}) {
|
|
16
|
+
const policy = policyFor({ runtime, capabilityId })
|
|
17
|
+
const safeAttempt = Math.max(1, Math.min(policy.maxAttempts, Number.isSafeInteger(attempt) ? attempt : 1))
|
|
18
|
+
const safeRefs = (Array.isArray(evidenceRefs) ? evidenceRefs : []).slice(0, 16)
|
|
19
|
+
.filter((ref) => ref && SAFE_REF.test(String(ref.type || '')) && SAFE_REF.test(String(ref.id || '')))
|
|
20
|
+
.map((ref) => ({ type: String(ref.type), id: String(ref.id) }))
|
|
21
|
+
const admitted = runtimeSupportsCapability(runtime, capabilityId) && CODE.test(String(code))
|
|
22
|
+
const canRetry = admitted && retryable === true && safeAttempt < policy.maxAttempts
|
|
23
|
+
const envelope = {
|
|
24
|
+
version: ERROR_RECOVERY_VERSION,
|
|
25
|
+
capabilityId: admitted ? capabilityId : 'unknown',
|
|
26
|
+
code: admitted ? String(code) : 'invalid_recovery_input',
|
|
27
|
+
retryable: canRetry,
|
|
28
|
+
attempt: safeAttempt,
|
|
29
|
+
maxAttempts: policy.maxAttempts,
|
|
30
|
+
next: canRetry ? 'retry' : (admitted && retryable === true ? 'ask_human' : 'stop'),
|
|
31
|
+
evidenceRefs: safeRefs,
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze(isSecretFreeRoomPayload(envelope, 4096) ? envelope : {
|
|
34
|
+
version: ERROR_RECOVERY_VERSION, capabilityId: 'unknown', code: 'invalid_recovery_input', retryable: false,
|
|
35
|
+
attempt: 1, maxAttempts: 1, next: 'stop', evidenceRefs: [],
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function recoveryCodeEvent(input) {
|
|
40
|
+
const recovery = normalizeErrorRecovery(input)
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
kind: recovery.next === 'ask_human' ? 'needs-input' : 'error',
|
|
43
|
+
subtype: 'bounded_recovery',
|
|
44
|
+
message: recovery.next === 'ask_human'
|
|
45
|
+
? `Recovery attempt ${recovery.attempt}/${recovery.maxAttempts} is exhausted; human input is required.`
|
|
46
|
+
: `Recovery stopped safely (${recovery.code}).`,
|
|
47
|
+
recovery,
|
|
48
|
+
verified: false,
|
|
49
|
+
})
|
|
50
|
+
}
|
package/event-bounds.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { compactEvidenceEnvelope } from './evidence-citations.mjs'
|
|
2
|
+
|
|
1
3
|
/* Browser-safe structured-event bounding shared by the bridge broadcast path and
|
|
2
4
|
the web client's persisted reload snapshot. Large Codex/Claude tool payloads
|
|
3
5
|
must remain recognisable (tool name + a useful preview), but must never turn a
|
|
@@ -66,6 +68,7 @@ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
|
|
|
66
68
|
if (!event || typeof event !== 'object' || sizeOf(event) <= cap) return event
|
|
67
69
|
|
|
68
70
|
const out = { ...event, _bounded: true }
|
|
71
|
+
if (out.evidence) out.evidence = compactEvidenceEnvelope(out.evidence, 0)
|
|
69
72
|
const stringBudget = Math.min(12000, Math.max(1000, Math.floor(cap / 4)))
|
|
70
73
|
for (const key of ['text', 'output', 'stdout', 'stderr', 'input']) {
|
|
71
74
|
if (typeof out[key] === 'string') out[key] = clipString(out[key], stringBudget)
|
|
@@ -89,6 +92,7 @@ export function boundStructuredEvent(event, cap = STRUCTURED_EVENT_CAP) {
|
|
|
89
92
|
for (const key of ['kind', 'seq', 'cid', 'ts', 'term', 'toolUseId', 'isError', 'by', 'crosspost', 'relaySourceName']) {
|
|
90
93
|
if (out[key] != null) identity[key] = out[key]
|
|
91
94
|
}
|
|
95
|
+
if (out.evidence) identity.evidence = compactEvidenceEnvelope(out.evidence, 0)
|
|
92
96
|
if (Array.isArray(out.blocks)) identity.blocks = out.blocks.slice(0, 4).map(block => compactBlock(block, 6000))
|
|
93
97
|
else if (Array.isArray(out.content)) identity.content = out.content.slice(0, 4).map(block => compactBlock(block, 6000))
|
|
94
98
|
else if (typeof out.content === 'string') identity.content = clipString(out.content, 12000)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Strict, runtime-neutral EvidenceEnvelopeV1 parsing. Only an actual
|
|
2
|
+
// project-context evidence tool result can create room evidence in S1.
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
export const EVIDENCE_START = '<<<TP_EVIDENCE_V1>>>'
|
|
6
|
+
export const EVIDENCE_END = '<<<END_TP_EVIDENCE_V1>>>'
|
|
7
|
+
export const EVIDENCE_CAP = 8
|
|
8
|
+
const LEVELS = new Set(['exact', 'section', 'source', 'unavailable'])
|
|
9
|
+
const WARNINGS = new Set(['NO_MATCH', 'LOW_LEXICAL_MATCH', 'UNCALIBRATED_RELEVANCE', 'SECTION_ONLY', 'SOURCE_ONLY', 'SOURCE_UNAVAILABLE', 'SPAN_ANCHOR_MISMATCH', 'TRANSCRIPT_BOUNDED'])
|
|
10
|
+
const safeText = (value, max) => typeof value === 'string' && value.length <= max && !/(?:https?:\/\/|file:|bearer\s|token=|signature=|x-amz-|\.\.[\\/]|^\/|[A-Za-z]:[\\/])/i.test(value)
|
|
11
|
+
const isProjectEvidenceTool = (name) => ['mcp__project_context__search_context_evidence', 'mcp__project-context__search_context_evidence'].includes(String(name || ''))
|
|
12
|
+
const textContent = (content) => typeof content === 'string' ? content : Array.isArray(content) ? content.map((part) => typeof part === 'string' ? part : part?.text || '').join('\n') : ''
|
|
13
|
+
const excerptDigest = (value) => createHash('sha256').update(value, 'utf8').digest('hex')
|
|
14
|
+
|
|
15
|
+
function validCitation(raw) {
|
|
16
|
+
if (!raw || raw.v !== 1 || !/^ev1_[a-f0-9]{24,64}$/.test(raw.id || '')) return null
|
|
17
|
+
const source = raw.source || {}, locator = raw.locator || {}, revision = source.revision || {}, assessment = raw.assessment || {}, provenance = raw.provenance || {}
|
|
18
|
+
if (source.kind !== 'project_prose' || source.collection !== 'repo' || !safeText(source.label, 180) || !safeText(source.path, 500) || !/^[a-f0-9]{40,64}$/i.test(revision.value || '') || !['git', 'content_sha256'].includes(revision.kind)) return null
|
|
19
|
+
if (!LEVELS.has(locator.level) || !safeText(locator.heading || '', 180)) return null
|
|
20
|
+
if (!['high', 'medium', 'low', 'unscored'].includes(assessment.relevance) || !['direct', 'partial', 'source_only', 'unavailable'].includes(assessment.confidence) || !Array.isArray(assessment.warnings) || assessment.warnings.some((warning) => !WARNINGS.has(warning))) return null
|
|
21
|
+
if (provenance.capturedBy !== 'project-context' || provenance.method !== 'fts5_heading') return null
|
|
22
|
+
const citation = { v: 1, id: raw.id, source: { kind: source.kind, collection: source.collection, label: source.label, path: source.path, revision: { kind: revision.kind, value: revision.value, ...(safeText(revision.indexedAt || '', 80) ? { indexedAt: revision.indexedAt } : {}) } }, locator: { level: locator.level, ...(safeText(locator.heading || '', 180) ? { heading: locator.heading } : {}) }, provenance: { capturedBy: provenance.capturedBy, method: provenance.method, ...(safeText(provenance.retrievedAt || '', 80) ? { retrievedAt: provenance.retrievedAt } : {}), ...(safeText(provenance.query || '', 160) ? { query: provenance.query } : {}), ...(Number.isSafeInteger(provenance.rank) && provenance.rank > 0 ? { rank: provenance.rank } : {}) }, assessment: { relevance: assessment.relevance, confidence: assessment.confidence, warnings: [...new Set(assessment.warnings)].slice(0, 8) } }
|
|
23
|
+
if (locator.level === 'exact') {
|
|
24
|
+
if (![locator.charStart, locator.charEnd, locator.lineStart, locator.lineEnd].every(Number.isSafeInteger) || locator.charStart < 0 || locator.charEnd <= locator.charStart || locator.lineStart < 1 || locator.lineEnd < locator.lineStart || !safeText(locator.anchorBefore || '', 96) || !safeText(locator.anchorAfter || '', 96) || !safeText(raw.excerpt || '', 600) || !/^[a-f0-9]{64}$/i.test(raw.excerptSha256 || '') || excerptDigest(raw.excerpt) !== raw.excerptSha256) return null
|
|
25
|
+
citation.locator = { ...citation.locator, charStart: locator.charStart, charEnd: locator.charEnd, lineStart: locator.lineStart, lineEnd: locator.lineEnd, anchorBefore: locator.anchorBefore, anchorAfter: locator.anchorAfter }
|
|
26
|
+
citation.excerpt = raw.excerpt
|
|
27
|
+
citation.excerptSha256 = raw.excerptSha256
|
|
28
|
+
}
|
|
29
|
+
return citation
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function parseEvidenceEnvelope(toolName, content) {
|
|
33
|
+
if (!isProjectEvidenceTool(toolName)) return null
|
|
34
|
+
const text = textContent(content)
|
|
35
|
+
const start = text.indexOf(EVIDENCE_START), end = text.indexOf(EVIDENCE_END)
|
|
36
|
+
if (start < 0 || end < start || text.indexOf(EVIDENCE_START, start + EVIDENCE_START.length) >= 0 || text.indexOf(EVIDENCE_END, end + EVIDENCE_END.length) >= 0) return null
|
|
37
|
+
let raw
|
|
38
|
+
try { raw = JSON.parse(text.slice(start + EVIDENCE_START.length, end)) } catch { return null }
|
|
39
|
+
if (!raw || raw.v !== 1 || !Array.isArray(raw.citations) || raw.citations.length > EVIDENCE_CAP) return null
|
|
40
|
+
const citations = raw.citations.map(validCitation)
|
|
41
|
+
if (citations.some((citation) => !citation)) return null
|
|
42
|
+
const codes = Array.isArray(raw.summary?.warningCodes) ? raw.summary.warningCodes.filter((code) => WARNINGS.has(code)).slice(0, 8) : []
|
|
43
|
+
const status = ['evidence_found', 'weak_evidence', 'no_evidence'].includes(raw.summary?.status) ? raw.summary.status : null
|
|
44
|
+
if (!status || !Array.isArray(raw.summary?.searchedCollections) || raw.summary.searchedCollections.some((collection) => collection !== 'repo')) return null
|
|
45
|
+
return { v: 1, citations, summary: { searchedCollections: ['repo'], directCount: citations.filter((citation) => ['exact', 'section'].includes(citation.locator.level)).length, warningCodes: [...new Set(codes)], status } }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function evidenceForToolResult(toolName, content) { return parseEvidenceEnvelope(toolName, content) || undefined }
|
|
49
|
+
|
|
50
|
+
export function compactEvidenceEnvelope(envelope, maxExcerpt = 600) {
|
|
51
|
+
if (!envelope?.citations?.length) return envelope
|
|
52
|
+
return { ...envelope, citations: envelope.citations.slice(0, EVIDENCE_CAP).map((citation) => {
|
|
53
|
+
if (typeof citation.excerpt !== 'string' || citation.excerpt.length <= maxExcerpt) return citation
|
|
54
|
+
return { ...citation, excerpt: undefined, excerptSha256: undefined, locator: { ...citation.locator, level: 'unavailable' }, assessment: { ...citation.assessment, confidence: 'unavailable', warnings: [...new Set([...citation.assessment.warnings, 'TRANSCRIPT_BOUNDED', 'SOURCE_UNAVAILABLE'])].slice(0, 8) } }
|
|
55
|
+
}) }
|
|
56
|
+
}
|
package/flow-preview.mjs
CHANGED
|
@@ -82,6 +82,10 @@ function makeHandler (dir) {
|
|
|
82
82
|
return async (req, res) => {
|
|
83
83
|
const resolved = resolveUnderRoot(dir, req.url || '/')
|
|
84
84
|
if (resolved === null) return send(res, 403, 'text/plain; charset=utf-8', 'Forbidden')
|
|
85
|
+
const relative = path.relative(dir, resolved)
|
|
86
|
+
if (relative.split(path.sep)[0] === '.thinkpool-design') {
|
|
87
|
+
return send(res, 404, 'text/plain; charset=utf-8', 'Not found')
|
|
88
|
+
}
|
|
85
89
|
|
|
86
90
|
// A concrete file hit (and still a real file, not a dir) → serve it.
|
|
87
91
|
if (await isFile(resolved)) {
|
package/hermes-event-mapper.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evidenceForToolResult } from './evidence-citations.mjs'
|
|
2
|
+
|
|
1
3
|
const textOf = (content) => {
|
|
2
4
|
if (typeof content === 'string') return content
|
|
3
5
|
if (content?.type === 'text') return String(content.text || '')
|
|
@@ -31,6 +33,9 @@ export function hermesToolFor(update = {}) {
|
|
|
31
33
|
const kind = String(update.kind || '').toLowerCase()
|
|
32
34
|
const title = String(update.title || '')
|
|
33
35
|
const raw = update.rawInput && typeof update.rawInput === 'object' ? update.rawInput : {}
|
|
36
|
+
const server = String(update.server || raw.server || '')
|
|
37
|
+
const tool = String(update.tool || raw.tool || raw.name || '')
|
|
38
|
+
if (server === 'project-context' && tool === 'search_context_evidence') return { name: 'mcp__project-context__search_context_evidence', input: raw }
|
|
34
39
|
const terminal = (update.content || []).find((part) => part?.type === 'terminal')
|
|
35
40
|
const shellText = (update.content || []).map(textOf).find((text) => text.trim().startsWith('$ '))
|
|
36
41
|
const location = update.locations?.[0]?.path
|
|
@@ -94,9 +99,11 @@ export class HermesEventMapper {
|
|
|
94
99
|
this.tools.set(update.toolCallId, merged)
|
|
95
100
|
if (!['completed', 'failed'].includes(update.status)) return
|
|
96
101
|
const completedTool = hermesToolFor(merged)
|
|
102
|
+
const content = [{ type: 'text', text: outputText(merged) }]
|
|
103
|
+
const evidence = evidenceForToolResult(completedTool.name, content)
|
|
97
104
|
this._emit({
|
|
98
105
|
kind: 'tool_result', toolUseId: update.toolCallId,
|
|
99
|
-
content
|
|
106
|
+
content, ...(evidence ? { evidence } : {}),
|
|
100
107
|
toolInput: { ...(prior.input || {}), ...(completedTool.input || {}) },
|
|
101
108
|
isError: update.status === 'failed',
|
|
102
109
|
durationMs: prior.startedAt ? Date.now() - prior.startedAt : undefined,
|