pantheon-opencode 1.4.2 → 1.5.0-beta.1

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 (98) hide show
  1. package/.pantheon/code-mode/.gitkeep +0 -0
  2. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-1.json +8 -0
  3. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-10.json +8 -0
  4. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-11.json +8 -0
  5. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-2.json +8 -0
  6. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-3.json +8 -0
  7. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-4.json +8 -0
  8. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-5.json +8 -0
  9. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-6.json +8 -0
  10. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-7.json +8 -0
  11. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-8.json +8 -0
  12. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/checkpoint-9.json +8 -0
  13. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/heartbeat.json +8 -0
  14. package/.pantheon/code-mode/.pantheon/deepwork/initial-token-40k/session.json +11 -0
  15. package/.pantheon/code-mode/README.md +93 -0
  16. package/.pantheon/code-mode/checkpoint-session.py +7 -0
  17. package/.pantheon/code-mode/checkpoint-session.sh +11 -0
  18. package/.pantheon/code-mode/checkpoint_session.py +481 -0
  19. package/.pantheon/code-mode/compress-inline.py +450 -0
  20. package/.pantheon/code-mode/eval-llm-judge.py +255 -0
  21. package/.pantheon/code-mode/eval-monte-carlo.py +368 -0
  22. package/.pantheon/code-mode/eval-run.py +197 -0
  23. package/.pantheon/code-mode/eval-static.py +321 -0
  24. package/.pantheon/code-mode/example-sync.sh +5 -0
  25. package/.pantheon/code-mode/session-end-save.py +186 -0
  26. package/.pantheon/code-mode/session-end-save.sh +18 -0
  27. package/.pantheon/code-mode/test-allowed.sh +5 -0
  28. package/.pantheon/code-mode/test-auto-continue-checkpoint.sh +179 -0
  29. package/.pantheon/code-mode/test-env.sh +7 -0
  30. package/.pantheon/code-mode/test-prlimit.sh +9 -0
  31. package/.pantheon/code-mode/test-timeout.sh +6 -0
  32. package/README.md +158 -1189
  33. package/README.pt-BR.md +222 -0
  34. package/bin/pantheon-init.mjs +30 -11
  35. package/opencode.json +1 -0
  36. package/package.json +14 -3
  37. package/plugin.json +15 -0
  38. package/scripts/_pantheon_paths.py +48 -7
  39. package/scripts/ci-validate-yaml.py +7 -2
  40. package/scripts/code_mode_server.py +33 -2
  41. package/scripts/doctor.mjs +135 -127
  42. package/scripts/generate-preset-docs.mjs +127 -66
  43. package/scripts/generate-prompts.sh +2 -2
  44. package/scripts/install/cli-ui.mjs +8 -1
  45. package/scripts/install/config-migration.mjs +401 -0
  46. package/scripts/install/health-check.mjs +3 -3
  47. package/scripts/install/model-picker.mjs +72 -23
  48. package/scripts/install/opencode-version.mjs +47 -0
  49. package/scripts/install/opencode.mjs +261 -88
  50. package/scripts/install/plugin.mjs +112 -42
  51. package/scripts/install/shared.mjs +13 -158
  52. package/scripts/install-mcp.mjs +44 -232
  53. package/scripts/mcp_persistence_server.py +344 -15
  54. package/scripts/mcp_resources_server.py +144 -40
  55. package/scripts/memory_mcp_server.py +2 -2
  56. package/scripts/recover-zenodo-deposition.mjs +146 -0
  57. package/scripts/sync-tui.mjs +32 -7
  58. package/scripts/test-opencode-v1-v2-sandbox.sh +591 -0
  59. package/scripts/uninstall.mjs +298 -954
  60. package/scripts/validate-zenodo-release.mjs +154 -0
  61. package/scripts/zenodo-artifact.mjs +21 -0
  62. package/scripts/zenodo-release-state.mjs +53 -0
  63. package/src/mcp/_pantheon_paths.py +48 -7
  64. package/src/mcp/code_mode_server.py +33 -2
  65. package/src/mcp/eval_store.py +1 -1
  66. package/src/mcp/mcp_persistence_server.py +344 -15
  67. package/src/mcp/mcp_resources_server.py +144 -40
  68. package/src/mcp/pantheon_vision_server.py +1 -1
  69. package/src/mcp/toon_codec.py +491 -0
  70. package/src/pantheon/background-job-board.ts +67 -1
  71. package/src/pantheon/cost-command.ts +6 -2
  72. package/src/pantheon/delegate-manager.ts +384 -0
  73. package/src/pantheon/delegate-task-adapter.ts +444 -0
  74. package/src/pantheon/delegation-notify.ts +35 -20
  75. package/src/pantheon/model-command.ts +86 -32
  76. package/src/pantheon/token-opt.ts +555 -0
  77. package/src/pantheon/toon-codec.ts +481 -0
  78. package/src/pantheon/tui-version-check.ts +1 -3
  79. package/src/pantheon/v2-bridge.ts +129 -0
  80. package/src/pantheon/v2-events.ts +131 -0
  81. package/src/pantheon/v2-hooks.ts +287 -0
  82. package/src/pantheon/v2-tool-definitions.ts +331 -0
  83. package/src/plugin-v2/index.ts +21 -0
  84. package/src/plugin-v2.ts +836 -0
  85. package/src/plugin.ts +57 -10
  86. package/src/plugins/tui/dist/server.d.ts +1 -1
  87. package/src/plugins/tui/dist/server.d.ts.map +1 -1
  88. package/src/plugins/tui/dist/server.js +1 -1
  89. package/src/plugins/tui/dist/server.js.map +1 -1
  90. package/src/plugins/tui/dist/tui.d.ts +10 -7
  91. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  92. package/src/plugins/tui/dist/tui.js +11 -8
  93. package/src/plugins/tui/dist/tui.js.map +1 -1
  94. package/src/plugins/tui/dist/tui.tsx +22 -16
  95. package/src/plugins/tui/package-lock.json +51 -51
  96. package/src/plugins/tui/package.json +7 -7
  97. package/src/plugins/tui/src/index.tsx +22 -16
  98. package/src/skills/README.md +0 -2
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+ /** Validate the immutable inputs used by the GitHub Release → Zenodo workflow. */
3
+
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import { compareVersions } from './version-check.mjs'
8
+
9
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
10
+
11
+ function validateHttpsUrl(value, name) {
12
+ let url
13
+ try {
14
+ url = new URL(value)
15
+ } catch {
16
+ throw new Error(`${name} must be an absolute HTTPS URL.`)
17
+ }
18
+ if (
19
+ url.protocol !== 'https:' ||
20
+ !url.hostname ||
21
+ url.username ||
22
+ url.password ||
23
+ /%(?![0-9A-Fa-f]{2})/.test(value) ||
24
+ /[\s{}\\|^`]/.test(value)
25
+ ) {
26
+ throw new Error(`${name} must be an absolute HTTPS URL without credentials.`)
27
+ }
28
+ return url
29
+ }
30
+
31
+ function maskHost(hostname) {
32
+ if (hostname.length <= 2) return '***'
33
+ return `${hostname[0]}***${hostname.at(-1)}`
34
+ }
35
+
36
+ export function summarizeZenodoConfiguration({
37
+ depositionsUrl,
38
+ filesUrlTemplate,
39
+ publishUrlTemplate,
40
+ creatorName,
41
+ }) {
42
+ const values = [
43
+ ['ZENODO_DEPOSITIONS_URL', depositionsUrl, true],
44
+ ['ZENODO_FILES_URL_TEMPLATE', filesUrlTemplate, true],
45
+ ['ZENODO_PUBLISH_URL_TEMPLATE', publishUrlTemplate, true],
46
+ ['ZENODO_CREATOR_NAME', creatorName, false],
47
+ ]
48
+ return values.map(([name, value, isUrl]) => {
49
+ if (typeof value !== 'string' || !value) return { name, present: false }
50
+ if (!isUrl) return { name, present: true }
51
+ try {
52
+ const url = new URL(value.replaceAll('{id}', '1'))
53
+ return {
54
+ name,
55
+ present: true,
56
+ protocol: url.protocol,
57
+ host: maskHost(url.hostname),
58
+ hasWhitespace: /\s/.test(value),
59
+ hasCredentials: Boolean(url.username || url.password),
60
+ }
61
+ } catch {
62
+ return {
63
+ name,
64
+ present: true,
65
+ protocol: 'invalid',
66
+ host: '***',
67
+ hasWhitespace: /\s/.test(value),
68
+ }
69
+ }
70
+ })
71
+ }
72
+
73
+ export function zenodoTemplateUrl(template, id, name = 'Zenodo URL template') {
74
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Zenodo deposition id is invalid.')
75
+ if (typeof template !== 'string' || !template.includes('{id}'))
76
+ throw new Error(`${name} must contain the {id} placeholder.`)
77
+ return validateHttpsUrl(template.replaceAll('{id}', String(id)), name).href
78
+ }
79
+
80
+ export function zenodoDepositionUrl(depositionsUrl, id) {
81
+ if (!Number.isSafeInteger(id) || id <= 0) throw new Error('Zenodo deposition id is invalid.')
82
+ if (typeof depositionsUrl !== 'string')
83
+ throw new Error('ZENODO_DEPOSITIONS_URL must be an absolute HTTPS URL.')
84
+ const base = validateHttpsUrl(depositionsUrl, 'ZENODO_DEPOSITIONS_URL')
85
+ if (base.search || base.hash)
86
+ throw new Error('ZENODO_DEPOSITIONS_URL must not contain a query or fragment.')
87
+ return new URL(`${base.pathname.replace(/\/$/, '')}/${id}`, base.origin).href
88
+ }
89
+
90
+ export function validateZenodoConfiguration({
91
+ depositionsUrl,
92
+ filesUrlTemplate,
93
+ publishUrlTemplate,
94
+ creatorName,
95
+ }) {
96
+ const base = validateHttpsUrl(depositionsUrl, 'ZENODO_DEPOSITIONS_URL')
97
+ for (const [name, template] of [
98
+ ['ZENODO_FILES_URL_TEMPLATE', filesUrlTemplate],
99
+ ['ZENODO_PUBLISH_URL_TEMPLATE', publishUrlTemplate],
100
+ ]) {
101
+ if (
102
+ typeof template !== 'string' ||
103
+ !template.includes('{id}') ||
104
+ template.replaceAll('{id}', '').match(/[{}]/) ||
105
+ template.includes('{{') ||
106
+ template.includes('}}')
107
+ ) {
108
+ throw new Error(`${name} must be an HTTPS URL template containing the {id} placeholder.`)
109
+ }
110
+ const url = new URL(zenodoTemplateUrl(template, 1, name))
111
+ if (url.origin !== base.origin) throw new Error(`${name} must use the Zenodo depositions host.`)
112
+ }
113
+ if (typeof creatorName !== 'string' || !creatorName.trim())
114
+ throw new Error('ZENODO_CREATOR_NAME must not be empty.')
115
+ return true
116
+ }
117
+
118
+ export function validateZenodoRelease(root, tag) {
119
+ if (!/^v/.test(tag)) throw new Error('Release tag must start with v.')
120
+ const version = tag.slice(1)
121
+ if (!SEMVER.test(version)) throw new Error('Release tag is not valid semver.')
122
+ const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
123
+ if (packageJson.name !== 'pantheon-opencode' || packageJson.version !== version) {
124
+ throw new Error('Release tag does not match package.json.')
125
+ }
126
+ const manifests = compareVersions(root)
127
+ if (!manifests.ok) throw new Error('Release manifests are out of sync.')
128
+ const citationPath = join(root, 'CITATION.cff')
129
+ const citation = { present: existsSync(citationPath), version: null }
130
+ if (citation.present) {
131
+ const match = readFileSync(citationPath, 'utf8').match(/^version:\s*["']?([^"'\s]+)["']?\s*$/m)
132
+ if (!match) throw new Error('CITATION.cff is missing its version field.')
133
+ citation.version = match[1]
134
+ if (citation.version !== version)
135
+ throw new Error('CITATION.cff version differs from the release tag.')
136
+ }
137
+ return { tag, version, citation }
138
+ }
139
+
140
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
141
+ if (isDirectRun) {
142
+ try {
143
+ validateZenodoConfiguration({
144
+ depositionsUrl: process.env.ZENODO_DEPOSITIONS_URL,
145
+ filesUrlTemplate: process.env.ZENODO_FILES_URL_TEMPLATE,
146
+ publishUrlTemplate: process.env.ZENODO_PUBLISH_URL_TEMPLATE,
147
+ creatorName: process.env.ZENODO_CREATOR_NAME,
148
+ })
149
+ console.log(JSON.stringify(validateZenodoRelease(process.cwd(), process.env.RELEASE_TAG ?? '')))
150
+ } catch (error) {
151
+ console.error(`zenodo validation: ${error.message}`)
152
+ process.exit(1)
153
+ }
154
+ }
@@ -0,0 +1,21 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync } from 'node:fs'
3
+
4
+ export function sha256File(path) {
5
+ return createHash('sha256').update(readFileSync(path)).digest('hex')
6
+ }
7
+
8
+ export function findZenodoFile(record, filename, checksum) {
9
+ if (!record || typeof record !== 'object' || !Array.isArray(record.files)) {
10
+ throw new Error('Zenodo record did not contain a files array.')
11
+ }
12
+ const matches = record.files.filter((file) => file?.filename === filename)
13
+ if (matches.length > 1) throw new Error(`Zenodo record contains duplicate file: ${filename}.`)
14
+ if (matches.length === 0) return null
15
+ const file = matches[0]
16
+ const expected = `sha256:${checksum}`
17
+ if (file.checksum !== expected) {
18
+ throw new Error(`Zenodo checksum mismatch for ${filename}.`)
19
+ }
20
+ return file
21
+ }
@@ -0,0 +1,53 @@
1
+ const DOI_PATTERN = /^10\.\d{4,9}\/\S+$/
2
+ const MARKER_PATTERN =
3
+ /<!-- zenodo-deposition:(?:id=(\d+);)?(?:doi=([^;\s]+);)?state=(pending|created|published)(?:;claim=([^\s]+))? -->/
4
+
5
+ export function extractZenodoDoi(response) {
6
+ if (!response || typeof response !== 'object') return null
7
+ const candidates = [response.doi, response.metadata?.doi, response.links?.doi]
8
+ for (const candidate of candidates) {
9
+ if (typeof candidate === 'string' && DOI_PATTERN.test(candidate.trim())) return candidate.trim()
10
+ }
11
+ return null
12
+ }
13
+
14
+ export function validateZenodoResponse(response, { requireDoi = false } = {}) {
15
+ if (!response || typeof response !== 'object' || Array.isArray(response)) {
16
+ throw new Error('Zenodo response must be a JSON object.')
17
+ }
18
+ if (!Number.isSafeInteger(response.id) || response.id <= 0) {
19
+ throw new Error('Zenodo response did not contain a valid deposition id.')
20
+ }
21
+ const doi = extractZenodoDoi(response)
22
+ if (requireDoi && !doi) throw new Error('Zenodo response did not contain a valid DOI.')
23
+ return { id: response.id, doi }
24
+ }
25
+
26
+ export function parseZenodoMarker(body) {
27
+ const text = String(body ?? '')
28
+ const match = text.match(MARKER_PATTERN)
29
+ if (!match) {
30
+ const legacy = text.match(/<!-- zenodo-deposition-id:(\d+) -->/)
31
+ if (!legacy) return null
32
+ const id = Number(legacy[1])
33
+ if (!Number.isSafeInteger(id) || id <= 0)
34
+ throw new Error('Zenodo marker has an invalid deposition id.')
35
+ return { id, doi: null, state: 'created', claim: null }
36
+ }
37
+ const id = match[1] ? Number(match[1]) : null
38
+ if (match[1] && (!Number.isSafeInteger(id) || id <= 0))
39
+ throw new Error('Zenodo marker has an invalid deposition id.')
40
+ if (match[2] && !DOI_PATTERN.test(match[2])) throw new Error('Zenodo marker has an invalid DOI.')
41
+ return { id, doi: match[2] ?? null, state: match[3], claim: match[4] ?? null }
42
+ }
43
+
44
+ export function zenodoMarker({ id = null, doi = null, state, claim = null }) {
45
+ if (!['pending', 'created', 'published'].includes(state))
46
+ throw new Error('Zenodo marker state is invalid.')
47
+ if (id !== null && (!Number.isSafeInteger(id) || id <= 0))
48
+ throw new Error('Zenodo marker id is invalid.')
49
+ if (doi !== null && !DOI_PATTERN.test(doi)) throw new Error('Zenodo marker DOI is invalid.')
50
+ if (state !== 'pending' && id === null)
51
+ throw new Error('Created or published Zenodo markers require an id.')
52
+ return `<!-- zenodo-deposition:${id === null ? '' : `id=${id};`}${doi === null ? '' : `doi=${doi};`}state=${state}${claim === null ? '' : `;claim=${claim}`} -->`
53
+ }
@@ -34,11 +34,11 @@ def pantheon_home() -> Path:
34
34
  """
35
35
  env = os.environ.get("PANTHEON_HOME")
36
36
  if env:
37
- return Path(env).expanduser().resolve()
37
+ return Path(env).expanduser().absolute()
38
38
 
39
39
  xdg = os.environ.get("XDG_CONFIG_HOME")
40
40
  if xdg:
41
- return Path(xdg).expanduser().resolve() / "opencode"
41
+ return Path(xdg).expanduser().absolute() / "opencode"
42
42
 
43
43
  return Path.home() / ".config" / "opencode"
44
44
 
@@ -48,7 +48,8 @@ def pantheon_project() -> Path | None:
48
48
 
49
49
  Resolution priority:
50
50
  1. $PANTHEON_PROJECT env var (explicit override)
51
- 2. Current working directory (set by MCP client's cwd in opencode.json)
51
+ 2. The validated logical ``$PWD`` supplied by the launcher
52
+ 3. Current working directory (set by MCP client's cwd in opencode.json)
52
53
 
53
54
  Returns:
54
55
  Absolute Path to the project root, or None if neither is available
@@ -56,10 +57,50 @@ def pantheon_project() -> Path | None:
56
57
  """
57
58
  env = os.environ.get("PANTHEON_PROJECT")
58
59
  if env:
59
- return Path(env).expanduser().resolve()
60
-
61
- cwd = os.getcwd()
60
+ return Path(env).expanduser().absolute()
61
+
62
+ logical_cwd = os.environ.get("PWD")
63
+ try:
64
+ cwd = os.getcwd()
65
+ except OSError:
66
+ cwd = ""
67
+ if logical_cwd and _is_valid_logical_cwd(logical_cwd, cwd):
68
+ return Path(logical_cwd).expanduser().absolute()
62
69
  if cwd:
63
- return Path(cwd).resolve()
70
+ return Path(cwd).absolute()
64
71
 
65
72
  return None
73
+
74
+
75
+ def _is_valid_logical_cwd(logical_cwd: str, physical_cwd: str) -> bool:
76
+ """Validate a launcher-provided logical cwd without resolving its result.
77
+
78
+ ``PWD`` is trusted only when it is an existing directory naming the same
79
+ inode as the process cwd. This preserves symlink spelling while rejecting
80
+ forged or stale environment values.
81
+ """
82
+ logical = Path(logical_cwd).expanduser()
83
+ if not logical.is_absolute() or not logical.is_dir():
84
+ return False
85
+ try:
86
+ return os.path.samefile(logical, physical_cwd)
87
+ except (OSError, ValueError):
88
+ return False
89
+
90
+
91
+ def has_symlink_component(path: Path) -> bool:
92
+ """Return whether any existing lexical component of ``path`` is a symlink.
93
+
94
+ The lexical path is intentionally inspected before ``resolve()`` so a
95
+ symlink cannot be hidden by resolving to an otherwise trusted directory.
96
+ Missing trailing components are allowed; this keeps normal project paths
97
+ usable while still checking every existing ancestor.
98
+ """
99
+ current = Path(path.anchor) if path.is_absolute() else Path.cwd()
100
+ for component in path.parts[1:] if path.is_absolute() else path.parts:
101
+ if component in {"", "."}:
102
+ continue
103
+ current /= component
104
+ if current.is_symlink():
105
+ return True
106
+ return False
@@ -18,8 +18,8 @@ from __future__ import annotations
18
18
  import asyncio
19
19
  import logging
20
20
  import os
21
- import signal
22
21
  import shutil
22
+ import signal
23
23
  import stat
24
24
  import sys
25
25
  import time
@@ -28,7 +28,6 @@ from pathlib import Path
28
28
  from typing import Any
29
29
 
30
30
  import yaml
31
-
32
31
  from _pantheon_paths import pantheon_home, pantheon_project
33
32
  from mcp.server.fastmcp import FastMCP
34
33
 
@@ -133,6 +132,34 @@ def _detect_prlimit() -> str | None:
133
132
  # 1. /.opencode/.pantheon/code-mode/ (project install)
134
133
  # 2. /.pantheon/code-mode/ (legacy fallback)
135
134
  # 3. /.pantheon/code-mode/ (global fallback)
135
+ # 4. .pantheon/code-mode/ shipped inside the installed package (tarball
136
+ # fallback — package.json `files` includes .pantheon/code-mode/**)
137
+
138
+
139
+ def _packaged_scripts_dir() -> Path | None:
140
+ """Return the code-mode dir shipped inside the installed package, if any.
141
+
142
+ Walks up from this file looking for a `.pantheon/code-mode/` directory
143
+ (e.g. `<pkg>/.pantheon/code-mode` when installed from the tarball).
144
+ Returns None when no packaged copy exists.
145
+ """
146
+ try:
147
+ here = Path(__file__).resolve()
148
+ except Exception:
149
+ return None
150
+ for parent in [here.parent, *here.parents]:
151
+ try:
152
+ candidate = parent / ".pantheon" / "code-mode"
153
+ except Exception:
154
+ continue
155
+ try:
156
+ if candidate.is_dir():
157
+ return candidate
158
+ except OSError:
159
+ continue
160
+ return None
161
+
162
+
136
163
  _PANTHEON_HOME: Path = pantheon_home()
137
164
  _SCRIPTS_DIR_CANDIDATES: list[Path] = []
138
165
  _proj = pantheon_project()
@@ -146,6 +173,10 @@ for _candidate in _SCRIPTS_DIR_CANDIDATES:
146
173
  if _candidate.is_dir():
147
174
  SCRIPTS_DIR = _candidate
148
175
  break
176
+ else:
177
+ _packaged = _packaged_scripts_dir()
178
+ if _packaged is not None:
179
+ SCRIPTS_DIR = _packaged
149
180
 
150
181
  # ── FastMCP App ───────────────────────────────────────────────────────────────
151
182
  mcp = FastMCP(
@@ -138,7 +138,7 @@ def store_eval(
138
138
  except sqlite3.IntegrityError:
139
139
  conn.rollback()
140
140
  return {"error": f"Duplicate key or constraint violation: {key}"}
141
- except Exception as e: # noqa: BLE001
141
+ except Exception as e:
142
142
  conn.rollback()
143
143
  return {"error": f"Failed to store eval: {e}"}
144
144
  finally: