thinkpool-pair 0.7.363 → 0.7.365

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +18 -0
  2. package/README.md +20 -0
  3. package/abort-turn-barrier.mjs +1 -23
  4. package/account.mjs +1 -1437
  5. package/acp-client.mjs +1 -140
  6. package/agent-detect.mjs +1 -28
  7. package/agent-notify.mjs +1 -142
  8. package/agent-visibility.mjs +1 -67
  9. package/auth-store.mjs +1 -120
  10. package/bridge.mjs +1 -6240
  11. package/byok-detect.mjs +1 -126
  12. package/claude-command-catalog.mjs +1 -91
  13. package/claude-session.mjs +1 -1519
  14. package/code-event-contract.mjs +1 -118
  15. package/codex-app-server.mjs +1 -340
  16. package/codex-commands.mjs +1 -83
  17. package/codex-event-mapper.mjs +1 -232
  18. package/codex-images.mjs +1 -69
  19. package/codex-mcp-http.mjs +1 -131
  20. package/codex-session.mjs +1 -1295
  21. package/command-catalog.mjs +1 -118
  22. package/command-guidance.mjs +1 -8
  23. package/context-contract.mjs +1 -95
  24. package/context-windows.mjs +1 -107
  25. package/cross-terminal.mjs +1 -789
  26. package/cumulative-event-relay.mjs +1 -53
  27. package/design-edit.mjs +1 -424
  28. package/design-source-contract.mjs +1 -4
  29. package/direct-pair-room.mjs +1 -57
  30. package/dispatch-lease.mjs +1 -37
  31. package/dispatch-permission-cleanup.mjs +1 -86
  32. package/edit-diff.mjs +1 -136
  33. package/error-recovery.mjs +1 -50
  34. package/event-bounds.mjs +1 -121
  35. package/event-delivery-queue.mjs +1 -0
  36. package/event-id.mjs +1 -549
  37. package/evidence-citations.mjs +1 -50
  38. package/evidence-compact.mjs +1 -11
  39. package/flow-assembly.mjs +1 -196
  40. package/flow-budget.mjs +1 -84
  41. package/flow-conductor.mjs +1 -259
  42. package/flow-context-store.mjs +1 -387
  43. package/flow-host-revert.mjs +1 -42
  44. package/flow-models.mjs +1 -139
  45. package/flow-preview.mjs +1 -148
  46. package/flow-receipt.mjs +1 -122
  47. package/flow-redispatch.mjs +1 -72
  48. package/flow-review-gate.mjs +1 -402
  49. package/flow-review-reflect.mjs +1 -115
  50. package/flow-review.mjs +1 -152
  51. package/flow-scope-evidence.mjs +1 -117
  52. package/flow-skill-registry.mjs +1 -140
  53. package/flow-task-graph.mjs +1 -562
  54. package/flow-worktree.mjs +1 -71
  55. package/git-diff-report.mjs +1 -121
  56. package/hermes-delegation-guard.mjs +1 -14
  57. package/hermes-event-mapper.mjs +1 -194
  58. package/hermes-isolation.mjs +1 -61
  59. package/hermes-model-cache.mjs +1 -54
  60. package/hermes-policy.mjs +1 -91
  61. package/hermes-probe.mjs +1 -57
  62. package/hermes-session.mjs +1 -683
  63. package/hermes-setup.mjs +1 -167
  64. package/host-memory.mjs +1 -116
  65. package/interrupted-resume.mjs +1 -95
  66. package/keep-awake.mjs +1 -148
  67. package/key-shape.mjs +1 -49
  68. package/lane-continuation.mjs +1 -83
  69. package/lane-lifecycle.mjs +1 -189
  70. package/lane-worktree.mjs +1 -77
  71. package/launcher.mjs +1 -354
  72. package/mcp-flight-recorder.mjs +1 -0
  73. package/mockup-delivery.mjs +1 -57
  74. package/model-prices.mjs +1 -113
  75. package/package.json +15 -4
  76. package/pair-bus.mjs +1 -98
  77. package/pair-control-authority.mjs +1 -89
  78. package/past-work-search.mjs +1 -105
  79. package/plan-meters.mjs +1 -144
  80. package/presence.mjs +1 -191
  81. package/privacy-report.mjs +1 -108
  82. package/provider-resilience.mjs +1 -356
  83. package/provider.mjs +1 -133
  84. package/providers.mjs +1 -491
  85. package/publish-guard.mjs +2 -0
  86. package/publish-manifest.json +129 -0
  87. package/question-response.mjs +1 -58
  88. package/reap-terminal.mjs +1 -68
  89. package/recap.mjs +1 -297
  90. package/replay-transport.mjs +1 -64
  91. package/repo-search.mjs +1 -190
  92. package/review-check.mjs +1 -182
  93. package/runtime-contract.mjs +1 -93
  94. package/runtime-registry.mjs +1 -64
  95. package/runtime-session.mjs +1 -20
  96. package/scheduled-run-admission.mjs +1 -364
  97. package/scheduled-runs.mjs +1 -268
  98. package/sdk-admission.mjs +1 -9
  99. package/sdk-smoke.mjs +1 -61
  100. package/serve-consent.mjs +1 -118
  101. package/serve-dir.mjs +1 -40
  102. package/service.mjs +1 -877
  103. package/session-store.mjs +1 -484
  104. package/side-lane.mjs +1 -63
  105. package/supabase-key.mjs +1 -176
  106. package/supervisor-ready.mjs +1 -57
  107. package/switch-provider.mjs +1 -84
  108. package/terminal-name.mjs +1 -359
  109. package/terminal-row-reconcile.mjs +1 -70
  110. package/thinkpool-capabilities.json +11 -2
  111. package/thinkpool-prompt-contracts.mjs +1 -85
  112. package/thinkpool-room-prompt.mjs +1 -187
  113. package/transcript-sanitize.mjs +1 -332
  114. package/turn-stall.mjs +1 -61
  115. package/update-gate.mjs +1 -53
  116. package/viewport.mjs +1 -810
  117. package/worker-completion.mjs +1 -57
package/flow-worktree.mjs CHANGED
@@ -1,71 +1 @@
1
- // Thinkpool Flow — per-lane git worktree isolation.
2
- //
3
- // A Flow task-lane builds its slice in its OWN worktree. A branch is NOT isolation
4
- // when ≥2 lanes share one working tree (M57) — two lanes editing files in the shared
5
- // checkout collide. A worktree gives each lane a disjoint tree + its own branch, and
6
- // that branch is the atomic-revert target (commit_sha on flow_tasks, Step 4).
7
- //
8
- // Pure over injected git/fs so it's unit-testable against a temp repo.
9
- // Spec: docs/specs/2026-06-29-thinkpool-flow.md (Step 2).
10
-
11
- import { execFileSync } from 'node:child_process'
12
- import path from 'node:path'
13
- import fs from 'node:fs'
14
- import { linkLocalEnvIntoWorktree } from './lane-worktree.mjs'
15
-
16
- const ROOT = process.cwd() // the bridge's checkout (the shared main repo root)
17
-
18
- // The lane's branch + directory for a (flowId, taskKey). Deterministic so a
19
- // re-dispatch of the same task reuses the same worktree.
20
- export function worktreeSpec ({ flowId, taskKey, root = ROOT }) {
21
- const wtRoot = path.join(root, '.claude', 'worktrees')
22
- const short = String(flowId).replace(/-/g, '').slice(0, 8)
23
- const safeKey = String(taskKey).toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 40).replace(/^-+|-+$/g, '') || 'task'
24
- const branch = `flow/${short}/${safeKey}`
25
- const dir = path.join(wtRoot, `flow-${short}-${safeKey}`)
26
- return { branch, dir, wtRoot }
27
- }
28
-
29
- // Create a worktree for a task-lane off `base`. Idempotent: if the dir already holds a
30
- // worktree, reuse it. Returns { dir, branch, created }.
31
- //
32
- // FL-M6/dispatch — the base must RESOLVE in this repo. The old hard default `origin/main`
33
- // threw on any repo without an `origin` remote or a non-`main` default branch (e.g. a
34
- // fresh `git init` checkout), and the dispatch loop swallowed the throw → NO lane ever
35
- // spawned. Resolve the first ref that actually exists: caller's base → origin/main →
36
- // origin/HEAD → main → master → HEAD.
37
- export function createFlowWorktree ({ flowId, taskKey, base = null, root = ROOT, git = runGit, fsImpl = fs }) {
38
- const { branch, dir, wtRoot } = worktreeSpec({ flowId, taskKey, root })
39
- if (fsImpl.existsSync(path.join(dir, '.git'))) {
40
- linkLocalEnvIntoWorktree({ root, dir, fsImpl })
41
- return { dir, branch, created: false }
42
- }
43
- fsImpl.mkdirSync(wtRoot, { recursive: true })
44
- let ref = base
45
- if (!ref) {
46
- for (const cand of ['origin/main', 'origin/HEAD', 'main', 'master', 'HEAD']) {
47
- try { git(['rev-parse', '--verify', '--quiet', cand], root); ref = cand; break } catch { /* try next candidate */ }
48
- }
49
- ref = ref || 'HEAD'
50
- }
51
- try {
52
- git(['worktree', 'add', '-b', branch, dir, ref], root)
53
- } catch {
54
- // Branch already exists (a prior dispatch of this task) — check it out instead.
55
- git(['worktree', 'add', dir, branch], root)
56
- }
57
- linkLocalEnvIntoWorktree({ root, dir, fsImpl })
58
- return { dir, branch, created: true }
59
- }
60
-
61
- // Remove a lane's worktree + delete its branch (on revert / cancel / flow cancel).
62
- // Best-effort: missing worktree/branch is not an error.
63
- export function removeFlowWorktree ({ flowId, taskKey, root = ROOT, git = runGit }) {
64
- const { branch, dir } = worktreeSpec({ flowId, taskKey, root })
65
- try { git(['worktree', 'remove', dir, '--force'], root) } catch { /* already gone */ }
66
- try { git(['branch', '-D', branch], root) } catch { /* already gone */ }
67
- }
68
-
69
- function runGit (args, cwd) {
70
- return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' })
71
- }
1
+ import{execFileSync as r}from"node:child_process";import e from"node:path";import o from"node:fs";import{linkLocalEnvIntoWorktree as t}from"./lane-worktree.mjs";const c=process.cwd();export function worktreeSpec({flowId:r,taskKey:o,root:t=c}){const i=e.join(t,".claude","worktrees"),n=String(r).replace(/-/g,"").slice(0,8),a=String(o).toLowerCase().replace(/[^a-z0-9-]/g,"-").slice(0,40).replace(/^-+|-+$/g,"")||"task";return{branch:`flow/${n}/${a}`,dir:e.join(i,`flow-${n}-${a}`),wtRoot:i}}export function createFlowWorktree({flowId:r,taskKey:n,base:a=null,root:s=c,git:d=i,fsImpl:f=o}){const{branch:p,dir:l,wtRoot:w}=worktreeSpec({flowId:r,taskKey:n,root:s});if(f.existsSync(e.join(l,".git")))return t({root:s,dir:l,fsImpl:f}),{dir:l,branch:p,created:!1};f.mkdirSync(w,{recursive:!0});let k=a;if(!k){for(const r of["origin/main","origin/HEAD","main","master","HEAD"])try{d(["rev-parse","--verify","--quiet",r],s),k=r;break}catch{}k=k||"HEAD"}try{d(["worktree","add","-b",p,l,k],s)}catch{d(["worktree","add",l,p],s)}return t({root:s,dir:l,fsImpl:f}),{dir:l,branch:p,created:!0}}export function removeFlowWorktree({flowId:r,taskKey:e,root:o=c,git:t=i}){const{branch:n,dir:a}=worktreeSpec({flowId:r,taskKey:e,root:o});try{t(["worktree","remove",a,"--force"],o)}catch{}try{t(["branch","-D",n],o)}catch{}}function i(e,o){return r("git",e,{cwd:o,stdio:["ignore","pipe","pipe"],encoding:"utf8"})}
@@ -1,121 +1 @@
1
- import { execFileSync } from 'node:child_process'
2
-
3
- const DEFAULT_MAX_FILES = 24
4
- const DEFAULT_MAX_COMMITS = 5
5
- const DEFAULT_MAX_CHARS = 1800
6
-
7
- const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`
8
-
9
- function defaultRunGit(args, cwd) {
10
- return execFileSync('git', args, {
11
- cwd,
12
- encoding: 'utf8',
13
- timeout: 3000,
14
- stdio: ['ignore', 'pipe', 'pipe'],
15
- })
16
- }
17
-
18
- function cleanLine(value, max = 240) {
19
- return Array.from(String(value || ''), (character) => {
20
- const code = character.charCodeAt(0)
21
- const removableControl = (code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f
22
- return removableControl ? '' : character
23
- }).join('')
24
- .replace(/\s+/g, ' ')
25
- .trim()
26
- .slice(0, max)
27
- }
28
-
29
- function gitText(runGit, cwd, args) {
30
- return String(runGit(args, cwd) || '').trim()
31
- }
32
-
33
- function tryGit(runGit, cwd, args) {
34
- try { return gitText(runGit, cwd, args) } catch { return '' }
35
- }
36
-
37
- function resolveComparisonRef(runGit, cwd) {
38
- const remoteHead = tryGit(runGit, cwd, ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
39
- if (remoteHead && tryGit(runGit, cwd, ['rev-parse', '--verify', '--quiet', remoteHead])) return remoteHead
40
- for (const candidate of ['origin/main', 'origin/master', 'main', 'master']) {
41
- if (tryGit(runGit, cwd, ['rev-parse', '--verify', '--quiet', candidate])) return candidate
42
- }
43
- return ''
44
- }
45
-
46
- function boundedReport(lines, maxChars) {
47
- const kept = []
48
- let length = 0
49
- for (const raw of lines) {
50
- const line = cleanLine(raw)
51
- if (!line) continue
52
- const nextLength = length + (kept.length ? 1 : 0) + line.length
53
- if (nextLength > maxChars) {
54
- const suffix = '… output shortened'
55
- if (length + (kept.length ? 1 : 0) + suffix.length <= maxChars) kept.push(suffix)
56
- break
57
- }
58
- kept.push(line)
59
- length = nextLength
60
- }
61
- return kept.join('\n')
62
- }
63
-
64
- export function gitDiffReport({
65
- cwd = process.cwd(),
66
- runGit = defaultRunGit,
67
- maxFiles = DEFAULT_MAX_FILES,
68
- maxCommits = DEFAULT_MAX_COMMITS,
69
- maxChars = DEFAULT_MAX_CHARS,
70
- } = {}) {
71
- gitText(runGit, cwd, ['rev-parse', '--show-toplevel'])
72
-
73
- const status = tryGit(runGit, cwd, ['status', '--porcelain=v1', '--untracked-files=normal'])
74
- const statusLines = status ? status.split(/\r?\n/).filter(Boolean) : []
75
- const branch = tryGit(runGit, cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']) || 'detached HEAD'
76
- const head = tryGit(runGit, cwd, ['rev-parse', '--short=8', 'HEAD']) || 'unknown'
77
- const subject = cleanLine(tryGit(runGit, cwd, ['log', '-1', '--pretty=%s']), 160)
78
- const comparisonRef = resolveComparisonRef(runGit, cwd)
79
- const lines = []
80
-
81
- if (statusLines.length) {
82
- lines.push(`${plural(statusLines.length, 'uncommitted file')}`)
83
- for (const line of statusLines.slice(0, Math.max(0, maxFiles))) lines.push(line)
84
- if (statusLines.length > maxFiles) lines.push(`… ${plural(statusLines.length - maxFiles, 'more file')}`)
85
- } else {
86
- lines.push('No uncommitted changes')
87
- }
88
-
89
- lines.push(`Branch · ${branch} · ${head}`)
90
-
91
- if (!comparisonRef) {
92
- if (subject) lines.push(`Latest commit · ${head} — ${subject}`)
93
- return boundedReport(lines, maxChars)
94
- }
95
-
96
- const counts = tryGit(runGit, cwd, ['rev-list', '--left-right', '--count', `${comparisonRef}...HEAD`])
97
- .split(/\s+/)
98
- .map((value) => Number(value))
99
- const behind = Number.isFinite(counts[0]) ? counts[0] : 0
100
- const ahead = Number.isFinite(counts[1]) ? counts[1] : 0
101
-
102
- if (ahead > 0) {
103
- lines.push(`${plural(ahead, 'commit')} ahead of ${comparisonRef}${behind ? ` · ${plural(behind, 'commit')} behind` : ''}`)
104
- const commitLines = tryGit(runGit, cwd, ['log', `--max-count=${Math.max(0, maxCommits)}`, '--pretty=%h %s', `${comparisonRef}..HEAD`])
105
- .split(/\r?\n/)
106
- .filter(Boolean)
107
- if (commitLines.length) {
108
- lines.push(`Commits not in ${comparisonRef}`)
109
- lines.push(...commitLines)
110
- if (ahead > commitLines.length) lines.push(`… ${plural(ahead - commitLines.length, 'more commit')}`)
111
- }
112
- } else if (behind > 0) {
113
- if (subject) lines.push(`Latest commit · ${head} — ${subject} · already in ${comparisonRef}`)
114
- lines.push(`${plural(behind, 'commit')} behind ${comparisonRef}`)
115
- } else {
116
- if (subject) lines.push(`Latest commit · ${head} — ${subject}`)
117
- lines.push(`Up to date with ${comparisonRef}`)
118
- }
119
-
120
- return boundedReport(lines, maxChars)
121
- }
1
+ import{execFileSync as t}from"node:child_process";const e=(t,e)=>`${t} ${e}${1===t?"":"s"}`;function n(e,n){return t("git",e,{cwd:n,encoding:"utf8",timeout:3e3,stdio:["ignore","pipe","pipe"]})}function r(t,e=240){return Array.from(String(t||""),t=>{const e=t.charCodeAt(0);return e<=31&&9!==e&&10!==e&&13!==e||127===e?"":t}).join("").replace(/\s+/g," ").trim().slice(0,e)}function i(t,e,n){return String(t(n,e)||"").trim()}function o(t,e,n){try{return i(t,e,n)}catch{return""}}function s(t,e){const n=[];let i=0;for(const o of t){const t=r(o);if(!t)continue;const s=i+(n.length?1:0)+t.length;if(s>e){const t="… output shortened";i+(n.length?1:0)+t.length<=e&&n.push(t);break}n.push(t),i=s}return n.join("\n")}export function gitDiffReport({cwd:t=process.cwd(),runGit:u=n,maxFiles:c=24,maxCommits:m=5,maxChars:h=1800}={}){i(u,t,["rev-parse","--show-toplevel"]);const a=o(u,t,["status","--porcelain=v1","--untracked-files=normal"]),l=a?a.split(/\r?\n/).filter(Boolean):[],p=o(u,t,["symbolic-ref","--quiet","--short","HEAD"])||"detached HEAD",f=o(u,t,["rev-parse","--short=8","HEAD"])||"unknown",$=r(o(u,t,["log","-1","--pretty=%s"]),160),g=function(t,e){const n=o(t,e,["symbolic-ref","--quiet","--short","refs/remotes/origin/HEAD"]);if(n&&o(t,e,["rev-parse","--verify","--quiet",n]))return n;for(const n of["origin/main","origin/master","main","master"])if(o(t,e,["rev-parse","--verify","--quiet",n]))return n;return""}(u,t),d=[];if(l.length){d.push(`${e(l.length,"uncommitted file")}`);for(const t of l.slice(0,Math.max(0,c)))d.push(t);l.length>c&&d.push(`… ${e(l.length-c,"more file")}`)}else d.push("No uncommitted changes");if(d.push(`Branch · ${p} · ${f}`),!g)return $&&d.push(`Latest commit · ${f} — ${$}`),s(d,h);const v=o(u,t,["rev-list","--left-right","--count",`${g}...HEAD`]).split(/\s+/).map(t=>Number(t)),y=Number.isFinite(v[0])?v[0]:0,b=Number.isFinite(v[1])?v[1]:0;if(b>0){d.push(`${e(b,"commit")} ahead of ${g}${y?` · ${e(y,"commit")} behind`:""}`);const n=o(u,t,["log",`--max-count=${Math.max(0,m)}`,"--pretty=%h %s",`${g}..HEAD`]).split(/\r?\n/).filter(Boolean);n.length&&(d.push(`Commits not in ${g}`),d.push(...n),b>n.length&&d.push(`… ${e(b-n.length,"more commit")}`))}else y>0?($&&d.push(`Latest commit · ${f} — ${$} · already in ${g}`),d.push(`${e(y,"commit")} behind ${g}`)):($&&d.push(`Latest commit · ${f} — ${$}`),d.push(`Up to date with ${g}`));return s(d,h)}
@@ -1,15 +1,2 @@
1
1
  #!/usr/bin/env node
2
- // Hermes shell hook: the ACP toolset currently includes delegate_task and ACP
3
- // has no per-session tool exclusion. Block it before execution so all delegated
4
- // work remains a visible ThinkPool lane.
5
- let input = ''
6
- process.stdin.setEncoding('utf8')
7
- process.stdin.on('data', (chunk) => { input += chunk })
8
- process.stdin.on('end', () => {
9
- let payload = null
10
- try { payload = JSON.parse(input) } catch { /* malformed hook input fails closed below */ }
11
- const blocking = !payload || payload.tool_name === 'delegate_task'
12
- process.stdout.write(JSON.stringify(blocking
13
- ? { action: 'block', message: 'ThinkPool blocks hidden Hermes delegation. Open visible ThinkPool lanes instead.' }
14
- : {}) + '\n')
15
- })
2
+ let e="";process.stdin.setEncoding("utf8"),process.stdin.on("data",s=>{e+=s}),process.stdin.on("end",()=>{let s=null;try{s=JSON.parse(e)}catch{}const n=!s||"delegate_task"===s.tool_name;process.stdout.write(JSON.stringify(n?{action:"block",message:"ThinkPool blocks hidden Hermes delegation. Open visible ThinkPool lanes instead."}:{})+"\n")});
@@ -1,194 +1 @@
1
- import { evidenceForToolResult } from './evidence-citations.mjs'
2
-
3
- const textOf = (content) => {
4
- if (typeof content === 'string') return content
5
- if (content?.type === 'text') return String(content.text || '')
6
- if (content?.type === 'content') return textOf(content.content)
7
- return ''
8
- }
9
- const outputText = (update) => {
10
- if (typeof update?.rawOutput === 'string') return update.rawOutput
11
- const text = (update?.content || []).map(textOf).filter(Boolean).join('\n')
12
- if (text) return text
13
- if (update?.rawOutput != null) {
14
- try { return JSON.stringify(update.rawOutput) } catch { return String(update.rawOutput) }
15
- }
16
- return ''
17
- }
18
-
19
- const diffInput = (content) => {
20
- const diffs = (Array.isArray(content) ? content : []).filter((part) => part?.type === 'diff')
21
- if (!diffs.length) return null
22
- if (diffs.length === 1) return {
23
- file_path: diffs[0].path || '', path: diffs[0].path || '',
24
- old_string: diffs[0].oldText || '', new_string: diffs[0].newText || '',
25
- }
26
- return {
27
- file_path: diffs[0].path || '', path: diffs[0].path || '',
28
- edits: diffs.map((diff) => ({ file_path: diff.path || '', old_string: diff.oldText || '', new_string: diff.newText || '' })),
29
- }
30
- }
31
-
32
- export function hermesToolFor(update = {}) {
33
- const kind = String(update.kind || '').toLowerCase()
34
- const title = String(update.title || '')
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 }
39
- const terminal = (update.content || []).find((part) => part?.type === 'terminal')
40
- const shellText = (update.content || []).map(textOf).find((text) => text.trim().startsWith('$ '))
41
- const location = update.locations?.[0]?.path
42
- if (kind === 'execute' || terminal || shellText) return { name: 'Bash', input: { ...raw, command: raw.command || shellText?.trim().slice(2) || title } }
43
- if (kind === 'edit') {
44
- const diff = diffInput(update.content)
45
- const name = diff?.edits ? 'MultiEdit' : title.toLowerCase().includes('write') ? 'Write' : 'Edit'
46
- return { name, input: { ...raw, ...(diff || {}), file_path: diff?.file_path || raw.path || location || '', path: diff?.path || raw.path || location || '' } }
47
- }
48
- if (kind === 'read') return { name: 'Read', input: { ...raw, file_path: raw.path || location || '', path: raw.path || location || '' } }
49
- if (kind === 'search') return { name: 'Grep', input: raw }
50
- if (kind === 'fetch') return { name: 'WebFetch', input: raw }
51
- return { name: title || 'HermesTool', input: raw }
52
- }
53
-
54
- export class HermesEventMapper {
55
- constructor({ onEvent, sessionId = null, model = null } = {}) {
56
- this.onEvent = onEvent
57
- this.sessionId = sessionId
58
- this.model = model
59
- this.tools = new Map()
60
- this.text = ''
61
- this.thought = ''
62
- this.textCid = null
63
- }
64
-
65
- _emit(event) { try { this.onEvent?.(event) } catch { /* consumer isolation */ } }
66
-
67
- startSession({ sessionId, models, modes, commands = [] } = {}) {
68
- this.sessionId = sessionId || this.sessionId
69
- this.model = models?.currentModelId || this.model
70
- const list = (models?.availableModels || []).map((item) => ({ value: item.modelId, displayName: item.name || item.modelId, description: item.description || '' }))
71
- this._emit({ kind: 'system', sessionId: this.sessionId, model: this.model, commands, models: list, modes })
72
- if (list.length) this._emit({ kind: 'models', models: list, model: this.model })
73
- }
74
-
75
- push(params = {}) {
76
- if (params.sessionId && this.sessionId && params.sessionId !== this.sessionId) return
77
- const update = params.update || params
78
- switch (update.sessionUpdate) {
79
- case 'agent_message_chunk': {
80
- const chunk = textOf(update.content)
81
- if (!chunk) return
82
- this.text += chunk
83
- this.textCid ||= `hermes-stream:${this.sessionId || 'session'}:${Date.now()}`
84
- this._emit({ kind: 'assistant_stream', cid: this.textCid, text: this.text, streaming: true })
85
- return
86
- }
87
- case 'agent_thought_chunk':
88
- this.thought += textOf(update.content)
89
- return
90
- case 'tool_call': {
91
- const tool = hermesToolFor(update)
92
- this.tools.set(update.toolCallId, { ...update, ...tool, startedAt: Date.now() })
93
- this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: update.toolCallId, name: tool.name, input: tool.input }], parentToolUseId: null })
94
- return
95
- }
96
- case 'tool_call_update': {
97
- const prior = this.tools.get(update.toolCallId) || {}
98
- const merged = { ...prior, ...update }
99
- this.tools.set(update.toolCallId, merged)
100
- if (!['completed', 'failed'].includes(update.status)) return
101
- const completedTool = hermesToolFor(merged)
102
- const content = [{ type: 'text', text: outputText(merged) }]
103
- const evidence = evidenceForToolResult(completedTool.name, content)
104
- this._emit({
105
- kind: 'tool_result', toolUseId: update.toolCallId,
106
- content, ...(evidence ? { evidence } : {}),
107
- toolInput: { ...(prior.input || {}), ...(completedTool.input || {}) },
108
- isError: update.status === 'failed',
109
- durationMs: prior.startedAt ? Date.now() - prior.startedAt : undefined,
110
- parentToolUseId: null,
111
- })
112
- this.tools.delete(update.toolCallId)
113
- return
114
- }
115
- case 'usage_update': {
116
- const used = Number(update.used) || 0
117
- const max = Number(update.size) || 0
118
- this._emit({ kind: 'usage', ctx: { used, max, pct: max ? Math.min(100, Math.round((used / max) * 100)) : 0, over: max > 0 && used > max, model: this.model } })
119
- return
120
- }
121
- case 'available_commands_update':
122
- // ACP commands carry useful UI metadata. Keep strings accepted for
123
- // older runtimes, but never flatten a native catalog on the way out.
124
- this._emit({ kind: 'system', sessionId: this.sessionId, model: this.model, commands: (update.availableCommands || []).map((command) => {
125
- if (typeof command === 'string') return command.startsWith('/') ? command : `/${command}`
126
- const name = String(command?.name || '').replace(/^\/+/, '')
127
- const hint = command?.input?.hint || command?.inputHint || command?.input_hint || ''
128
- return {
129
- name: `/${name}`,
130
- description: String(command?.description || ''),
131
- ...(hint ? { inputHint: String(hint) } : {}),
132
- }
133
- }).filter((command) => typeof command === 'string' ? command !== '/' : command.name !== '/') })
134
- return
135
- case 'current_mode_update':
136
- this._emit({ kind: 'mode', mode: update.currentModeId })
137
- return
138
- case 'plan': {
139
- const todos = (update.entries || []).map((entry) => ({ content: entry.content || '', activeForm: entry.content || '', status: entry.status === 'in_progress' ? 'in_progress' : entry.status === 'completed' ? 'completed' : 'pending' }))
140
- this._emit({ kind: 'assistant', blocks: [{ type: 'tool_use', id: `hermes-plan:${this.sessionId}`, name: 'TodoWrite', input: { todos } }], parentToolUseId: null })
141
- return
142
- }
143
- default:
144
- return
145
- }
146
- }
147
-
148
- finishTurn({ stopReason = 'end_turn', usage = null } = {}) {
149
- for (const [toolCallId, tool] of this.tools) {
150
- const cancelled = stopReason === 'cancelled'
151
- this._emit({
152
- kind: 'tool_result', toolUseId: toolCallId,
153
- content: [{ type: 'text', text: cancelled ? '[cancelled]' : 'Hermes ended the turn without reporting this tool result.' }],
154
- toolInput: tool.input || undefined,
155
- isError: !cancelled && stopReason !== 'end_turn',
156
- missing: !cancelled,
157
- durationMs: tool.startedAt ? Date.now() - tool.startedAt : undefined,
158
- parentToolUseId: null,
159
- })
160
- }
161
- this.tools.clear()
162
- const blocks = []
163
- if (this.thought) blocks.push({ type: 'thinking', text: this.thought, reasoningKind: 'thought' })
164
- if (this.text) blocks.push({ type: 'text', text: this.text })
165
- if (blocks.length) this._emit({ kind: 'assistant', blocks, parentToolUseId: null, ...(this.textCid ? { replacesCid: this.textCid } : {}) })
166
- this._emit({
167
- kind: 'result', subtype: stopReason === 'cancelled' ? 'aborted' : stopReason === 'end_turn' ? 'success' : 'error_during_execution',
168
- sessionId: this.sessionId, model: this.model, costUsd: null,
169
- usage: usage ? {
170
- input_tokens: Number(usage.inputTokens) || 0,
171
- cache_read_input_tokens: Number(usage.cachedReadTokens) || 0,
172
- cache_creation_input_tokens: 0,
173
- output_tokens: (Number(usage.outputTokens) || 0) + (Number(usage.thoughtTokens) || 0),
174
- } : null,
175
- numTurns: 1, durationMs: undefined, denials: 0, resultText: this.text || null,
176
- })
177
- this.text = ''
178
- this.thought = ''
179
- this.textCid = null
180
- }
181
-
182
- compactOutcome() {
183
- // /compact is handled locally by Hermes and its response is the only
184
- // authoritative success signal. Do not infer a reset from a successful
185
- // ACP envelope: no-op and failure also return end_turn.
186
- const text = String(this.text || '')
187
- const hit = text.match(/Context compressed:\s*(\d+)\s*->\s*(\d+)\s*messages[\s\S]*?~?([\d,]+)\s*->\s*~?([\d,]+)\s*tokens/i)
188
- if (!hit) return null
189
- return {
190
- preMessages: Number(hit[1]), postMessages: Number(hit[2]),
191
- preTokens: Number(hit[3].replace(/,/g, '')), postTokens: Number(hit[4].replace(/,/g, '')),
192
- }
193
- }
194
- }
1
+ import{evidenceForToolResult as t}from"./evidence-citations.mjs";const e=t=>"string"==typeof t?t:"text"===t?.type?String(t.text||""):"content"===t?.type?e(t.content):"",s=t=>{if("string"==typeof t?.rawOutput)return t.rawOutput;const s=(t?.content||[]).map(e).filter(Boolean).join("\n");if(s)return s;if(null!=t?.rawOutput)try{return JSON.stringify(t.rawOutput)}catch{return String(t.rawOutput)}return""};export function hermesToolFor(t={}){const s=String(t.kind||"").toLowerCase(),n=String(t.title||""),o=t.rawInput&&"object"==typeof t.rawInput?t.rawInput:{},i=String(t.server||o.server||""),r=String(t.tool||o.tool||o.name||"");if("project-context"===i&&"search_context_evidence"===r)return{name:"mcp__project-context__search_context_evidence",input:o};const a=(t.content||[]).find(t=>"terminal"===t?.type),d=(t.content||[]).map(e).find(t=>t.trim().startsWith("$ ")),l=t.locations?.[0]?.path;if("execute"===s||a||d)return{name:"Bash",input:{...o,command:o.command||d?.trim().slice(2)||n}};if("edit"===s){const e=(t=>{const e=(Array.isArray(t)?t:[]).filter(t=>"diff"===t?.type);return e.length?1===e.length?{file_path:e[0].path||"",path:e[0].path||"",old_string:e[0].oldText||"",new_string:e[0].newText||""}:{file_path:e[0].path||"",path:e[0].path||"",edits:e.map(t=>({file_path:t.path||"",old_string:t.oldText||"",new_string:t.newText||""}))}:null})(t.content);return{name:e?.edits?"MultiEdit":n.toLowerCase().includes("write")?"Write":"Edit",input:{...o,...e||{},file_path:e?.file_path||o.path||l||"",path:e?.path||o.path||l||""}}}return"read"===s?{name:"Read",input:{...o,file_path:o.path||l||"",path:o.path||l||""}}:"search"===s?{name:"Grep",input:o}:"fetch"===s?{name:"WebFetch",input:o}:{name:n||"HermesTool",input:o}}export class HermesEventMapper{constructor({onEvent:t,sessionId:e=null,model:s=null}={}){this.onEvent=t,this.sessionId=e,this.model=s,this.tools=new Map,this.text="",this.thought="",this.textCid=null}_emit(t){try{this.onEvent?.(t)}catch{}}startSession({sessionId:t,models:e,modes:s,commands:n=[]}={}){this.sessionId=t||this.sessionId,this.model=e?.currentModelId||this.model;const o=(e?.availableModels||[]).map(t=>({value:t.modelId,displayName:t.name||t.modelId,description:t.description||""}));this._emit({kind:"system",sessionId:this.sessionId,model:this.model,commands:n,models:o,modes:s}),o.length&&this._emit({kind:"models",models:o,model:this.model})}push(n={}){if(n.sessionId&&this.sessionId&&n.sessionId!==this.sessionId)return;const o=n.update||n;switch(o.sessionUpdate){case"agent_message_chunk":{const t=e(o.content);if(!t)return;return this.text+=t,this.textCid||=`hermes-stream:${this.sessionId||"session"}:${Date.now()}`,void this._emit({kind:"assistant_stream",cid:this.textCid,text:this.text,streaming:!0})}case"agent_thought_chunk":return void(this.thought+=e(o.content));case"tool_call":{const t=hermesToolFor(o);return this.tools.set(o.toolCallId,{...o,...t,startedAt:Date.now()}),void this._emit({kind:"assistant",blocks:[{type:"tool_use",id:o.toolCallId,name:t.name,input:t.input}],parentToolUseId:null})}case"tool_call_update":{const e=this.tools.get(o.toolCallId)||{},n={...e,...o};if(this.tools.set(o.toolCallId,n),!["completed","failed"].includes(o.status))return;const i=hermesToolFor(n),r=[{type:"text",text:s(n)}],a=t(i.name,r);return this._emit({kind:"tool_result",toolUseId:o.toolCallId,content:r,...a?{evidence:a}:{},toolInput:{...e.input||{},...i.input||{}},isError:"failed"===o.status,durationMs:e.startedAt?Date.now()-e.startedAt:void 0,parentToolUseId:null}),void this.tools.delete(o.toolCallId)}case"usage_update":{const t=Number(o.used)||0,e=Number(o.size)||0;return void this._emit({kind:"usage",ctx:{used:t,max:e,pct:e?Math.min(100,Math.round(t/e*100)):0,over:e>0&&t>e,model:this.model}})}case"available_commands_update":return void this._emit({kind:"system",sessionId:this.sessionId,model:this.model,commands:(o.availableCommands||[]).map(t=>{if("string"==typeof t)return t.startsWith("/")?t:`/${t}`;const e=String(t?.name||"").replace(/^\/+/,""),s=t?.input?.hint||t?.inputHint||t?.input_hint||"";return{name:`/${e}`,description:String(t?.description||""),...s?{inputHint:String(s)}:{}}}).filter(t=>"string"==typeof t?"/"!==t:"/"!==t.name)});case"current_mode_update":return void this._emit({kind:"mode",mode:o.currentModeId});case"plan":{const t=(o.entries||[]).map(t=>({content:t.content||"",activeForm:t.content||"",status:"in_progress"===t.status?"in_progress":"completed"===t.status?"completed":"pending"}));return void this._emit({kind:"assistant",blocks:[{type:"tool_use",id:`hermes-plan:${this.sessionId}`,name:"TodoWrite",input:{todos:t}}],parentToolUseId:null})}default:return}}finishTurn({stopReason:t="end_turn",usage:e=null}={}){for(const[e,s]of this.tools){const n="cancelled"===t;this._emit({kind:"tool_result",toolUseId:e,content:[{type:"text",text:n?"[cancelled]":"Hermes ended the turn without reporting this tool result."}],toolInput:s.input||void 0,isError:!n&&"end_turn"!==t,missing:!n,durationMs:s.startedAt?Date.now()-s.startedAt:void 0,parentToolUseId:null})}this.tools.clear();const s=[];this.thought&&s.push({type:"thinking",text:this.thought,reasoningKind:"thought"}),this.text&&s.push({type:"text",text:this.text}),s.length&&this._emit({kind:"assistant",blocks:s,parentToolUseId:null,...this.textCid?{replacesCid:this.textCid}:{}}),this._emit({kind:"result",subtype:"cancelled"===t?"aborted":"end_turn"===t?"success":"error_during_execution",sessionId:this.sessionId,model:this.model,costUsd:null,usage:e?{input_tokens:Number(e.inputTokens)||0,cache_read_input_tokens:Number(e.cachedReadTokens)||0,cache_creation_input_tokens:0,output_tokens:(Number(e.outputTokens)||0)+(Number(e.thoughtTokens)||0)}:null,numTurns:1,durationMs:void 0,denials:0,resultText:this.text||null}),this.text="",this.thought="",this.textCid=null}compactOutcome(){const t=String(this.text||"").match(/Context compressed:\s*(\d+)\s*->\s*(\d+)\s*messages[\s\S]*?~?([\d,]+)\s*->\s*~?([\d,]+)\s*tokens/i);return t?{preMessages:Number(t[1]),postMessages:Number(t[2]),preTokens:Number(t[3].replace(/,/g,"")),postTokens:Number(t[4].replace(/,/g,""))}:null}}
@@ -1,61 +1 @@
1
- import fs from 'node:fs'
2
- import os from 'node:os'
3
- import path from 'node:path'
4
- import { randomUUID } from 'node:crypto'
5
- import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
6
- import { startHermesSession } from './hermes-session.mjs'
7
- import { withMcpSessionFactory } from './codex-mcp-http.mjs'
8
-
9
- const waitFor = (predicate, timeoutMs = 25_000) => new Promise((resolve, reject) => {
10
- const started = Date.now()
11
- const timer = setInterval(() => {
12
- if (predicate()) { clearInterval(timer); resolve() }
13
- else if (Date.now() - started > timeoutMs) { clearInterval(timer); reject(new Error('Hermes MCP isolation probe timed out')) }
14
- }, 50)
15
- })
16
-
17
- export function assertHermesIsolation(outputs, markers) {
18
- if (!Array.isArray(outputs) || outputs.length !== 2 || !Array.isArray(markers) || markers.length !== 2) {
19
- throw new TypeError('Hermes MCP isolation requires exactly two session outputs and markers')
20
- }
21
- for (let i = 0; i < 2; i++) {
22
- if (!outputs[i].includes(`mcp__thinkpool__${markers[i]}`)) throw new Error(`Hermes lane ${i + 1} did not advertise its own MCP marker`)
23
- if (outputs[i].includes(`mcp__thinkpool__${markers[1 - i]}`)) throw new Error(`Hermes lane ${i + 1} advertised the other lane's MCP marker`)
24
- }
25
- return true
26
- }
27
-
28
- export async function verifyHermesMcpIsolation({ rounds = 2, sessionFactory = startHermesSession } = {}) {
29
- const evidence = []
30
- for (let round = 0; round < rounds; round++) {
31
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'tp-hermes-mcp-isolation-'))
32
- const suffix = randomUUID().replaceAll('-', '').slice(0, 10)
33
- const markers = [`lane_alpha_${suffix}`, `lane_beta_${suffix}`]
34
- const events = [[], []]
35
- const sessions = []
36
- try {
37
- for (let i = 0; i < 2; i++) {
38
- const cwd = path.join(root, i === 0 ? 'alpha' : 'beta')
39
- fs.mkdirSync(cwd)
40
- const createMarkerServer = () => createSdkMcpServer({
41
- name: 'thinkpool', version: '1.0.0',
42
- tools: [tool(markers[i], 'Synthetic ThinkPool lane-isolation marker.', {}, async () => ({ content: [{ type: 'text', text: markers[i] }] }))],
43
- })
44
- const sdkServer = withMcpSessionFactory(createMarkerServer)
45
- sessions.push(sessionFactory({ cwd, lazy: true, mcpServers: { thinkpool: sdkServer }, onEvent: (event) => events[i].push(event) }))
46
- }
47
- sessions.forEach((session) => session.sendTurn('/tools'))
48
- await waitFor(() => events.every((list) => list.some((event) => event.kind === 'result') || list.some((event) => event.kind === 'error')))
49
- const errors = events.flatMap((list) => list.filter((event) => event.kind === 'error'))
50
- if (errors.length) throw new Error(`Hermes MCP isolation probe failed: ${errors.map((event) => event.message).join('; ')}`)
51
- const outputs = events.map((list) => list.filter((event) => event.kind === 'assistant_stream').map((event) => event.text).join(''))
52
- assertHermesIsolation(outputs, markers)
53
- evidence.push({ round: round + 1, markers, sessionIds: sessions.map((session) => session.sessionId) })
54
- } finally {
55
- sessions.forEach((session) => session.end())
56
- await new Promise((resolve) => setTimeout(resolve, 250))
57
- fs.rmSync(root, { recursive: true, force: true })
58
- }
59
- }
60
- return evidence
61
- }
1
+ import e from"node:fs";import r from"node:os";import o from"node:path";import{randomUUID as t}from"node:crypto";import{createSdkMcpServer as n,tool as s}from"@anthropic-ai/claude-agent-sdk";import{startHermesSession as i}from"./hermes-session.mjs";import{withMcpSessionFactory as a}from"./codex-mcp-http.mjs";const m=(e,r=25e3)=>new Promise((o,t)=>{const n=Date.now(),s=setInterval(()=>{e()?(clearInterval(s),o()):Date.now()-n>r&&(clearInterval(s),t(new Error("Hermes MCP isolation probe timed out")))},50)});export function assertHermesIsolation(e,r){if(!Array.isArray(e)||2!==e.length||!Array.isArray(r)||2!==r.length)throw new TypeError("Hermes MCP isolation requires exactly two session outputs and markers");for(let o=0;o<2;o++){if(!e[o].includes(`mcp__thinkpool__${r[o]}`))throw new Error(`Hermes lane ${o+1} did not advertise its own MCP marker`);if(e[o].includes(`mcp__thinkpool__${r[1-o]}`))throw new Error(`Hermes lane ${o+1} advertised the other lane's MCP marker`)}return!0}export async function verifyHermesMcpIsolation({rounds:l=2,sessionFactory:p=i}={}){const c=[];for(let i=0;i<l;i++){const l=e.mkdtempSync(o.join(r.tmpdir(),"tp-hermes-mcp-isolation-")),d=t().replaceAll("-","").slice(0,10),h=[`lane_alpha_${d}`,`lane_beta_${d}`],f=[[],[]],u=[];try{for(let r=0;r<2;r++){const t=o.join(l,0===r?"alpha":"beta");e.mkdirSync(t);const i=a(()=>n({name:"thinkpool",version:"1.0.0",tools:[s(h[r],"Synthetic ThinkPool lane-isolation marker.",{},async()=>({content:[{type:"text",text:h[r]}]}))]}));u.push(p({cwd:t,lazy:!0,mcpServers:{thinkpool:i},onEvent:e=>f[r].push(e)}))}u.forEach(e=>e.sendTurn("/tools")),await m(()=>f.every(e=>e.some(e=>"result"===e.kind)||e.some(e=>"error"===e.kind)));const r=f.flatMap(e=>e.filter(e=>"error"===e.kind));if(r.length)throw new Error(`Hermes MCP isolation probe failed: ${r.map(e=>e.message).join("; ")}`);assertHermesIsolation(f.map(e=>e.filter(e=>"assistant_stream"===e.kind).map(e=>e.text).join("")),h),c.push({round:i+1,markers:h,sessionIds:u.map(e=>e.sessionId)})}finally{u.forEach(e=>e.end()),await new Promise(e=>setTimeout(e,250)),e.rmSync(l,{recursive:!0,force:!0})}}return c}
@@ -1,54 +1 @@
1
- // Hermes exposes its model catalog only after `session/new`, which may take up
2
- // to 30 seconds while the ACP process boots. The catalog is public metadata and
3
- // identical for every room served by this Hermes profile, so keep the last
4
- // validated copy at the host level. A live Hermes session always replaces it.
5
-
6
- import fs from 'node:fs'
7
- import os from 'node:os'
8
- import path from 'node:path'
9
-
10
- const MAX_MODELS = 1000
11
- const MAX_BYTES = 512 * 1024
12
- const MAX_FIELD = 512
13
-
14
- const cachePath = () => process.env.TP_HERMES_MODEL_CACHE
15
- || path.join(process.env.TP_PAIR_ROOT || path.join(os.homedir(), '.thinkpool-pair'), 'hermes-models.json')
16
-
17
- export function normalizeHermesModelCache(models) {
18
- if (!Array.isArray(models)) return []
19
- const seen = new Set()
20
- const normalized = []
21
- for (const raw of models.slice(0, MAX_MODELS)) {
22
- const value = String(raw?.value || '').trim().slice(0, MAX_FIELD)
23
- if (!value || seen.has(value)) continue
24
- seen.add(value)
25
- normalized.push({
26
- value,
27
- displayName: String(raw?.displayName || value).trim().slice(0, MAX_FIELD) || value,
28
- description: String(raw?.description || '').trim().slice(0, MAX_FIELD),
29
- })
30
- }
31
- return normalized
32
- }
33
-
34
- export function loadHermesModelCache() {
35
- try {
36
- const file = cachePath()
37
- if ((Number(fs.statSync(file).size) || 0) > MAX_BYTES) return []
38
- const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
39
- return normalizeHermesModelCache(parsed?.models)
40
- } catch { return [] }
41
- }
42
-
43
- export function saveHermesModelCache(models) {
44
- const normalized = normalizeHermesModelCache(models)
45
- if (!normalized.length) return []
46
- try {
47
- const file = cachePath()
48
- fs.mkdirSync(path.dirname(file), { recursive: true })
49
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`
50
- fs.writeFileSync(temp, JSON.stringify({ savedAt: Date.now(), models: normalized }), { mode: 0o600 })
51
- fs.renameSync(temp, file)
52
- } catch { /* cache is opportunistic; live discovery remains the fallback */ }
53
- return normalized
54
- }
1
+ import e from"node:fs";import r from"node:os";import o from"node:path";const n=()=>process.env.TP_HERMES_MODEL_CACHE||o.join(process.env.TP_PAIR_ROOT||o.join(r.homedir(),".thinkpool-pair"),"hermes-models.json");export function normalizeHermesModelCache(e){if(!Array.isArray(e))return[];const r=new Set,o=[];for(const n of e.slice(0,1e3)){const e=String(n?.value||"").trim().slice(0,512);e&&!r.has(e)&&(r.add(e),o.push({value:e,displayName:String(n?.displayName||e).trim().slice(0,512)||e,description:String(n?.description||"").trim().slice(0,512)}))}return o}export function loadHermesModelCache(){try{const r=n();if((Number(e.statSync(r).size)||0)>524288)return[];const o=JSON.parse(e.readFileSync(r,"utf8"));return normalizeHermesModelCache(o?.models)}catch{return[]}}export function saveHermesModelCache(r){const t=normalizeHermesModelCache(r);if(!t.length)return[];try{const r=n();e.mkdirSync(o.dirname(r),{recursive:!0});const s=`${r}.${process.pid}.${Date.now()}.tmp`;e.writeFileSync(s,JSON.stringify({savedAt:Date.now(),models:t}),{mode:384}),e.renameSync(s,r)}catch{}return t}