pantheon-opencode 1.5.0 → 1.5.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.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
18
18
 
19
19
  ## ✅ Closed Issues
20
20
 
21
+ ## [v1.5.1] - 2026-09-22
22
+
23
+ ## 🐞 Fixed
24
+ - **agents** — declare explicit read: allow for zeus
25
+ - **ci** — drop fastembed from CI, keep pytest collection green (#163)
26
+ - **plugin** — detect registered-plugin version drift after pantheon_delegate removal (#169)
27
+ - **config** — correct node floor and warn on unsupported runtime (#168)
28
+ - **mcp** — degrade gracefully when fastembed import fails (#165)
29
+ - **mcp** — drop dead toon_codec module from published package (#167)
30
+ - **tui** — remove dead pantheon_delegate refs after v1.5.0 V1 removal (#166)
31
+ - **mnemosyne** — grant scoped write to memory-bank and deepwork paths (#164)
32
+
33
+ ## ✅ Closed Issues
34
+ - #94 - drop fastembed from CI, keep pytest collection green (#163)
35
+ - #158 - detect registered-plugin version drift after pantheon_delegate removal (#169)
36
+ - #114 - correct node floor and warn on unsupported runtime (#168)
37
+ - #160 - correct node floor and warn on unsupported runtime (#168)
38
+ - #159 - degrade gracefully when fastembed import fails (#165)
39
+ - #162 - drop dead toon_codec module from published package (#167)
40
+ - #161 - remove dead pantheon_delegate refs after v1.5.0 V1 removal (#166)
41
+ - #111 - grant scoped write to memory-bank and deepwork paths (#164)
42
+
21
43
  ## [v1.5.0] - 2026-09-16
22
44
 
23
45
  ### 🆕 What's New
package/README.md CHANGED
@@ -30,7 +30,15 @@ way to plan work, make progress, check results, and keep useful project context.
30
30
 
31
31
  ## Start in 2 minutes
32
32
 
33
- Requirements: [OpenCode 1.18.4+](https://opencode.ai/docs/) and Node.js 22+.
33
+ Requirements: [OpenCode 1.18.4+](https://opencode.ai/docs/) and Node.js
34
+ 22.22.2+ (or 24.15.0+ / 26+).
35
+
36
+ Pantheon declares `engines.node` as `^22.22.2 || ^24.15.0 || >=26.0.0`. The
37
+ floor reflects what the dependency tree actually needs — the transitive
38
+ `ini@7` rejects earlier 22.x/24.x builds with `EBADENGINE` — and odd-numbered
39
+ Node releases (23, 25) are out of range. The `pantheon_cost` tool also needs
40
+ `node:sqlite`, which requires Node >= 22.5; `doctor` warns when the running
41
+ runtime cannot load it.
34
42
 
35
43
  From the project where you want to use Pantheon:
36
44
 
@@ -246,7 +254,12 @@ Two freshness guarantees back it up:
246
254
  re-run `init` just for file copies.
247
255
  - **Drift detection** — the installer stamps the installed version in
248
256
  `.pantheon/install-state.json` and `doctor` warns when the package is newer
249
- than the last sync, pointing at `update`.
257
+ than the last sync, pointing at `update`. `doctor` also detects **plugin
258
+ version drift** (issue #158): when `opencode.json` registers a plugin path
259
+ inside a `node_modules/pantheon-opencode` copy whose `package.json` version
260
+ differs from the running package, it warns that the registered tool surface
261
+ is stale. Re-running `init`/`update` realigns the registration onto the
262
+ current package.
250
263
 
251
264
  `init` also gained `--components agents,skills,...` (narrow install),
252
265
  `--clean` (alias of `--force`), `--opencode-version auto`, atomic config
@@ -256,6 +269,42 @@ the install completes but MCP entries are omitted (with a warning) instead of
256
269
  pointing at a broken interpreter. Installer messages auto-detect pt-BR via
257
270
  `LANG`/`LC_ALL`.
258
271
 
272
+ ## Migrating to 1.5.x (from 1.4.x)
273
+
274
+ 1.5.0 removed the custom `pantheon_delegate` tool (and the V1 delegation
275
+ engine) in favor of OpenCode's native `task()`; see
276
+ [Delegation (native `task()`)](#delegation-native-task). Two things change on
277
+ an existing install:
278
+
279
+ 1. **The tool disappears from the plugin surface.** `pantheon_delegate` is no
280
+ longer registered by `src/plugin.ts` or `src/plugin-v2.ts`. Agents now
281
+ delegate through `task()` only — no configuration is needed.
282
+ 2. **A lockfile-pinned copy can keep the old tool alive.** `npm install` is
283
+ lockfile-authoritative: a `package-lock.json` pinned to `1.4.1` (which
284
+ satisfies `^1.4.1`) is never re-resolved, so a project's
285
+ `node_modules/pantheon-opencode` can stay on 1.4.x while the published
286
+ package moved on. The plugin path your `opencode.json` registers keeps
287
+ pointing at that stale copy, and you keep running the obsolete tool surface
288
+ — including `pantheon_delegate` — with no warning.
289
+
290
+ The fix is a realignment + a detector:
291
+
292
+ ```bash
293
+ # Realign the registered plugin path onto the current package (rewrites any
294
+ # node_modules/pantheon-opencode reference in opencode.json):
295
+ npx pantheon-opencode@latest init --yes --headless
296
+ # or, with a global install:
297
+ pantheon-opencode update
298
+
299
+ # Then verify no drift remains:
300
+ npx pantheon-opencode@latest doctor
301
+ ```
302
+
303
+ `doctor` now reports a **Plugin Version Drift** warning (section H3) when the
304
+ registered plugin points into an installed copy whose version differs from the
305
+ running package, naming both versions and the removal (`pantheon_delegate` in
306
+ 1.5.0). A healthy install reports no warning.
307
+
259
308
  ## Releases
260
309
 
261
310
  Publication is authorized **only** by an explicit `workflow_dispatch` of the
@@ -331,6 +380,18 @@ and returns nothing. Prefer `background=true` dispatches with an explicit
331
380
  `task_status(wait=true)` fan-in so large payloads are collected deterministically.
332
381
 
333
382
 
383
+ ## Configuration (environment variables)
384
+
385
+ | Variable | Default | Description |
386
+ |---|---|---|
387
+ | `PANTHEON_MEMORY_EMBED` | `on` | `off` (also `0`, `false`, `no`) disables the `pantheon-memory` embedding/vector pipeline — search runs in FTS5-only mode with no model download and no `sqlite-vec` writes. Also forced `off` automatically when `fastembed` fails to import, so a broken embedding install never takes the server down (issue #159). |
388
+
389
+ The memory MCP degrades gracefully: when `fastembed` or `sqlite-vec` are
390
+ unavailable (network failure, incompatible wheel), the server still starts,
391
+ answers the MCP `initialize` handshake, and serves keyword search — only the
392
+ semantic vector ranking is unavailable.
393
+
394
+
334
395
  ## Documentation
335
396
 
336
397
  - [Installation](docs/INSTALLATION.md) · [Quick start](docs/QUICKSTART.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -119,7 +119,7 @@
119
119
  "author": "ils15",
120
120
  "license": "MIT",
121
121
  "engines": {
122
- "node": ">=22"
122
+ "node": "^22.22.2 || ^24.15.0 || >=26.0.0"
123
123
  },
124
124
  "preferGlobal": true
125
125
  }
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pantheon",
3
3
  "publisher": "ils15",
4
- "version": "1.5.0",
4
+ "version": "1.5.1",
5
5
  "displayName": "Pantheon",
6
6
  "description": "Conductor-Delegate orchestration platform with 14 specialized agents, 21 skills, and OpenCode support.",
7
7
  "agents": "./src/agents",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.backends._legacy:_Backend"
4
4
 
5
5
  [project]
6
6
  name = "pantheon"
7
- version = "1.5.0"
7
+ version = "1.5.1"
8
8
  description = "Multi-agent orchestration framework"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = []
@@ -39,6 +39,7 @@ ignore = ["E501"]
39
39
  "scripts/vector_memory/*.py" = ["PLC0415"]
40
40
  "tests/test_code_mode_sandbox.py" = ["PLC0415", "N806"]
41
41
  "tests/test_code_mode_server.py" = ["PLC0415"]
42
+ "tests/test_codemap_mcp.py" = ["PLC0415"]
42
43
  "tests/test_memory_mcp_server.py" = ["PLC0415", "PLR2004"]
43
44
  # Existing public APIs and test fixtures are intentionally broad/simple; refactoring
44
45
  # them would be behavior-affecting rather than a mechanical lint correction.
@@ -9,6 +9,7 @@
9
9
  * - Permission/frontmatter mismatches
10
10
  * - OpenCode artifact freshness
11
11
  * - Git status
12
+ * - Node runtime (node:sqlite availability for pantheon_cost)
12
13
  *
13
14
  * Usage:
14
15
  * node scripts/doctor.mjs # auto-detect, cwd
@@ -25,7 +26,7 @@ import { spawn as spawnAsync, spawnSync } from 'node:child_process'
25
26
  import { createHash } from 'node:crypto'
26
27
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, statSync } from 'node:fs'
27
28
  import { homedir } from 'node:os'
28
- import { basename, dirname, join, resolve } from 'node:path'
29
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
29
30
  import { fileURLToPath } from 'node:url'
30
31
 
31
32
  // ---------------------------------------------------------------------------
@@ -159,6 +160,8 @@ function showHelp() {
159
160
  D. OpenCode Status — OpenCode artifact layout
160
161
  E. Git Status — uncommitted changes
161
162
  F. Runtime Layer — venv python + pinned pip dependencies
163
+ H3. Plugin Drift — registered plugin version vs this package
164
+ K. Node Runtime — node:sqlite availability for pantheon_cost
162
165
 
163
166
  Exit codes:
164
167
  0 — no blocking errors (warnings are advisory)
@@ -1161,7 +1164,10 @@ function comparePantheonVersions(a, b) {
1161
1164
  for (let i = 0; i < 3; i++) {
1162
1165
  if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]
1163
1166
  }
1164
- return pa.beta - pb.beta
1167
+ // Infinity - Infinity is NaN, which would make equal stable versions compare
1168
+ // as drift for any caller using a strict === 0 check.
1169
+ if (pa.beta === pb.beta) return 0
1170
+ return pa.beta < pb.beta ? -1 : 1
1165
1171
  }
1166
1172
 
1167
1173
  /**
@@ -1214,6 +1220,159 @@ export function checkInstallVersionDrift(args) {
1214
1220
  return 'sync'
1215
1221
  }
1216
1222
 
1223
+ // ---------------------------------------------------------------------------
1224
+ // Check H3: Plugin version drift (issue #158)
1225
+ // ---------------------------------------------------------------------------
1226
+ // npm installs are lockfile-authoritative: a package-lock.json pinned to an
1227
+ // older pantheon-opencode (e.g. 1.4.1 satisfying ^1.4.1) is never re-resolved,
1228
+ // so an installed copy under node_modules/pantheon-opencode can lag several
1229
+ // releases behind the running package. The opencode.json keeps registering
1230
+ // that stale copy's plugin path and the user silently runs an obsolete tool
1231
+ // surface — 1.5.0 removed the pantheon_delegate tool, so a drifted install
1232
+ // still exposes it. This layer compares the version of the package a
1233
+ // registered plugin points into against this package's own version. Without
1234
+ // network access the "latest" version IS the running package's version.
1235
+
1236
+ /** Locates an installed copy of this package inside a dependency tree. */
1237
+ const INSTALLED_PACKAGE_MARKER = '/node_modules/pantheon-opencode/'
1238
+
1239
+ /**
1240
+ * Return the installed-package root containing a plugin path, or null when the
1241
+ * path is not inside a `node_modules/pantheon-opencode` tree.
1242
+ *
1243
+ * Only installed copies are compared: a dev checkout (e.g. <dev>/pantheon)
1244
+ * and unrelated directories named pantheon-opencode are deliberately not
1245
+ * flagged — the drift signal must never fire on a path the installer does not
1246
+ * own (see the third-party guards in scripts/install/opencode.mjs).
1247
+ *
1248
+ * @param {string|null|undefined} pluginPath
1249
+ * @returns {string|null}
1250
+ */
1251
+ export function resolveInstalledPackageRoot(pluginPath) {
1252
+ if (typeof pluginPath !== 'string') return null
1253
+ const normalized = pluginPath.replaceAll('\\', '/')
1254
+ const index = normalized.toLowerCase().lastIndexOf(INSTALLED_PACKAGE_MARKER.toLowerCase())
1255
+ if (index === -1) return null
1256
+ return normalized.slice(0, index + INSTALLED_PACKAGE_MARKER.length - 1)
1257
+ }
1258
+
1259
+ /**
1260
+ * Read the version of the installed pantheon-opencode package a registered
1261
+ * plugin path points into.
1262
+ * @param {string} pluginPath - absolute or config-relative plugin ref
1263
+ * @returns {{root: string, version: string}|null} null when not an installed copy
1264
+ */
1265
+ function readInstalledPluginVersion(pluginPath) {
1266
+ const root = resolveInstalledPackageRoot(pluginPath)
1267
+ if (!root) return null
1268
+ const pkg = readJson(join(root, 'package.json'))
1269
+ if (!pkg || typeof pkg.version !== 'string') return null
1270
+ return { root, version: pkg.version }
1271
+ }
1272
+
1273
+ /**
1274
+ * Classify plugin version drift between the package a registered plugin points
1275
+ * into and the running doctor's own package. Uses the prerelease-aware
1276
+ * comparator so a stable release outranks its own betas (1.5.0 > 1.5.0-beta.9).
1277
+ * Unreadable versions classify as "unknown" rather than drift, so a broken
1278
+ * package.json never produces a false warning.
1279
+ * @param {string|null} registeredVersion
1280
+ * @param {string|null} packageVersion
1281
+ * @returns {'sync'|'drift'|'unknown'}
1282
+ */
1283
+ export function classifyPluginVersionDrift(registeredVersion, packageVersion) {
1284
+ if (!registeredVersion || !packageVersion) return 'unknown'
1285
+ return comparePantheonVersions(registeredVersion, packageVersion) === 0 ? 'sync' : 'drift'
1286
+ }
1287
+
1288
+ /**
1289
+ * Collect the plugin refs registered across opencode.json configs, from both
1290
+ * the V1 `plugin` and V2 `plugins` lists. Each entry keeps the config it came
1291
+ * from so relative refs resolve against the config directory (as the loader
1292
+ * does), never against the doctor's cwd.
1293
+ * @param {{path: string, data: object}[]} configs
1294
+ * @returns {{path: string, configPath: string, configLabel: string}[]}
1295
+ */
1296
+ export function collectRegisteredPluginRefs(configs) {
1297
+ const refs = []
1298
+ for (const cfg of configs) {
1299
+ for (const key of ['plugin', 'plugins']) {
1300
+ const list = cfg.data?.[key]
1301
+ if (!Array.isArray(list)) continue
1302
+ for (const ref of list) {
1303
+ if (typeof ref === 'string') {
1304
+ refs.push({ path: ref, configPath: cfg.path, configLabel: cfg.label })
1305
+ } else if (ref && typeof ref === 'object') {
1306
+ // Object-shaped entries carry their path under `path`/`source`.
1307
+ const objectPath = ['path', 'source', 'id', 'name'].find(
1308
+ (k) => typeof ref[k] === 'string',
1309
+ )
1310
+ if (objectPath) {
1311
+ refs.push({ path: ref[objectPath], configPath: cfg.path, configLabel: cfg.label })
1312
+ }
1313
+ }
1314
+ }
1315
+ }
1316
+ }
1317
+ return refs
1318
+ }
1319
+
1320
+ /**
1321
+ * Compare the version of the package each registered plugin points into (when
1322
+ * it is an installed copy under node_modules/pantheon-opencode) against this
1323
+ * package's own version, and warn on divergence. Advisory only — a drift
1324
+ * warning never changes the exit code, and a healthy install stays silent.
1325
+ * @param {object} args
1326
+ * @returns {'drift'|'sync'|'skip'|'no-plugin'|'no-version'}
1327
+ */
1328
+ export function checkPluginVersionDrift(args) {
1329
+ section('H3. Plugin Version Drift')
1330
+
1331
+ const pkg = readJson(join(ROOT, 'package.json'))
1332
+ const packageVersion = pkg?.version
1333
+ if (!packageVersion) {
1334
+ info('Plugin version drift skipped — this package has no readable version')
1335
+ return 'no-version'
1336
+ }
1337
+
1338
+ const refs = collectRegisteredPluginRefs(collectMcpConfigs(args))
1339
+ if (refs.length === 0) {
1340
+ info('No plugin registrations found in opencode.json — version drift skipped')
1341
+ return 'no-plugin'
1342
+ }
1343
+
1344
+ const seen = new Set()
1345
+ let compared = 0
1346
+ let driftReported = false
1347
+ for (const ref of refs) {
1348
+ const absolute = isAbsolute(ref.path) ? ref.path : resolve(dirname(ref.configPath), ref.path)
1349
+ const installed = readInstalledPluginVersion(absolute)
1350
+ if (!installed) continue
1351
+ // The doctor running from the very tree the plugin points at is the
1352
+ // healthy global-install case — trivially in sync, never flagged.
1353
+ if (resolve(installed.root) === resolve(ROOT)) continue
1354
+ if (seen.has(installed.root)) continue
1355
+ seen.add(installed.root)
1356
+ compared++
1357
+
1358
+ if (classifyPluginVersionDrift(installed.version, packageVersion) !== 'drift') continue
1359
+ driftReported = true
1360
+ warn(
1361
+ `${ref.configLabel}: plugin is registered inside pantheon-opencode v${installed.version}, but this package is v${packageVersion} — the registered tool surface is stale (pantheon_delegate was removed in 1.5.0). Run \`npx pantheon-opencode init\`, or update the package, to realign the registered plugin path`,
1362
+ )
1363
+ }
1364
+
1365
+ if (driftReported) return 'drift'
1366
+ if (compared === 0) {
1367
+ info(
1368
+ 'No registered plugin points into an installed pantheon-opencode copy — version drift skipped',
1369
+ )
1370
+ return 'skip'
1371
+ }
1372
+ pass(`All registered plugins point at package v${packageVersion}`)
1373
+ return 'sync'
1374
+ }
1375
+
1217
1376
  // ---------------------------------------------------------------------------
1218
1377
  // Check G: AGENTS.md freshness
1219
1378
  // ---------------------------------------------------------------------------
@@ -1424,6 +1583,71 @@ export function classifyPermissionTaskCheck(profile, installedCount, missingCoun
1424
1583
  return missingCount === 0 ? 'pass' : 'error'
1425
1584
  }
1426
1585
 
1586
+ // ---------------------------------------------------------------------------
1587
+ // Check K: Node Runtime — node:sqlite availability (issue #114)
1588
+ // ---------------------------------------------------------------------------
1589
+
1590
+ // pantheon_cost uses node:sqlite as its read-only backend; below this Node
1591
+ // version the tool reports UNSUPPORTED (see the "Cost tool backend" README
1592
+ // section). Matches the floor documented for the PANTHEON_NODE fallback.
1593
+ const NODE_SQLITE_MIN = '22.5'
1594
+
1595
+ /**
1596
+ * Probe whether the running Node.js can load `node:sqlite` — the read-only
1597
+ * backend used by the `pantheon_cost` tool (issue #114). Runs the import in a
1598
+ * `--no-warnings` child so the experimental-feature notice never pollutes the
1599
+ * doctor output, and so a probe crash can never take down the doctor itself.
1600
+ *
1601
+ * @param {{ execPath?: string; module?: string }} [options]
1602
+ * @returns {{ available: boolean; version: string; reason?: string }}
1603
+ */
1604
+ export function probeNodeSqlite(options = {}) {
1605
+ const execPath = options.execPath ?? process.execPath
1606
+ const target = options.module ?? 'node:sqlite'
1607
+ const result = spawn(execPath, [
1608
+ '--no-warnings',
1609
+ '--input-type=module',
1610
+ '-e',
1611
+ `import '${target}'`,
1612
+ ])
1613
+ if (result.status === 0) return { available: true, version: process.version }
1614
+ const reason = (result.stderr || result.stdout || 'import failed').split('\n')[0]
1615
+ return { available: false, version: process.version, reason }
1616
+ }
1617
+
1618
+ /**
1619
+ * Classify a node:sqlite probe result for the doctor. Kept free of I/O so the
1620
+ * contract is exercised deterministically (see tests/test_doctor_layers.mjs).
1621
+ *
1622
+ * @param {{ available: boolean; version: string }} probe
1623
+ * @returns {{ status: 'ok' | 'unsupported'; message: string }}
1624
+ */
1625
+ export function classifyNodeSqliteProbe(probe) {
1626
+ if (probe.available) {
1627
+ return {
1628
+ status: 'ok',
1629
+ message: `node:sqlite available on ${probe.version} — pantheon_cost read-only backend supported`,
1630
+ }
1631
+ }
1632
+ return {
1633
+ status: 'unsupported',
1634
+ message: `pantheon_cost will report UNSUPPORTED on Node ${probe.version} — node:sqlite requires Node >= ${NODE_SQLITE_MIN}`,
1635
+ }
1636
+ }
1637
+
1638
+ function checkNodeRuntime() {
1639
+ section('K. Node Runtime')
1640
+ const outcome = classifyNodeSqliteProbe(probeNodeSqlite())
1641
+ if (outcome.status === 'ok') {
1642
+ pass(outcome.message)
1643
+ } else {
1644
+ warn(outcome.message)
1645
+ info(
1646
+ `Upgrade to Node >= ${NODE_SQLITE_MIN} (or a runtime with node:sqlite) to enable pantheon_cost`,
1647
+ )
1648
+ }
1649
+ }
1650
+
1427
1651
  /**
1428
1652
  * Return the final doctor status message without masking blocking errors.
1429
1653
  * @param {{ error: number; warn: number }} summaryCounts
@@ -1506,12 +1730,14 @@ async function main() {
1506
1730
  existsSync(join(args.target, 'scripts', 'doctor.mjs')) === false
1507
1731
 
1508
1732
  // Run checks (layer order: Config → Venv → spawn paths → runtime smoke)
1733
+ checkNodeRuntime(args)
1509
1734
  checkAgentFiles(args)
1510
1735
  checkMcpConfig(args)
1511
1736
  checkVenvLayer(args)
1512
1737
  checkCodeModeDir(args)
1513
1738
  checkCodeModeManifest(args)
1514
1739
  checkInstallVersionDrift(args)
1740
+ checkPluginVersionDrift(args)
1515
1741
  await checkMcpRuntimeSmoke(args)
1516
1742
  checkPermissionMismatches(args)
1517
1743
  checkSyncStatus(args)
@@ -34,7 +34,17 @@ permission:
34
34
  bash: deny
35
35
  read: allow
36
36
  grep: allow
37
- edit: allow
37
+ # Scoped write/edit: mnemosyne may create and modify files ONLY inside the
38
+ # memory-bank and deepwork directories. Everything else stays denied, keeping
39
+ # the SCOPE BOUNDARY below enforceable at the permission layer (issue #111).
40
+ write:
41
+ "*": deny
42
+ ".pantheon/memory-bank/**": allow
43
+ ".pantheon/deepwork/**": allow
44
+ edit:
45
+ "*": deny
46
+ ".pantheon/memory-bank/**": allow
47
+ ".pantheon/deepwork/**": allow
38
48
  task:
39
49
  "*": deny
40
50
  ---
@@ -3,6 +3,7 @@ description: "Orquestrador central — NUNCA implementa. Roteia para especialist
3
3
  mode: primary
4
4
  reasoning_effort: medium
5
5
  permission:
6
+ read: allow
6
7
  edit: deny
7
8
  bash: deny
8
9
  task:
@@ -6,6 +6,10 @@ for semantic memory with hybrid search (vector cosine + FTS5 BM25).
6
6
 
7
7
  Total footprint: ~50MB (sqlite-vec + fastembed) vs ~1.4GB (old chromadb).
8
8
 
9
+ Both embedding backends are optional: if either fails to import the server
10
+ still starts and serves FTS5-only search (``PANTHEON_MEMORY_EMBED=off``
11
+ disables the vector pipeline explicitly).
12
+
9
13
  Tools:
10
14
  memory_store — Store with automatic embedding
11
15
  memory_search — Hybrid vector + FTS5 keyword search
@@ -30,9 +34,21 @@ from datetime import UTC, datetime
30
34
  from pathlib import Path
31
35
  from typing import Any
32
36
 
33
- import sqlite_vec
37
+ # Embedding backends are optional (issue #159): a failed fastembed/sqlite_vec
38
+ # install (network error, incompatible wheel) must not kill the server — it
39
+ # degrades to FTS-only search instead of crashing before the MCP handshake.
40
+ try:
41
+ import sqlite_vec
42
+ except ImportError: # pragma: no cover - degraded mode
43
+ sqlite_vec = None # type: ignore[assignment]
44
+
34
45
  from _pantheon_paths import pantheon_home
35
- from fastembed import TextEmbedding
46
+
47
+ try:
48
+ from fastembed import TextEmbedding
49
+ except ImportError: # pragma: no cover - degraded mode
50
+ TextEmbedding = None # type: ignore[assignment]
51
+
36
52
  from mcp.server.fastmcp import FastMCP
37
53
 
38
54
  try:
@@ -52,6 +68,12 @@ except ImportError: # pragma: no cover - deployed runtime may need path fix
52
68
  DB_PATH = pantheon_home() / "memory" / "memory.db"
53
69
  EMBED_CACHE = Path.home() / ".cache" / "fastembed"
54
70
 
71
+ # PANTHEON_MEMORY_EMBED=off disables the embedding/vector pipeline so the
72
+ # server serves FTS-only search (issue #159). Default "on" keeps the current
73
+ # hybrid vector + FTS behavior. The flag is also forced off automatically
74
+ # when fastembed failed to import.
75
+ _EMBED_DISABLED = frozenset({"off", "0", "false", "no"})
76
+
55
77
 
56
78
  _BYTE_UNIT = 1024
57
79
 
@@ -81,11 +103,6 @@ CREATE TABLE IF NOT EXISTS memories (
81
103
  CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_ns_key
82
104
  ON memories(namespace, key);
83
105
 
84
- CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(
85
- id INTEGER PRIMARY KEY,
86
- embedding float[384]
87
- );
88
-
89
106
  CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
90
107
  namespace, key, value, metadata,
91
108
  content='memories', content_rowid='id',
@@ -113,6 +130,14 @@ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
113
130
  END;
114
131
  """
115
132
 
133
+ # Vector store requires the sqlite-vec extension; created only when available.
134
+ VEC_SCHEMA_SQL = """
135
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_memories USING vec0(
136
+ id INTEGER PRIMARY KEY,
137
+ embedding float[384]
138
+ );
139
+ """
140
+
116
141
  # ── FastMCP App ───────────────────────────────────────────────────────────────
117
142
 
118
143
  mcp = FastMCP(
@@ -129,7 +154,8 @@ def _get_db() -> sqlite3.Connection:
129
154
  """Get or create the SQLite connection singleton.
130
155
 
131
156
  Creates DB directory, applies WAL/performance pragmas, registers
132
- the sqlite-vec extension, and runs schema init.
157
+ the sqlite-vec extension, and runs schema init. When sqlite-vec is
158
+ unavailable the vector table is skipped — search degrades to FTS-only.
133
159
  """
134
160
  DB_PATH.parent.mkdir(parents=True, exist_ok=True)
135
161
  conn = sqlite3.connect(str(DB_PATH), check_same_thread=False)
@@ -137,9 +163,10 @@ def _get_db() -> sqlite3.Connection:
137
163
  conn.execute("PRAGMA journal_mode=WAL")
138
164
  conn.execute("PRAGMA busy_timeout=5000")
139
165
  conn.execute("PRAGMA foreign_keys=ON")
140
- # Register sqlite-vec extension
141
-
142
- sqlite_vec.load(conn)
166
+ # Register sqlite-vec extension (optional; enables vector search)
167
+ if sqlite_vec is not None:
168
+ sqlite_vec.load(conn)
169
+ conn.executescript(VEC_SCHEMA_SQL)
143
170
  conn.executescript(SCHEMA_SQL)
144
171
  if _codemap is not None:
145
172
  with contextlib.suppress(Exception):
@@ -155,13 +182,30 @@ def _get_conn() -> sqlite3.Connection:
155
182
  # ── Embedding ─────────────────────────────────────────────────────────────────
156
183
 
157
184
 
185
+ def _embeddings_enabled() -> bool:
186
+ """Whether the embedding/vector pipeline is active.
187
+
188
+ Controlled by ``PANTHEON_MEMORY_EMBED`` (``on`` by default; ``off``,
189
+ ``0``, ``false`` or ``no`` disable it). Automatically disabled when
190
+ ``fastembed`` failed to import, so the server degrades to FTS-only
191
+ search instead of crashing at startup (issue #159).
192
+ """
193
+ if TextEmbedding is None:
194
+ return False
195
+ flag = os.environ.get("PANTHEON_MEMORY_EMBED", "on").strip().lower()
196
+ return flag not in _EMBED_DISABLED
197
+
198
+
158
199
  @functools.cache
159
- def _get_embedder() -> TextEmbedding:
200
+ def _get_embedder() -> TextEmbedding | None:
160
201
  """Lazy-load fastembed model (auto-downloads on first call, ~30MB).
161
202
 
162
203
  Uses BAAI/bge-small-en-v1.5 (384-dim, CPU, ONNX) — no PyTorch needed.
163
- Model is cached in ~/.cache/fastembed/.
204
+ Model is cached in ~/.cache/fastembed/. Returns None when fastembed is
205
+ unavailable.
164
206
  """
207
+ if TextEmbedding is None:
208
+ return None
165
209
  os.environ.setdefault("TQDM_DISABLE", "1")
166
210
  return TextEmbedding(
167
211
  model_name="BAAI/bge-small-en-v1.5",
@@ -169,9 +213,18 @@ def _get_embedder() -> TextEmbedding:
169
213
  )
170
214
 
171
215
 
172
- def _embed(text: str) -> list[float]:
173
- """Generate a 384-dim embedding vector for the given text."""
174
- return next(iter(_get_embedder().embed(text))).tolist()
216
+ def _embed(text: str) -> list[float] | None:
217
+ """Generate a 384-dim embedding vector for the given text.
218
+
219
+ Returns None when embeddings are disabled (``PANTHEON_MEMORY_EMBED=off``
220
+ or fastembed missing) — callers fall back to FTS-only search.
221
+ """
222
+ if not _embeddings_enabled():
223
+ return None
224
+ embedder = _get_embedder()
225
+ if embedder is None:
226
+ return None
227
+ return next(iter(embedder.embed(text))).tolist()
175
228
 
176
229
 
177
230
  # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -341,14 +394,14 @@ def memory_store(
341
394
  )
342
395
  entry_id = cur.lastrowid
343
396
 
344
- # Generate embedding
397
+ # Generate embedding (None when disabled → FTS-only mode)
345
398
  embedding = _embed(value)
346
- embedding_json = json.dumps(embedding)
347
-
348
- db.execute(
349
- "INSERT INTO vec_memories(id, embedding) VALUES (?, ?)",
350
- [entry_id, embedding_json],
351
- )
399
+ if embedding is not None and sqlite_vec is not None:
400
+ embedding_json = json.dumps(embedding)
401
+ db.execute(
402
+ "INSERT INTO vec_memories(id, embedding) VALUES (?, ?)",
403
+ [entry_id, embedding_json],
404
+ )
352
405
  db.commit()
353
406
  except sqlite3.IntegrityError as e:
354
407
  db.rollback()
@@ -406,8 +459,8 @@ def memory_search( # noqa: C901, PLR0912
406
459
  vec_ids: list[int] = []
407
460
  fts_ids: list[int] = []
408
461
 
409
- # 2. Vector search
410
- if query_vec is not None:
462
+ # 2. Vector search (skipped when embeddings or sqlite-vec are unavailable)
463
+ if query_vec is not None and sqlite_vec is not None:
411
464
  nvec = top_k * 2
412
465
  try:
413
466
  if namespace:
@@ -564,8 +617,9 @@ def memory_forget(
564
617
  db = _get_conn()
565
618
  try:
566
619
  if col == "id":
567
- # Vec table cascade
568
- db.execute("DELETE FROM vec_memories WHERE id = ?", [id])
620
+ # Vec table cascade (absent when sqlite-vec is unavailable)
621
+ if sqlite_vec is not None:
622
+ db.execute("DELETE FROM vec_memories WHERE id = ?", [id])
569
623
  cur = db.execute("DELETE FROM memories WHERE id = ?", [id])
570
624
  else:
571
625
  cur = db.execute(sql, params)