thinkpool-pair 0.7.353 → 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 CHANGED
@@ -55,10 +55,12 @@ 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'
61
62
  import { gitDiffReport } from './git-diff-report.mjs'
63
+ import { repoSearch } from './repo-search.mjs'
62
64
  import { probeHermesRuntime } from './hermes-probe.mjs'
63
65
  import { hermesRequiredMcpTools, hermesRoleFor } from './hermes-policy.mjs'
64
66
  import { canonicalRoomFilePath, waitForNativeImages } from './codex-images.mjs'
@@ -2262,6 +2264,10 @@ function worktreeSnapshot(cwd) {
2262
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 }) {
2263
2265
  if (sessions.has(id)) return
2264
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}`)
2265
2271
  mode = structuredModeForSlice(runtime, { mode, sliceType, flowRole })
2266
2272
  // No explicit mode → a sensible default per runtime (see defaultModeForRuntime):
2267
2273
  // codex → bypassPermissions, so a freshly-opened codex terminal can fetch /
@@ -4539,7 +4545,7 @@ channel
4539
4545
  // Display the clean body (web sends `body` when the agent `text` carries host
4540
4546
  // file paths) + the uploaded attachments, so the partner shows the image — not
4541
4547
  // a raw /var path. The agent already got the full `text` via sendTurn above.
4542
- const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(payload.authorId ? { authorId: payload.authorId } : {}), ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
4548
+ const evt = { kind: 'you', text: payload.body != null ? String(payload.body) : text, cid: payload.cid, by: payload.by, ...(payload.authorId ? { authorId: payload.authorId } : {}), ...(payload.steered ? { steered: true } : {}), ...(Array.isArray(payload.files) && payload.files.length ? { files: payload.files } : {}), ...(Array.isArray(payload.pastes) && payload.pastes.length ? { pastes: payload.pastes } : {}) }
4543
4549
  pushLog(s, evt)
4544
4550
  bcast('code-event', { term: payload.term, evt })
4545
4551
  }
@@ -4577,6 +4583,19 @@ channel
4577
4583
  } catch { ctlLine('Working-tree diff unavailable outside a readable Git checkout') }
4578
4584
  return
4579
4585
  }
4586
+ const findMatch = text.match(/^\/find(?:\s+([\s\S]+))?$/i)
4587
+ if (findMatch) {
4588
+ const search = repoSearch({ cwd: s.cwd || process.cwd(), query: findMatch[1] || '' })
4589
+ const evt = {
4590
+ kind: 'repo-search',
4591
+ ...search,
4592
+ by: payload.by,
4593
+ cid: payload.cid,
4594
+ }
4595
+ pushLog(s, evt)
4596
+ bcast('code-event', { term: payload.term, evt })
4597
+ return
4598
+ }
4580
4599
  if (/^\/credits\s*$/.test(text) && s.runtime !== 'hermes') {
4581
4600
  if (s.runtime !== 'codex' || typeof s.session?.accountUsage !== 'function') {
4582
4601
  ctlLine('Credit balance is unavailable for this runtime; use /usage for provider limits')
@@ -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 → start time, for the duration badge
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
- emit({ kind: 'tool_result', toolUseId: b.tool_use_id, content: b.content, isError: !!b.is_error, durationMs: start != null ? Date.now() - start : undefined, parentToolUseId: m.parent_tool_use_id || null })
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
@@ -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',
@@ -47,6 +49,7 @@ export const CODE_EVENT_REGISTRY = Object.freeze({
47
49
  'side-handback': P,
48
50
  answer: C,
49
51
  control: C,
52
+ 'repo-search': C,
50
53
  clear: C,
51
54
  'queue-add': C,
52
55
  'queue-cancel': C,
@@ -59,6 +62,7 @@ export const CODE_EVENT_REGISTRY = Object.freeze({
59
62
  image: E,
60
63
  'needs-input': E,
61
64
  'needs-resolved': E,
65
+ continuation: E,
62
66
  'turn-done': E,
63
67
  })
64
68
 
@@ -105,3 +109,9 @@ export function unknownCodeEventNotice (eventOrKind) {
105
109
  }
106
110
 
107
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
+ }
@@ -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
- this._emit({ kind: 'tool_result', toolUseId: it.id, content: [{ type: 'text', text: it.error?.message || resultText(it.result) }], isError: it.status === 'failed' || !!it.error, durationMs, parentToolUseId: null })
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.
@@ -19,6 +19,7 @@ export const CODE_ROOM_COMMANDS = Object.freeze([
19
19
  command('/usage', 'session usage and provider limits', 'control'),
20
20
  command('/context', 'current context-window usage', 'control'),
21
21
  command('/diff', 'Git changes and branch status', 'control'),
22
+ command('/find', 'find relevant files in this repository', 'control', 'query'),
22
23
  command('/compact', 'compact context', 'runtime'),
23
24
  command('/clear', 'clear context · confirms', 'clear'),
24
25
  command('/model', 'pick model — opens selector', 'model'),
@@ -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
- const revision = sourceRevision(source)
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 { sourceKind: 'preview', previewId, revision, sourcePath, workspaceRoot: root, source, outbox, route, previewRoot, captureKey }
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
- return { ok: true, record, request: { previewId, revision: record.revision, mode, intent, target, movement, asset, cid: clean(payload.cid, 80) } }
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}\nSelected element:\n${JSON.stringify(edit.target, null, 2)}\n\n${designAction(edit)}`).join('\n\n---\n\n')
311
- : `Selected element:\n${JSON.stringify(request.target, null, 2)}\n\n${designAction(request)}`
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. Locate the represented element in the application source inside the workspace and make the smallest source change that produces the requested result.\n- If the 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.`
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,4 @@
1
+ export const DESIGN_SOURCE_MAP_VERSION = 1
2
+ export const DESIGN_SOURCE_MAP_RELATIVE = '.thinkpool-design/source-map.json'
3
+ export const MAX_DESIGN_SOURCE_MAP_BYTES = 2 * 1024 * 1024
4
+ export const MAX_DESIGN_SOURCE_ENTRIES = 50_000
@@ -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
+ }