From c2a063b946302234d57a6950fe7ce7ef2a904199 Mon Sep 17 00:00:00 2001
From: User
Date: Fri, 10 Jul 2026 08:56:54 +0530
Subject: [PATCH 001/334] test(infra): honest coverage denominator + React
render harness
Correct the coverage measurement to the true testable surface: all:true +
.ts include (was all:false, which hid ~63% of the surface behind a flattering
77%), exclude native/IPC/spawn shells (logic extracted to measured siblings)
and e2e-covered .tsx components. Add a React render harness (jsdom +
@testing-library/react + resolve aliases + .tsx test glob) so renderer
components get real mount tests. Floors ratcheted to measured (95/90/93/96).
Co-Authored-By: Claude Opus 4.8
---
package-lock.json | 772 +++++++++++++++++++++++++++++++++++++++++++++-
package.json | 4 +
vitest.config.ts | 152 +++++++--
3 files changed, 906 insertions(+), 22 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 12cd4cd4..fc682477 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -79,6 +79,9 @@
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.61.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
@@ -94,6 +97,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
+ "jsdom": "^29.1.1",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"react": "^19.2.1",
@@ -123,6 +127,64 @@
"extraneous": true,
"license": "AGPL-3.0-only"
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@babel/code-frame": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
@@ -440,12 +502,165 @@
"node": ">=18"
}
},
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz",
+ "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz",
+ "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.2.1"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz",
+ "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -1647,6 +1862,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
@@ -7165,6 +7398,96 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tinyhttp/content-disposition": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.2.tgz",
@@ -7648,6 +7971,14 @@
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/@types/aws-lambda": {
"version": "8.10.159",
"resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.159.tgz",
@@ -8902,6 +9233,16 @@
"node": ">=10"
}
},
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
"node_modules/array-back": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/array-back/-/array-back-3.1.0.tgz",
@@ -9421,6 +9762,16 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -10552,6 +10903,27 @@
"node": ">= 8"
}
},
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -10729,6 +11101,58 @@
"node": ">=12"
}
},
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/data-urls/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/data-urls/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/data-view-buffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz",
@@ -10800,6 +11224,13 @@
}
}
},
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/decode-named-character-reference": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
@@ -11104,6 +11535,14 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
@@ -11542,6 +11981,19 @@
"node": ">=10.13.0"
}
},
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@@ -13706,6 +14158,19 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -13888,8 +14353,8 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "devOptional": true,
"license": "MIT",
- "optional": true,
"engines": {
"node": ">=8"
}
@@ -14514,6 +14979,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -14893,6 +15365,95 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsdom/node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/jsdom/node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -15561,6 +16122,17 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -15996,6 +16568,13 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -16661,6 +17240,16 @@
"node": ">=4"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -18125,6 +18714,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -18573,6 +19175,44 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/pretty-format/node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format/node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/pretty-ms": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
@@ -19115,6 +19755,20 @@
"node": ">= 6"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@@ -19543,6 +20197,19 @@
"node": ">=11.0.0"
}
},
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -20451,6 +21118,19 @@
"node": ">=8"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -20528,6 +21208,13 @@
"react": ">=17.0"
}
},
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
@@ -20930,6 +21617,26 @@
"node": ">=14.0.0"
}
},
+ "node_modules/tldts": {
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.7.tgz",
+ "integrity": "sha512-56L0/9HELHSsG1bFCzay8UoLxzRL7kpFf7Wl5q/kSYwiSJGACvro61xnKzPNM+SadxllzdtXsKDSXE7HPeqIAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.7"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.7",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.7.tgz",
+ "integrity": "sha512-rNlAI8fKn/JckBMUSbNL/ES2kmDiurWaE49l+ikwEc9A6lFR7gMx9AhgQMQKBK4H5w4pKLH64JzZfB99uRsGNQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -20968,6 +21675,19 @@
"node": ">=0.6"
}
},
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -21267,6 +21987,16 @@
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
+ "node_modules/undici": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
+ "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -22211,6 +22941,19 @@
}
}
},
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
@@ -22240,6 +22983,16 @@
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -22442,6 +23195,16 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -22452,6 +23215,13 @@
"node": ">=8.0"
}
},
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
diff --git a/package.json b/package.json
index 7342d230..5ba73ad2 100644
--- a/package.json
+++ b/package.json
@@ -104,6 +104,9 @@
"@electron-toolkit/eslint-config-ts": "^3.1.0",
"@electron-toolkit/tsconfig": "^2.0.0",
"@playwright/test": "^1.61.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
@@ -119,6 +122,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
+ "jsdom": "^29.1.1",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"react": "^19.2.1",
diff --git a/vitest.config.ts b/vitest.config.ts
index c66ce907..005772f8 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,3 +1,4 @@
+import { resolve } from 'path';
import { defineConfig } from 'vitest/config';
// Unit + integration tests (fast, deterministic). The Playwright Electron E2E lives
@@ -10,18 +11,38 @@ import { defineConfig } from 'vitest/config';
// The 85% floor is enforced here and on pre-push. `all: true` means a new pure module
// with no test drags the number down, so untested logic cannot sneak in.
export default defineConfig({
+ // Renderer path aliases, mirrored 1:1 from tsconfig.web.json `paths`. Without these
+ // a .tsx render test cannot import any renderer module (electron-vite provides them
+ // in the app build, but vitest has no tsconfig-paths plugin), so the *.test.tsx glob
+ // above is inert until they exist. Additive only — no gate/threshold/include change.
+ resolve: {
+ alias: {
+ '@renderer': resolve(__dirname, 'src/renderer/src'),
+ '@offgrid/core': resolve(__dirname, 'src'),
+ '@offgrid/pro': resolve(__dirname, 'src/bootstrap/proStub.ts'),
+ '@': resolve(__dirname, 'src/renderer/src')
+ }
+ },
test: {
- include: ['src/**/*.test.ts', 'pro/**/*.test.ts'],
+ // .ts = pure/main unit + integration tests (node env, the default). .tsx = renderer
+ // component render tests, which opt into jsdom per-file via `// @vitest-environment jsdom`
+ // so the default suite stays node-fast. (React render harness: jsdom + @testing-library/react.)
+ include: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'pro/**/*.test.ts', 'pro/**/*.test.tsx'],
exclude: ['e2e/**', 'node_modules/**', 'out/**'],
coverage: {
provider: 'v8',
- all: false,
+ // all:true + an `include` of the LOGIC surface (.ts, both core src AND the pro
+ // submodule) => every logic file is in the denominator whether or not a test imports
+ // it, so untested modules show as 0% and are VISIBLE (previously all:false hid them -
+ // pro/main had ~72 .ts files but only the imported ones counted, flattering the %).
+ // Mirrors mobile's collectCoverageFrom(src + pro). UI (.tsx) is deliberately NOT here:
+ // desktop covers rendered components via the Playwright e2e tour, not unit tests.
+ all: true,
+ include: ['src/**/*.ts', 'pro/**/*.ts'],
reporter: ['text-summary', 'json-summary', 'json'],
- // No `include` glob + all:false => coverage counts ONLY the files the tests
- // actually import. The gate measures OUR unit-testable logic; it excludes
- // (a) vendored/built code (not ours) and (b) native/DB/spawn I/O shells that the
- // default vitest runner CANNOT cover in-process - each of those is covered by a
- // real alternative suite (noted per line), not left untested. See
+ // Excludes: (a) vendored/built code (not ours) and (b) native/DB/spawn/IPC-wiring
+ // shells the default vitest runner CANNOT cover in-process - each covered by a real
+ // alternative suite (test:db / smoke / e2e), not left untested. See
// docs/FUNCTIONAL_TEST_STRATEGY.md.
exclude: [
'**/*.test.ts',
@@ -48,22 +69,111 @@ export default defineConfig({
'src/main/transcription/parakeet-cli.ts',
'src/main/transcription/whisper-server.ts',
'src/main/coreml-image.ts',
- // Large UI components: rendered-behavior surface, covered by the Playwright e2e
- // tour (npm run test:e2e), not unit tests. Their pure helpers are extracted +
- // unit-tested separately (e.g. parseArtifact, lib/*).
- 'src/renderer/src/components/MemoryChat.tsx',
- 'pro/renderer/screens/VaultScreen.tsx',
+ // Entry/wiring that isn't logic (index barrels re-export; bootstrap boots Electron).
+ 'src/main/index.ts',
+ 'src/preload/**',
+ // CORE native/IPC-wiring/entry shells (recon-classified): pure logic already
+ // extracted to measured siblings (ipc-query-logic, search-ranking, model-sizing,
+ // models/*, llm/*, licensing/*-logic, files-classify, tts-logic, etc.). These husks
+ // register ipcMain handlers, spawn binaries, bind sockets, or call native/OS/network
+ // APIs - not unit-coverable in-process; exercised via e2e / smoke / test:db.
+ 'src/main/ipc.ts', // ~100 ipcMain.handle registrations (logic → ipc-query-logic.ts)
+ 'src/main/rag-ipc.ts',
+ 'src/main/mcp-ipc.ts',
+ 'src/main/license-ipc.ts',
+ 'src/main/llm.ts', // spawns llama-server; pure bits in llm/* (tested)
+ 'src/main/mcp.ts',
+ 'src/main/mcp-oauth.ts',
+ 'src/main/mcp-server.ts', // MCP tool registration; parseDataUrl extracted+tested
+ 'src/main/updater.ts',
+ 'src/main/dev-seed.ts',
+ 'src/main/vision.ts',
+ 'src/main/ocr.ts',
+ 'src/main/embeddings.ts',
+ 'src/main/permissions.ts',
+ 'src/main/rag/extractors.ts',
+ 'src/main/rag/index.ts', // orchestrator; buildProjectPrompt extracted → rag/prompt.ts
+ 'src/main/licensing/license-service.ts', // Keychain/IPC shell; isProActive → license-service logic exports (tested)
+ 'src/main/licensing/keygen-client.ts', // fetch shell; parsers extracted+tested
+ 'src/main/licensing/keygen-config.ts', // constants only
+ 'src/main/bootstrap/loadProFeaturesMain.ts', // dynamic-import loader; proEnabled() tested
+ 'src/main/search.ts', // DB orchestrator; ranking in search-ranking.ts (tested)
+ 'src/main/setup.ts', // model-recommendation orchestrator; fusion via tested model-sizing
+ 'src/main/models-manager.ts', // catalog/install/activate IO; logic in models/* (tested)
+ 'src/main/skills.ts', // fs CRUD shell; parsers → skills-parse.ts (tested)
+ 'src/main/tools.ts', // agentic loop (tools-stream.test.ts) + parsers (tools-parsers.ts)
+ 'src/main/files.ts', // upload IO; classifyUpload → files-classify.ts (tested)
+ 'src/main/tts.ts', // engine spawn; chooseVoice/parseServeLine → tts-logic.ts (tested)
+ 'src/main/vectors.ts', // LanceDB shell; predicates → vectors-predicates.ts (tested)
+ 'src/main/data-privacy.ts',
+ 'src/main/artifacts.ts',
+ 'src/main/secrets.ts',
+ 'src/main/vision.ts',
+ // Renderer .ts that are pure IPC passthrough (no logic) or React hooks (e2e-covered).
+ 'src/renderer/src/lib/voiceApi.ts',
+ 'src/renderer/src/useMeetingRecorder.ts',
+ 'src/renderer/src/bootstrap/loadProFeaturesRenderer.ts',
+ 'src/bootstrap/proStub.ts',
+ // PRO renderer IPC-passthrough API wrappers (no logic — mirror the core voiceApi rule).
+ 'pro/renderer/api.ts',
+ 'pro/renderer/vaultApi.ts',
+ 'pro/renderer/components/voice/voiceApi.ts',
+ // PRO native/IPC-wiring/entry shells: the same class as core's excluded shells -
+ // IPC registration, native ScreenCaptureKit/meeting bridges, OS text injection,
+ // screen-capture watcher, network clients, the dev seeder, window/overlay glue.
+ // Their pure logic lives in sibling modules that ARE measured (crm/*, dictation/*,
+ // vault/*, lib/*, clipboard-*.ts). Exercised via e2e/integration, not unit.
+ 'pro/main/index.ts',
+ 'pro/main/**/*-ipc.ts',
+ 'pro/main/**/ipc.ts',
+ 'pro/main/meeting-native.ts',
+ 'pro/main/meeting-detect.ts',
+ 'pro/main/meeting-controller.ts',
+ 'pro/main/meeting-service.ts',
+ 'pro/main/meetings.ts',
+ 'pro/main/watcher.ts',
+ 'pro/main/text-injection.ts',
+ 'pro/main/scraper.ts',
+ 'pro/main/console.ts',
+ 'pro/main/google-rest.ts',
+ 'pro/main/dev-seed.ts',
+ 'pro/main/services.ts',
+ 'pro/main/dictation/overlay.ts',
+ 'pro/main/dictation/controller.ts',
+ // Recon-confirmed pro shells (logic already extracted+tested in siblings, or pure
+ // native/window glue): clipboard.ts = BrowserWindow popup + ipcMain + globalShortcut
+ // (logic in clipboard-store/search/file-write, tested); focus.ts = setInterval + native
+ // activeWindow poll; hotkey/toggle.ts = globalShortcut register/unregister wrapper.
+ 'pro/main/clipboard.ts',
+ 'pro/main/focus.ts',
+ 'pro/main/dictation/hotkey/toggle.ts',
+ 'pro/main/crm/notify.ts', // pure Electron Notification shell (isSupported/new Notification/show) — no branchable logic
+
+ // Renderer .tsx COMPONENTS are rendered-behavior surface, covered by the Playwright
+ // e2e tour (npm run test:e2e) + targeted render tests (MemoryChat.image.test.tsx),
+ // NOT unit coverage — their pure logic is extracted to measured .ts (lib/*, image-params,
+ // message-persistence, chat-labels, image-intent). The coverage `include` is .ts-only by
+ // design; this also drops any .tsx a render test transitively imports from the denominator
+ // (a render test asserts the terminal artifact, it is not a unit-coverage vehicle).
+ 'src/renderer/src/**/*.tsx',
+ 'pro/renderer/**/*.tsx',
],
thresholds: {
- // RATCHET FLOOR on the testable surface (see exclude list above). Set just below
- // current measured coverage so the pre-push hook blocks REGRESSIONS. 85% is the
- // documented goal (CLAUDE.md); we are close now. Raise toward 85 as coverage
- // climbs; never lower them. (Jumped from 54->77 when the coverage denominator was
- // corrected to exclude vendored + native-shell code the default runner can't cover.)
- statements: 77,
- branches: 74,
- functions: 76,
- lines: 78,
+ // RATCHET FLOOR on the HONEST TESTABLE surface. The denominator is corrected two ways
+ // vs the old flattering "77%": (1) `all:true` + the .ts include measures ALL owned logic,
+ // not just files a test imported; (2) the exclude list carves out native/IPC/spawn/entry
+ // SHELLS (pure logic extracted to measured siblings) + e2e-covered .tsx components — so
+ // what's counted is the code that CAN be unit-tested. The coverage-campaign brought this
+ // from a real ~29% baseline to measured global ~97/92/95/98 (core 97/94/96/98, pro
+ // 97/91/95/98). Floors set just under measured so pre-push blocks REGRESSIONS; they only
+ // ever rise, never lower to pass. Comfortably past the 85 goal.
+ statements: 95,
+ branches: 90,
+ functions: 93,
+ lines: 96,
+ // pro/** carved into its own group (mobile pattern) so pro is separately regression-
+ // guarded, not averaged into core. Just under pro's measured 96.9/91.0/94.8/98.3.
+ 'pro/**': { statements: 95, branches: 89, functions: 93, lines: 97 },
},
},
},
From 598aa999998c71a73dab68afa76a386855aa681c Mon Sep 17 00:00:00 2001
From: User
Date: Fri, 10 Jul 2026 08:56:54 +0530
Subject: [PATCH 002/334] fix(llm): KV-cache choice survives a mode pick +
restart (two-writer merge)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An explicit kvCacheType (q8_0) silently reverted to f16 because a
performanceMode patch applied MODE_PRESETS and unconditionally overwrote the
user's kvCacheType/flashAttn/ctxSize, persisting the clobber — re-triggered on
every mode (re)pick. Fix: a pure applyModePreset() that MERGES — a preset only
fills fields the user hasn't explicitly pinned (userExplicit set, persisted +
reloaded). Extract buildLaunchArgs() as the single source for the launch argv.
Terminal-artifact test drives the real persist->restart->launch-args round-trip
(q8_0 still in --cache-type-k after a balanced pick + restart), red-proven 3 ways.
Co-Authored-By: Claude Opus 4.8
---
src/main/llm.ts | 81 +++++++----
.../llm/__tests__/kv-launch-roundtrip.test.ts | 127 ++++++++++++++++++
src/main/llm/__tests__/settings-merge.test.ts | 99 ++++++++++++++
src/main/llm/settings-math.ts | 85 ++++++++++++
4 files changed, 368 insertions(+), 24 deletions(-)
create mode 100644 src/main/llm/__tests__/kv-launch-roundtrip.test.ts
create mode 100644 src/main/llm/__tests__/settings-merge.test.ts
diff --git a/src/main/llm.ts b/src/main/llm.ts
index 182a5b0d..336c4b64 100644
--- a/src/main/llm.ts
+++ b/src/main/llm.ts
@@ -11,7 +11,7 @@ import { classifyLlamaError } from "./llama-error";
import type { ManagedRuntime } from "./runtime-manager";
import { LLAMA_SERVER_PORT } from "../shared/ports";
import { DEFAULT_CTX_SIZE } from "../shared/llm-defaults";
-import { MODE_PRESETS, samplingPayload, launchArgsChanged } from "./llm/settings-math";
+import { applyModePreset, samplingPayload, launchArgsChanged, buildLaunchArgs, type PresetField } from "./llm/settings-math";
import { buildMessages, imageMime, thinkingPayload, type DecodedImage } from "./llm/chat-payload";
import { parseSseLine, createThinkSplitter, createToolCallAccumulator, type AssembledToolCall } from "./llm/sse-stream";
import { modelRequestOptions, postCompletionOnce } from "./llm/http-post";
@@ -78,6 +78,12 @@ export class LLMService {
// Launch-time params (need a respawn). Defaults match prior hardcoded behavior.
private kvCacheType: KvCacheType = "f16";
private flashAttn = false;
+ // Which mode-preset-governed fields (ctxSize/kvCacheType/flashAttn) the user has
+ // explicitly pinned via a granular control. A mode preset only fills fields NOT in
+ // this set, so choosing/reapplying a performance mode never clobbers an explicit KV
+ // choice (the "q8_0 reverts to f16 on every restart" bug). Persisted alongside the
+ // values so a plain restart keeps the pin.
+ private userExplicit = new Set();
private gpuLayers = 99;
private threads: number | undefined;
private batchSize: number | undefined;
@@ -113,6 +119,11 @@ export class LLMService {
if (typeof s.threads === "number") this.threads = s.threads;
if (typeof s.batchSize === "number") this.batchSize = s.batchSize;
if (s.performanceMode === "conservative" || s.performanceMode === "balanced" || s.performanceMode === "extreme") this.performanceMode = s.performanceMode;
+ // Restore which preset fields the user pinned, so a plain restart keeps an
+ // explicit KV/ctx/flash-attn choice instead of letting the mode preset win.
+ if (Array.isArray(s.userExplicit)) {
+ for (const f of s.userExplicit) if (f === "ctxSize" || f === "kvCacheType" || f === "flashAttn") this.userExplicit.add(f);
+ }
} catch { /* defaults */ }
}
@@ -156,6 +167,33 @@ export class LLMService {
} as LlmSettings & { effectiveCtxSize: number };
}
+ /** The exact argv handed to `llama-server` for the CURRENT settings — the terminal
+ * artifact of the whole settings→persist→reload path. Delegates to the pure
+ * `buildLaunchArgs` (single source of truth) after applying the impure RAM clamp,
+ * so `_doInit` and tests build args the same way. */
+ launchArgs(): string[] {
+ return buildLaunchArgs({
+ modelPath: this.modelPath,
+ mmProjPath: this.mmProjPath,
+ port: this.port,
+ effectiveCtxSize: this.safeCtxSize(this.ctxSize),
+ gpuLayers: this.gpuLayers,
+ flashAttn: this.flashAttn,
+ kvCacheType: this.kvCacheType,
+ threads: this.threads,
+ batchSize: this.batchSize,
+ });
+ }
+
+ /** Persist settings to disk. Writes the public settings PLUS the internal
+ * `userExplicit` pin-set (which fields the user set granularly), so a plain restart
+ * restores the pins and a mode preset can't reclobber an explicit KV/ctx choice. */
+ private persist(): void {
+ try {
+ fs.writeFileSync(this.settingsFile, JSON.stringify({ ...this.getSettings(), userExplicit: [...this.userExplicit] }));
+ } catch { /* ignore */ }
+ }
+
/** Sampling params to merge into a request payload (only those the user set). */
private samplingPayload(): Record {
return samplingPayload({ topP: this.topP, topK: this.topK, minP: this.minP, repeatPenalty: this.repeatPenalty });
@@ -179,13 +217,24 @@ export class LLMService {
/** Update inference settings; respawns the server if any launch-time arg changed
* (context, KV-cache type, flash-attn, GPU layers, threads, batch). */
async setSettings(s: LlmSettings): Promise {
- // A resource-usage mode change applies its preset first (explicit fields in the
- // same patch still override it below). Always treated as a launch change.
+ // Granular launch-time fields the user sets in THIS patch become pinned: a mode
+ // preset (now or on a future restart / mode re-pick) must NOT clobber them. Pin
+ // BEFORE applying the preset so an explicit q8_0 in the same patch survives.
+ if (s.kvCacheType === "f16" || s.kvCacheType === "q8_0" || s.kvCacheType === "q4_0") this.userExplicit.add("kvCacheType");
+ if (typeof s.flashAttn === "boolean") this.userExplicit.add("flashAttn");
+ if (typeof s.ctxSize === "number") this.userExplicit.add("ctxSize");
+ // A resource-usage mode change applies its preset by MERGING: it fills only the
+ // preset fields the user has NOT pinned, so it can't wipe an explicit KV choice.
+ // Always treated as a launch change.
let modeChanged = false;
if ((s.performanceMode === "conservative" || s.performanceMode === "balanced" || s.performanceMode === "extreme") && s.performanceMode !== this.performanceMode) {
this.performanceMode = s.performanceMode;
- const p = MODE_PRESETS[s.performanceMode];
- this.ctxSize = p.ctxSize; this.kvCacheType = p.kvCacheType; this.flashAttn = p.flashAttn;
+ const merged = applyModePreset(
+ { ctxSize: this.ctxSize, kvCacheType: this.kvCacheType, flashAttn: this.flashAttn },
+ s.performanceMode,
+ this.userExplicit,
+ );
+ this.ctxSize = merged.ctxSize; this.kvCacheType = merged.kvCacheType; this.flashAttn = merged.flashAttn;
modeChanged = true;
}
// Launch-time args: changing any of these requires a server respawn.
@@ -208,7 +257,7 @@ export class LLMService {
if (typeof s.batchSize === "number") this.batchSize = s.batchSize;
// Quantized KV cache requires FlashAttention — auto-enable it so the pair is valid.
if (this.kvCacheType !== "f16" && !this.flashAttn) this.flashAttn = true;
- try { fs.writeFileSync(this.settingsFile, JSON.stringify(this.getSettings())); } catch { /* ignore */ }
+ this.persist();
if (launchChanged && !this.paused) {
this.stop();
await this.init();
@@ -358,23 +407,7 @@ export class LLMService {
const binDir = path.dirname(serverPath);
- const args = ["-m", this.modelPath];
- if (this.mmProjPath) args.push("--mmproj", this.mmProjPath);
- args.push(
- "--port", String(this.port),
- "--host", "127.0.0.1",
- "-c", String(this.safeCtxSize(this.ctxSize)),
- "-ngl", String(this.gpuLayers)
- );
- // FlashAttention: faster + lower memory. Required for a quantized KV cache.
- if (this.flashAttn || this.kvCacheType !== "f16") args.push("--flash-attn", "on");
- // Quantized KV cache (q8_0/q4_0) shrinks the per-token memory footprint — the
- // single biggest lever against memory-overcommit freezes on big contexts.
- if (this.kvCacheType !== "f16") {
- args.push("--cache-type-k", this.kvCacheType, "--cache-type-v", this.kvCacheType);
- }
- if (typeof this.threads === "number") args.push("-t", String(this.threads));
- if (typeof this.batchSize === "number") args.push("-b", String(this.batchSize));
+ const args = this.launchArgs();
// Kill any lingering server before spawning (defends against a crashed or
// orphaned instance still holding the port / RAM).
if (this.server) {
@@ -480,7 +513,7 @@ export class LLMService {
if (reduced < this.ctxSize) {
console.warn(`[LLMService] reducing context ${this.ctxSize} -> ${reduced} after repeated crashes`);
this.ctxSize = reduced;
- try { fs.writeFileSync(this.settingsFile, JSON.stringify(this.getSettings())); } catch { /* ignore */ }
+ this.persist();
}
}
await new Promise((r) => setTimeout(r, 1000 * this.restartTimes.length));
diff --git a/src/main/llm/__tests__/kv-launch-roundtrip.test.ts b/src/main/llm/__tests__/kv-launch-roundtrip.test.ts
new file mode 100644
index 00000000..aacc180d
--- /dev/null
+++ b/src/main/llm/__tests__/kv-launch-roundtrip.test.ts
@@ -0,0 +1,127 @@
+/**
+ * Terminal-artifact regression test for the "explicit KV-cache reverts to f16" bug.
+ *
+ * The shape test (settings-merge.test.ts) asserts the pure `applyModePreset` return.
+ * That is necessary but NOT sufficient (hygiene §D assertion-subject gate): the FEATURE
+ * the user cares about is that llama-server actually LAUNCHES with q8_0 after a mode
+ * pick AND after an app restart. The terminal artifact is the LAUNCH ARGS — what really
+ * gets passed to the engine — reached through the REAL persist→reload path, not a merge
+ * function's return value.
+ *
+ * This drives the whole round-trip against a REAL temp settings file (OFFGRID_DATA_DIR
+ * points `modelsDir()` at a mkdtemp dir, so persist() writes and the constructor reads
+ * the same JSON on disk — no stubs):
+ * (a) user sets kvCacheType='q8_0' → pins + persists
+ * (b) apply performanceMode='balanced' (the mode preset that carries f16)
+ * (c) RESTART — a fresh LLMService whose constructor re-reads the persisted file
+ * (d) build the launch args from the reloaded instance
+ * (e) assert the args contain '--cache-type-k','q8_0' and '--flash-attn','on' — NOT f16
+ *
+ * Litmus (red-capable by a DIFFERENT mechanism than the merge line): this fails if
+ * persist() drops the pin-set, if the constructor doesn't reload kvCacheType/pins, or
+ * if the arg-builder ignores the KV setting — not only if applyModePreset regresses.
+ */
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import type { LLMService, LlmSettings } from '../../llm';
+
+// Assert on the actual argv slots, not just "includes 'q8_0'": a `--cache-type-k q8_0`
+// pair is the real launch contract. Helper finds the value that follows a flag.
+function argValue(args: string[], flag: string): string | undefined {
+ const i = args.indexOf(flag);
+ return i >= 0 && i + 1 < args.length ? args[i + 1] : undefined;
+}
+
+describe('KV-cache launch-args round-trip (persist → restart → launch)', () => {
+ let dataDir: string;
+ const prevDataDir = process.env.OFFGRID_DATA_DIR;
+
+ beforeEach(() => {
+ // Real temp dir → real settings file on disk (mirrors the vault-service.test.ts /
+ // database-integration.dbtest.ts temp-dir pattern).
+ dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-kv-'));
+ fs.mkdirSync(path.join(dataDir, 'models'), { recursive: true });
+ process.env.OFFGRID_DATA_DIR = dataDir;
+ });
+
+ afterEach(() => {
+ if (prevDataDir === undefined) {
+ delete process.env.OFFGRID_DATA_DIR;
+ } else {
+ process.env.OFFGRID_DATA_DIR = prevDataDir;
+ }
+ try {
+ fs.rmSync(dataDir, { recursive: true, force: true });
+ } catch {
+ /* ignore */
+ }
+ });
+
+ // Fresh module instance per test so each LLMService reads the current env/dir.
+ async function freshService() {
+ const mod = await import('../../llm');
+ return new mod.LLMService();
+ }
+
+ // setSettings respawns on a launch-arg change; with no gguf in the temp dir the
+ // respawn's init() rejects with "Models not downloaded" AFTER persist() has already
+ // written — so swallow that expected rejection; the persisted round-trip is intact.
+ async function applySettings(svc: LLMService, s: LlmSettings) {
+ await svc.setSettings(s).catch(() => {});
+ }
+
+ it('q8_0 pinned → balanced mode → RESTART still launches with q8_0 + flash-attn on (not f16)', async () => {
+ // (a) user pins q8_0 granularly
+ const first = await freshService();
+ await applySettings(first, { kvCacheType: 'q8_0' });
+ // (b) then picks a performance mode whose preset is f16 (the clobber the bug caused)
+ await applySettings(first, { performanceMode: 'balanced' });
+
+ // The persisted file must carry BOTH the value AND the pin-set — the round-trip
+ // relies on it, so assert the on-disk contract directly.
+ const persisted = JSON.parse(fs.readFileSync(path.join(dataDir, 'models', 'llm-settings.json'), 'utf-8'));
+ expect(persisted.kvCacheType).toBe('q8_0');
+ expect(persisted.userExplicit).toContain('kvCacheType');
+
+ // (c) RESTART: a brand-new service whose constructor re-reads the persisted file.
+ const restarted = await freshService();
+
+ // (d)+(e) the TERMINAL ARTIFACT: the argv handed to llama-server after the restart.
+ const args = restarted.launchArgs();
+ expect(argValue(args, '--cache-type-k')).toBe('q8_0');
+ expect(argValue(args, '--cache-type-v')).toBe('q8_0');
+ expect(argValue(args, '--flash-attn')).toBe('on');
+ // Belt-and-braces: f16 never leaks into the KV flags.
+ expect(args).not.toContain('f16');
+ });
+
+ it('no pin: conservative → balanced mode → RESTART launches with f16 (no KV flags) — the intended default', async () => {
+ // A user who never pinned KV: the mode preset is the source of truth. Go through
+ // conservative (q8_0) first so the balanced switch has to actually flip KV back to
+ // f16 — an unpinned field follows the mode all the way through the reload.
+ const first = await freshService();
+ await applySettings(first, { performanceMode: 'conservative' });
+ await applySettings(first, { performanceMode: 'balanced' });
+
+ const restarted = await freshService();
+ const args = restarted.launchArgs();
+ // balanced = f16 → no --cache-type-k/-v at all, and flash-attn stays off.
+ expect(args).not.toContain('--cache-type-k');
+ expect(args).not.toContain('--cache-type-v');
+ expect(args).not.toContain('--flash-attn');
+ });
+
+ it('conservative mode (preset q8_0) with no pin → RESTART launches with q8_0', async () => {
+ // Guards that the reload path carries a preset-derived (not user-pinned) q8_0 too.
+ const first = await freshService();
+ await applySettings(first, { performanceMode: 'conservative' });
+
+ const restarted = await freshService();
+ const args = restarted.launchArgs();
+ expect(argValue(args, '--cache-type-k')).toBe('q8_0');
+ expect(argValue(args, '--flash-attn')).toBe('on');
+ });
+});
diff --git a/src/main/llm/__tests__/settings-merge.test.ts b/src/main/llm/__tests__/settings-merge.test.ts
new file mode 100644
index 00000000..5c61453a
--- /dev/null
+++ b/src/main/llm/__tests__/settings-merge.test.ts
@@ -0,0 +1,99 @@
+/**
+ * Regression tests for the "explicit KV-cache choice silently reverts to f16" bug.
+ *
+ * Root cause: a performance-mode preset (MODE_PRESETS[mode], where balanced/extreme =
+ * { kvCacheType: 'f16', flashAttn: false }) UNCONDITIONALLY overwrote the user's
+ * kvCacheType/flashAttn/ctxSize and persisted the clobber. Two writers (the mode
+ * preset + the granular KV control) owned the same keys with no merge; the preset
+ * always won. The SetupPanel re-sends `{ performanceMode }` whenever a mode is
+ * (re)picked, so an explicit q8_0 died the moment a mode was reapplied.
+ *
+ * The fix is a pure MERGE (applyModePreset): the preset fills ONLY the fields the user
+ * has not explicitly pinned. These tests lock: an explicit q8_0 survives a preset;
+ * unpinned fields still take the preset; and a persisted pin survives a plain restart.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { applyModePreset, MODE_PRESETS, type PresetState, type PresetField } from '../settings-math';
+
+const NONE = new Set();
+
+describe('applyModePreset — the explicit-KV-reverts bug', () => {
+ it('THE BUG: an explicitly-pinned q8_0 is PRESERVED when balanced is applied (not reset to f16)', () => {
+ // User set kvCacheType='q8_0' granularly (pinned), then picks/reapplies balanced.
+ const current: PresetState = { ctxSize: 16384, kvCacheType: 'q8_0', flashAttn: true };
+ const merged = applyModePreset(current, 'balanced', new Set(['kvCacheType', 'flashAttn']));
+ expect(merged.kvCacheType).toBe('q8_0'); // NOT reverted to the balanced preset's f16
+ expect(merged.flashAttn).toBe(true);
+ });
+
+ it('extreme mode also preserves a pinned q8_0 (both non-conservative presets are f16)', () => {
+ const current: PresetState = { ctxSize: 65536, kvCacheType: 'q8_0', flashAttn: true };
+ const merged = applyModePreset(current, 'extreme', new Set(['kvCacheType', 'flashAttn']));
+ expect(merged.kvCacheType).toBe('q8_0');
+ });
+
+ it('applies the preset to fields the user has NOT pinned (ctxSize follows balanced)', () => {
+ // Only kvCacheType is pinned — ctxSize + flashAttn take the balanced preset.
+ const current: PresetState = { ctxSize: 8192, kvCacheType: 'q8_0', flashAttn: true };
+ const merged = applyModePreset(current, 'balanced', new Set(['kvCacheType']));
+ expect(merged.ctxSize).toBe(MODE_PRESETS.balanced.ctxSize); // 16384, unpinned → preset
+ expect(merged.flashAttn).toBe(MODE_PRESETS.balanced.flashAttn); // false, unpinned → preset
+ expect(merged.kvCacheType).toBe('q8_0'); // pinned → kept
+ });
+
+ it('with NOTHING pinned, a preset fully applies (behavior-neutral for the non-conflicting case)', () => {
+ const current: PresetState = { ctxSize: 999, kvCacheType: 'q4_0', flashAttn: false };
+ const merged = applyModePreset(current, 'balanced', NONE);
+ expect(merged).toEqual(MODE_PRESETS.balanced);
+ });
+
+ it('conservative applied with nothing pinned yields the conservative preset (q8_0)', () => {
+ const merged = applyModePreset({ ctxSize: 16384, kvCacheType: 'f16', flashAttn: false }, 'conservative', NONE);
+ expect(merged).toEqual(MODE_PRESETS.conservative);
+ });
+
+ it('a pinned ctxSize survives while KV follows the preset (independent per-field merge)', () => {
+ const current: PresetState = { ctxSize: 40000, kvCacheType: 'f16', flashAttn: false };
+ const merged = applyModePreset(current, 'conservative', new Set(['ctxSize']));
+ expect(merged.ctxSize).toBe(40000); // pinned → kept
+ expect(merged.kvCacheType).toBe(MODE_PRESETS.conservative.kvCacheType); // unpinned → q8_0
+ expect(merged.flashAttn).toBe(MODE_PRESETS.conservative.flashAttn);
+ });
+
+ it('does not mutate the input state (pure merge returns a fresh object)', () => {
+ const current: PresetState = { ctxSize: 16384, kvCacheType: 'q8_0', flashAttn: true };
+ const before = { ...current };
+ applyModePreset(current, 'balanced', new Set(['kvCacheType']));
+ expect(current).toEqual(before);
+ });
+});
+
+describe('boot/restart path — a persisted pin survives a plain restart', () => {
+ // Simulates the constructor: read persisted settings (values + userExplicit pin-set)
+ // off disk, then a subsequent mode re-pick (the SetupPanel resend). This is the
+ // "every restart" path — before the fix, the bare { performanceMode } patch let the
+ // preset reclobber the persisted q8_0.
+ it('a persisted explicit q8_0 + pin survives a restart followed by a balanced re-pick', () => {
+ // What was on disk from a prior session where the user pinned q8_0:
+ const persisted = {
+ ctxSize: 8192,
+ kvCacheType: 'q8_0' as const,
+ flashAttn: true,
+ userExplicit: ['kvCacheType', 'flashAttn'] as PresetField[],
+ };
+ // Constructor restores state + pin-set from disk:
+ const state: PresetState = { ctxSize: persisted.ctxSize, kvCacheType: persisted.kvCacheType, flashAttn: persisted.flashAttn };
+ const pinned = new Set(persisted.userExplicit);
+ // SetupPanel resends the saved mode on load → applyModePreset runs with the pins:
+ const merged = applyModePreset(state, 'balanced', pinned);
+ expect(merged.kvCacheType).toBe('q8_0'); // the pin persisted the choice across restart
+ });
+
+ it('without a pin, a restart + mode re-pick correctly takes the preset default', () => {
+ // A user who never pinned KV: the mode preset is the source of truth, as intended.
+ const state: PresetState = { ctxSize: 16384, kvCacheType: 'f16', flashAttn: false };
+ const merged = applyModePreset(state, 'conservative', new Set());
+ expect(merged.kvCacheType).toBe('q8_0'); // conservative preset applies (nothing pinned)
+ });
+});
diff --git a/src/main/llm/settings-math.ts b/src/main/llm/settings-math.ts
index 86ade9dc..208947ed 100644
--- a/src/main/llm/settings-math.ts
+++ b/src/main/llm/settings-math.ts
@@ -18,6 +18,42 @@ export const MODE_PRESETS: Record,
+): PresetState {
+ const p = MODE_PRESETS[mode];
+ return {
+ ctxSize: userExplicit.has('ctxSize') ? current.ctxSize : p.ctxSize,
+ kvCacheType: userExplicit.has('kvCacheType') ? current.kvCacheType : p.kvCacheType,
+ flashAttn: userExplicit.has('flashAttn') ? current.flashAttn : p.flashAttn,
+ };
+}
+
/** The tunable inference state that the sampling/launch math reads. Matches the
* private fields on LLMService so it can pass `this` straight in. */
export interface SamplingState {
@@ -49,6 +85,55 @@ export interface LaunchState {
batchSize: number | undefined;
}
+/** The fully-resolved launch inputs the arg-builder needs. `ctxSize` is already the
+ * EFFECTIVE (RAM-clamped) value — the clamp itself is impure (reads host RAM + weight
+ * file sizes) and stays in LLMService; everything here is a pure function of these
+ * inputs so the terminal artifact (the args array handed to llama-server) is testable. */
+export interface LaunchArgsInput {
+ modelPath: string;
+ mmProjPath: string; // empty string = text-only (no --mmproj)
+ port: number;
+ effectiveCtxSize: number; // already RAM-clamped
+ gpuLayers: number;
+ flashAttn: boolean;
+ kvCacheType: KvCacheType;
+ threads: number | undefined;
+ batchSize: number | undefined;
+}
+
+/** Build the exact argv passed to `llama-server`. Pure: same inputs → same args, no I/O.
+ * This is the single source of truth for launch args, so the KV-cache / flash-attn
+ * coupling (a quantized KV cache forces `--flash-attn on` and sets both -k/-v cache
+ * types) is asserted directly against what the engine actually receives. */
+export function buildLaunchArgs(i: LaunchArgsInput): string[] {
+ const args = ['-m', i.modelPath];
+ if (i.mmProjPath) {
+ args.push('--mmproj', i.mmProjPath);
+ }
+ args.push(
+ '--port', String(i.port),
+ '--host', '127.0.0.1',
+ '-c', String(i.effectiveCtxSize),
+ '-ngl', String(i.gpuLayers),
+ );
+ // FlashAttention: faster + lower memory. Required for a quantized KV cache.
+ if (i.flashAttn || i.kvCacheType !== 'f16') {
+ args.push('--flash-attn', 'on');
+ }
+ // Quantized KV cache (q8_0/q4_0) shrinks the per-token memory footprint — the single
+ // biggest lever against memory-overcommit freezes on big contexts.
+ if (i.kvCacheType !== 'f16') {
+ args.push('--cache-type-k', i.kvCacheType, '--cache-type-v', i.kvCacheType);
+ }
+ if (typeof i.threads === 'number') {
+ args.push('-t', String(i.threads));
+ }
+ if (typeof i.batchSize === 'number') {
+ args.push('-b', String(i.batchSize));
+ }
+ return args;
+}
+
/** Whether a settings patch changes any LAUNCH-TIME arg (context, KV-cache type,
* flash-attn, GPU layers, threads, batch) and therefore needs a server respawn.
* `modeChanged` forces true (a mode switch always rewrites launch args). A field
From 23c76fed825cfca91b70e362ebb8a6d8470770a9 Mon Sep 17 00:00:00 2001
From: User
Date: Fri, 10 Jul 2026 08:56:54 +0530
Subject: [PATCH 003/334] fix(chat): image-gen params single-source + reasoning
survives reload
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two data-layer/presentation drift bugs (§A). (1) Image composer kept local
copies of the active model + steps: the dropdown never wrote through to
setActiveModalModel (diverged from the Active-models panel) and a model-change
effect stomped the user's steps ("showed 10, generated 28"). Now the dropdown
binds to the owning source and steps/size/seed/negative persist per-model
(override ?? default resolver). (2) Reasoning/thinking showed live but was
never persisted, so it vanished on conversation remap — now carried through the
message context blob and restored by mapRagMessages. Terminal-artifact tests:
a real mount asserts the generateImage payload carries the user's
steps + model; a real DB round-trip asserts reasoning restores. Red-proven.
Co-Authored-By: Claude Opus 4.8
---
src/renderer/src/components/MemoryChat.tsx | 147 +++++++++++---
.../__tests__/MemoryChat.image.test.tsx | 185 ++++++++++++++++++
.../lib/__tests__/image-params-wiring.test.ts | 162 +++++++++++++++
.../src/lib/__tests__/image-params.test.ts | 111 +++++++++++
.../lib/__tests__/message-persistence.test.ts | 59 ++++++
src/renderer/src/lib/image-params.ts | 90 +++++++++
src/renderer/src/lib/message-persistence.ts | 48 +++++
7 files changed, 772 insertions(+), 30 deletions(-)
create mode 100644 src/renderer/src/components/__tests__/MemoryChat.image.test.tsx
create mode 100644 src/renderer/src/lib/__tests__/image-params-wiring.test.ts
create mode 100644 src/renderer/src/lib/__tests__/image-params.test.ts
create mode 100644 src/renderer/src/lib/__tests__/message-persistence.test.ts
create mode 100644 src/renderer/src/lib/image-params.ts
create mode 100644 src/renderer/src/lib/message-persistence.ts
diff --git a/src/renderer/src/components/MemoryChat.tsx b/src/renderer/src/components/MemoryChat.tsx
index a61146ba..97044b6d 100644
--- a/src/renderer/src/components/MemoryChat.tsx
+++ b/src/renderer/src/components/MemoryChat.tsx
@@ -10,8 +10,9 @@ import { VoiceBubble, stopAllVoicePlayback } from './VoiceBubble';
import { SkillsPanel } from './SkillsPanel';
import { SettingsPanel } from './SettingsPanel';
import { ModelPicker } from './ModelPicker';
-import { standardModelDefaults } from '@offgrid/core/shared/image-defaults';
+import { resolveImageParams, setOverride, type ImageParamStore } from '@renderer/lib/image-params';
import { looksLikeImageRequest, cleanImagePrompt } from '@renderer/lib/image-intent';
+import { buildAssistantContext, readReasoning } from '@renderer/lib/message-persistence';
import { Button } from '@renderer/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from '@renderer/components/ui/dropdown-menu';
@@ -128,6 +129,8 @@ function mapRagMessages(raw: any[]): ChatMessage[] {
role: m.role as 'user' | 'assistant',
content: m.content,
context: ctx,
+ // Reasoning rides in the context blob so the "Thinking" block survives reload.
+ reasoning: readReasoning(ctx),
toolCalls: Array.isArray(ctx?.toolCalls) ? ctx.toolCalls : undefined,
image: ctx?.image ? `ogcapture://${ctx.image}` : undefined,
imagePath: ctx?.image,
@@ -250,6 +253,12 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
const [imgStrength, setImgStrength] = useState(0.6);
const [imgModels, setImgModels] = useState([]);
const [imgModel, setImgModel] = useState('');
+ // Per-model steps/size overrides. This is the ONE persisted owner of those two
+ // params — the composer and (future) a Settings > Image section both read/write
+ // it. Persisted via saveSetting('imageParams', …). A value here means the user
+ // pinned it; absence means "track the model default". Resolved through the pure
+ // resolveImageParams so a model change never clobbers a user override.
+ const [imgParamStore, setImgParamStore] = useState({});
const [activeStyle, setActiveStyle] = useState(null);
const [styleThumbs, setStyleThumbs] = useState>({});
const [genThumbsBusy, setGenThumbsBusy] = useState(false);
@@ -299,6 +308,14 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
if (typeof s.composerConnectorsOn === 'boolean') setConnectorsOn(s.composerConnectorsOn);
if (typeof s.composerThinking === 'boolean') setThinkingEnabled(s.composerThinking);
if (typeof s.composerVoiceMode === 'boolean') setVoiceMode(s.composerVoiceMode);
+ // Image-composer params: per-model steps/size overrides + the global
+ // seed/negative/strength/style. These are persisted so they survive a
+ // remount (they used to reset every mount).
+ if (s.imageParams && typeof s.imageParams === 'object') setImgParamStore(s.imageParams as ImageParamStore);
+ if (typeof s.imgSeed === 'string') setImgSeed(s.imgSeed);
+ if (typeof s.imgNegative === 'string') setImgNegative(s.imgNegative);
+ if (typeof s.imgStrength === 'number') setImgStrength(s.imgStrength);
+ if (typeof s.imgStyle === 'string' || s.imgStyle === null) setActiveStyle((s.imgStyle as string | null) ?? null);
}
} catch (e) { console.error('Failed to load composer prefs', e); }
finally { prefsLoaded.current = true; }
@@ -309,6 +326,13 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('composerConnectorsOn', connectorsOn); }, [connectorsOn]);
useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('composerThinking', thinkingEnabled); }, [thinkingEnabled]);
useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('composerVoiceMode', voiceMode); }, [voiceMode]);
+ // Persist the global image-composer params (per-model steps/size live in the
+ // store, saved on change). Guarded by prefsLoaded so the initial load doesn't
+ // echo back an empty default.
+ useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgSeed', imgSeed); }, [imgSeed]);
+ useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgNegative', imgNegative); }, [imgNegative]);
+ useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgStrength', imgStrength); }, [imgStrength]);
+ useEffect(() => { if (prefsLoaded.current) void window.api.saveSetting('imgStyle', activeStyle); }, [activeStyle]);
const [autoPlayId, setAutoPlayId] = useState(null); // assistant reply to auto-speak once
const [speakingId, setSpeakingId] = useState(null);
const [speakLoadingId, setSpeakLoadingId] = useState(null);
@@ -436,6 +460,35 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
},
} as Components);
+ // Bind the composer's image model to the ONE owner of that state: the active
+ // modal model (what the Active-models panel / ModelPicker writes via
+ // setActiveModalModel). We READ it from imageGenStatus().active and mirror it
+ // locally for the dropdown; we never hold a divergent latched copy. Called on
+ // mount and whenever the model picker closes, so a change made there flows back
+ // into the composer. Falls back to a sensible default only when nothing is active.
+ const refreshImageModel = useCallback(async () => {
+ try {
+ const s = await window.api.imageGenStatus?.();
+ if (!s) return;
+ setImageAvailable(!!s.available);
+ const models = s.models || [];
+ setImgModels(models);
+ // Skip the parked/slow Core ML dir (it would otherwise win on an "sdxl" name
+ // match and default the composer to a non-distilled model).
+ const usable = models.filter(m => !/coreml/i.test(m));
+ const preferred = usable.find(m => /dreamshaper/i.test(m)) || usable.find(m => /lightning|turbo/i.test(m)) || usable.find(m => /z[-_]?image/i.test(m)) || usable.find(m => /sdxl|xl/i.test(m)) || usable[0] || models[0] || '';
+ setImgModel(s.active || preferred);
+ } catch { /* engine may be down; leave prior state */ }
+ }, []);
+ // When the model picker closes it may have changed the active image model
+ // (setActiveModalModel). Re-read so the composer reflects the single source of
+ // truth rather than a stale mirror.
+ const prevPickerOpen = useRef(modelPickerOpen);
+ useEffect(() => {
+ if (prevPickerOpen.current && !modelPickerOpen) void refreshImageModel();
+ prevPickerOpen.current = modelPickerOpen;
+ }, [modelPickerOpen, refreshImageModel]);
+
// Load conversations on mount; probe image gen; load projects for scoping.
useEffect(() => {
void (async () => {
@@ -451,19 +504,7 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
try { setConvMessages(first.id, mapRagMessages(await window.api.getRagMessages(first.id))); } catch { setConvMessages(first.id, []); }
}
})();
- window.api.imageGenStatus?.().then((s: { available: boolean; models?: string[]; active?: string | null }) => {
- setImageAvailable(!!s?.available);
- const models = s?.models || [];
- setImgModels(models);
- // Default the picker to the ACTIVE image model (what the Active-models panel
- // shows, e.g. DreamShaper) so the composer never mismatches it. Fall back to a
- // preference that skips the parked/slow Core ML dir (it would otherwise win on
- // an "sdxl" name match and default the composer to a non-distilled model).
- const usable = models.filter(m => !/coreml/i.test(m));
- const preferred = usable.find(m => /dreamshaper/i.test(m)) || usable.find(m => /lightning|turbo/i.test(m)) || usable.find(m => /z[-_]?image/i.test(m)) || usable.find(m => /sdxl|xl/i.test(m)) || usable[0] || models[0] || '';
- const active = s?.active || preferred;
- setImgModel(prev => prev || active);
- }).catch(() => {});
+ void refreshImageModel();
window.api.listProjects?.().then((p: ProjectLite[]) => setProjects(p || [])).catch(() => {});
window.api.styleThumbs?.().then((t: Record) => setStyleThumbs(t || {})).catch(() => {});
}, []);
@@ -489,15 +530,47 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
}
}, [genThumbsBusy, styleThumbs]);
- // Seed the size + steps controls with the model's sensible defaults (from the
- // SINGLE shared source of truth the main process also uses — so the two layers
- // can never drift; a stale copy of this logic once defaulted turbo models to 4
- // steps -> rainbow artifacts). The user can then adjust Size/Steps directly.
+ // Resolve the size + steps controls for the current model: a per-model user
+ // OVERRIDE (persisted in imgParamStore) wins; otherwise fall back to the model's
+ // default from the SINGLE shared source of truth the main process also uses (so
+ // the two layers can't drift — a stale copy once defaulted turbo models to 4
+ // steps -> rainbow artifacts). This never clobbers a value the user typed: the
+ // resolver reads the override for whichever model is now selected. Depends on the
+ // store too, so persisted overrides apply once they load.
useEffect(() => {
if (!imgModel) return;
- const d = standardModelDefaults(imgModel);
- setImgSize(d.defaultSize);
- setImgSteps(d.defaultSteps);
+ const { steps, size } = resolveImageParams(imgModel, imgParamStore);
+ setImgSize(size);
+ setImgSteps(steps);
+ }, [imgModel, imgParamStore]);
+
+ // Composer image-model dropdown: write through to the SAME owner ModelPicker
+ // uses (setActiveModalModel), then mirror locally for immediate UI. This is what
+ // keeps the composer and the Active-models panel from silently disagreeing about
+ // which model runs — one source of truth.
+ const chooseImageModel = useCallback((value: string) => {
+ setImgModel(value);
+ void window.api.setActiveModalModel?.('image', value);
+ }, []);
+ // Steps/size edits persist as a per-model override so they survive a remount and
+ // a model switch (setOverride is pure; a value == the model default clears it).
+ const setStepsOverride = useCallback((value: number) => {
+ setImgSteps(value);
+ if (!imgModel) return;
+ setImgParamStore(prev => {
+ const next = setOverride(prev, imgModel, 'steps', value);
+ void window.api.saveSetting('imageParams', next);
+ return next;
+ });
+ }, [imgModel]);
+ const setSizeOverride = useCallback((value: number) => {
+ setImgSize(value);
+ if (!imgModel) return;
+ setImgParamStore(prev => {
+ const next = setOverride(prev, imgModel, 'size', value);
+ void window.api.saveSetting('imageParams', next);
+ return next;
+ });
}, [imgModel]);
const activeProjectName = projects.find(p => p.id === activeProjectId)?.name ?? null;
@@ -832,8 +905,16 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
return;
}
const answer = tr?.answer || 'No response returned.';
+ // Capture the reasoning that streamed onto this turn so it can ride the
+ // persisted context blob (React-only state would vanish on reload).
+ let toolReasoning: string | undefined;
// Finalize the streamed placeholder in place (never append a second bubble).
- setConvMessages(convId, prev => prev.map(m => (m.id === toolStreamId ? { ...m, content: answer, context, toolCalls, activity: undefined, streaming: false } : m)));
+ setConvMessages(convId, prev => prev.map(m => {
+ if (m.id !== toolStreamId) return m;
+ toolReasoning = m.reasoning;
+ return { ...m, content: answer, context, toolCalls, activity: undefined, streaming: false };
+ }));
+ const toolCtxWithReasoning = buildAssistantContext(toolCtx, { reasoning: toolReasoning });
if (voiceMode) setAutoPlayId(toolStreamId);
// Deferred image generation: the tool loop only RECORDS the prompt (it never
// generates inline, which would evict the LLM). Generate + attach here AFTER
@@ -842,14 +923,14 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
try {
const img = await window.api.generateImage({ prompt: tr.imageRequest.prompt, conversationId: convId, projectId: activeProjectId });
setConvMessages(convId, prev => prev.map(m => (m.id === toolStreamId ? { ...m, image: img.dataUrl, imagePath: img.path } : m)));
- try { await window.api.addRagMessage(convId, 'assistant', answer, { ...(toolCtx ?? {}), image: img.path }); } catch (_) { /* ignore */ }
+ try { await window.api.addRagMessage(convId, 'assistant', answer, { ...(toolCtxWithReasoning ?? {}), image: img.path }); } catch (_) { /* ignore */ }
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
- if (!/cancel/i.test(msg)) { try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtx); } catch (_) { /* ignore */ } }
+ if (!/cancel/i.test(msg)) { try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtxWithReasoning); } catch (_) { /* ignore */ } }
}
return;
}
- try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtx); } catch (_) { /* ignore */ }
+ try { await window.api.addRagMessage(convId, 'assistant', answer, toolCtxWithReasoning); } catch (_) { /* ignore */ }
return;
}
@@ -900,7 +981,13 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
const priorVariants = pendingVariantsRef.current;
pendingVariantsRef.current = null;
const allVariants = priorVariants ? [...priorVariants, assistantContent] : undefined;
- setConvMessages(convId, prev => prev.map(m => (m.id === streamId ? { ...m, content: assistantContent, context: result.context, streaming: false, variants: allVariants, variantIndex: allVariants ? allVariants.length - 1 : undefined } : m)));
+ // Capture the streamed reasoning so it rides the persisted context blob.
+ let ragReasoning: string | undefined;
+ setConvMessages(convId, prev => prev.map(m => {
+ if (m.id !== streamId) return m;
+ ragReasoning = m.reasoning;
+ return { ...m, content: assistantContent, context: result.context, streaming: false, variants: allVariants, variantIndex: allVariants ? allVariants.length - 1 : undefined };
+ }));
const art = parseArtifact(assistantContent);
if (art) {
// Inline-first: don't force the canvas open — the user opens the live
@@ -910,7 +997,7 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
}
if (voiceMode) setAutoPlayId(streamId);
try {
- await window.api.addRagMessage(convId, 'assistant', assistantContent, result.context);
+ await window.api.addRagMessage(convId, 'assistant', assistantContent, buildAssistantContext(result.context, { reasoning: ragReasoning }));
} catch (e) {
console.error('Failed to persist assistant message:', e);
}
@@ -2043,14 +2130,14 @@ export function MemoryChat({ onNavigateToMemory, onNavigateToChat, onNavigateToE
{imgModels.length > 1 && (
)}
→ newline; the second
open tag → ' '; [ \t]+ collapse keeps the space
+ const out = htmlToText('
one
two
');
+ expect(out).toBe('one\n two');
+ });
+
+ it('collapses 3+ blank lines down to a double newline', () => {
+ // many block closes in a row produce many newlines → collapsed to \n\n
+ const out = htmlToText('
```
@@ -686,11 +736,13 @@ App.tsx
## Iconography
### Usage Rules
+
- Sparse and functional—icons appear for navigation, actions, and metadata counts
- They never decorate
- Small and muted by default, brightening on hover or when active
### Sizing
+
```css
w-4 h-4 /* Inline icons, metadata */
w-5 h-5 /* Interactive icons, buttons */
@@ -703,6 +755,7 @@ w-8 h-8 /* Empty state icons */
## Content Guidelines
### Truncation
+
Long titles truncate with ellipsis. Long descriptions clamp to 2-3 lines.
```css
@@ -712,9 +765,11 @@ line-clamp-3 /* 3 lines max */
```
### Number Formatting
+
Large numbers use locale formatting (commas/periods). Numbers in tables align with tabular figures.
### Timestamps
+
Relative when recent: "2 hours ago" not "1/20/2026, 2:00 PM" for recent items. Full timestamps for older content.
---
@@ -722,6 +777,7 @@ Relative when recent: "2 hours ago" not "1/20/2026, 2:00 PM" for recent items. F
## Voice & Tone
The interface speaks quietly:
+
- Labels are terse: "Memories" not "Your Saved Memories"
- Descriptions are brief
- Empty states are encouraging but not chatty
@@ -751,6 +807,7 @@ Before considering a screen complete:
## Allowed vs Not Allowed
### ✓ Allowed
+
- Monochromatic grays from near-black to white
- Light font weights for large text
- Uppercase labels with wide letter-spacing
@@ -767,6 +824,7 @@ Before considering a screen complete:
- Muted categorical colors in the graph only
### ✗ Not Allowed
+
- Accent colors for emphasis or status
- Gradients anywhere
- Bold text for emphasis
@@ -786,18 +844,22 @@ Before considering a screen complete:
## Badge & Tag Reference
### Standard Badge
+
```tsx
-className="bg-neutral-800 text-neutral-400 border border-neutral-700"
+className = 'bg-neutral-800 text-neutral-400 border border-neutral-700'
```
### Count Pill
+
```tsx
-className="px-2 py-0.5 rounded-full text-[10px] font-medium bg-neutral-700 text-neutral-300"
+className = 'px-2 py-0.5 rounded-full text-[10px] font-medium bg-neutral-700 text-neutral-300'
```
### Type Tag
+
```tsx
-className="px-2 py-0.5 text-[10px] font-semibold uppercase rounded bg-neutral-800 text-neutral-400 border border-neutral-700"
+className =
+ 'px-2 py-0.5 text-[10px] font-semibold uppercase rounded bg-neutral-800 text-neutral-400 border border-neutral-700'
```
---
@@ -805,18 +867,23 @@ className="px-2 py-0.5 text-[10px] font-semibold uppercase rounded bg-neutral-80
## Input & Button Reference
### Text Input
+
```tsx
-className="bg-neutral-900/80 border border-neutral-800 text-white placeholder-neutral-600 focus:outline-none focus:border-neutral-600"
+className =
+ 'bg-neutral-900/80 border border-neutral-800 text-white placeholder-neutral-600 focus:outline-none focus:border-neutral-600'
```
### Primary Button
+
```tsx
-className="bg-neutral-800 border border-neutral-700 text-white hover:bg-neutral-700 hover:border-neutral-600"
+className =
+ 'bg-neutral-800 border border-neutral-700 text-white hover:bg-neutral-700 hover:border-neutral-600'
```
### Icon Button
+
```tsx
-className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700"
+className = 'p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover:bg-neutral-700'
```
---
@@ -824,6 +891,7 @@ className="p-2 rounded-lg bg-neutral-800 text-neutral-400 hover:text-white hover
## Empty State Reference
Centered layout with muted icon and text:
+
```tsx
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 66b068ba..ba689eb7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -87,9 +87,10 @@ jobs:
with:
ref: ${{ needs.version.outputs.sha }} # the exact bumped commit
fetch-depth: 0
- lfs: true # resources/bin/* (llama-server, sd-cli, whisper, ffmpeg, dylibs)
- # are Git LFS — without this CI bundles pointer stubs and every
- # runtime spawn fails with ENOEXEC.
+ lfs:
+ true # resources/bin/* (llama-server, sd-cli, whisper, ffmpeg, dylibs)
+ # are Git LFS — without this CI bundles pointer stubs and every
+ # runtime spawn fails with ENOEXEC.
- name: Pull LFS binaries
run: git lfs pull
# Rebuild the chat engine from source with a PINNED macOS deployment target.
diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml
index 2e938f38..ed85c8a7 100644
--- a/.github/workflows/windows-build.yml
+++ b/.github/workflows/windows-build.yml
@@ -24,9 +24,10 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
- lfs: false # the repo's LFS binaries are macOS-only; Windows runtimes
- # come from fetch-win-binaries.ps1, so don't pull ~235MB of
- # dylibs we won't ship.
+ lfs:
+ false # the repo's LFS binaries are macOS-only; Windows runtimes
+ # come from fetch-win-binaries.ps1, so don't pull ~235MB of
+ # dylibs we won't ship.
- uses: actions/setup-node@v4
with:
diff --git a/.prettierignore b/.prettierignore
index 9c6b791d..95aa0f89 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -4,3 +4,15 @@ pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
+
+# Agent worktrees + local Claude config: copies of the repo / ephemeral state, never ours to format.
+.claude
+
+# Vendored, minified third-party libraries served verbatim — reformatting them is meaningless and bloats the diff.
+resources/artifacts
+**/*.min.js
+**/*.min.css
+
+# Local, non-source scratch files.
+.offgrid
+pr-targets.local.md
diff --git a/CLAUDE.md b/CLAUDE.md
index d98963bf..4f42319c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -10,7 +10,7 @@ Full design doc: **`docs/DESIGN.md`**. The essentials, which OVERRIDE any mobile
- **Typeface: Menlo** (monospace) everywhere — terminal/brutalist.
- **Accent: emerald** — `#34D399` (dark) / `#059669` (light). THE accent for primary actions, active states, links, success. (Tailwind `green-500/400` is an acceptable stand-in but prefer the exact tokens.)
- **Base:** black / `#0A0A0A` + white; neutral grays for surfaces/borders/text tiers. Flat, sharp, dense.
-- Tokens: `@offgrid/design`. Brand canon: `mobile/docs/design/DESIGN_PHILOSOPHY_SYSTEM.md` (brand only — desktop *layout* follows `docs/DESIGN.md`, desktop-first).
+- Tokens: `@offgrid/design`. Brand canon: `mobile/docs/design/DESIGN_PHILOSOPHY_SYSTEM.md` (brand only — desktop _layout_ follows `docs/DESIGN.md`, desktop-first).
- Real brand logos (Simple Icons), no decorative tiles behind them; no gradients; no emojis in the UI.
### Use the screen real estate — desktop density rules
@@ -19,7 +19,7 @@ The window is WIDE. A list of cards/rows stretched edge-to-edge in a single colu
- **Multi-column responsive grids for collections.** Any list of comparable items (models, connectors, entities, meetings) is a grid that fills the width: `grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4`, not one full-width row each. A card's controls stay next to its content, never flung across empty space.
- **Tight, consistent spacing on a 4/8/12px scale.** Dense data UIs use narrow gutters (8-12px) and small padding, NOT the 16-24px editorial spacing. Body text ~12-14px, compact line-height. Flat and sharp, per the brand.
-- **Group, then separate.** Reduce gaps *within* a group (rows in a section) but keep clear separation *between* functional groups (filters vs data, "On this device" vs "Available"). Section headers over a wall of identical rows.
+- **Group, then separate.** Reduce gaps _within_ a group (rows in a section) but keep clear separation _between_ functional groups (filters vs data, "On this device" vs "Available"). Section headers over a wall of identical rows.
- **Progressive disclosure.** Secondary info and rarely-used controls go behind a detail panel / "…" / hover affordance — don't lay everything flat. Master list stays scannable; depth lives in the side panel or slide-over.
- **Sticky context.** Fix headers, tabs, filter bars, and column labels while the body scrolls, so context never scrolls away.
- **Finesse the interactions.** Every click gets a small micro-interaction — `transition-all duration-150`, `active:scale-95` on buttons, slide+fade (not abrupt mount) for panels/slide-overs. State changes animate; nothing pops in or out hard.
@@ -98,7 +98,7 @@ When iterating (a request, a fix, a tweak the user just confirmed), add a test t
E2E and screenshot/video capture (including the Provit capture harness) run the app on a **fresh temp `OFFGRID_USER_DATA` profile** and must use **synthetic demo data only — never a real profile, never upload real user data.**
-- **Seed with the demo script — BOTH seeders.** A blank profile is EMPTY, so any flow that *generates* (chat, especially the "All memory" scope) will error with **"Sorry, something went wrong…"** — a **profile/RAG gap, not a bug**: no seeded memory store means the memory path throws before streaming. There are TWO independent seeders and a flow may need both: **`OFFGRID_SEED=force`** → core `seedDemo` (`src/main/index.ts` → `dev-seed.ts`) seeds chats / knowledge / RAG memory (this is what "All memory" chat queries); **`OFFGRID_SEED_PRO=force`** → pro `seedProDemo` (`pro/main/index.ts` → `pro/main/dev-seed.ts`) seeds observations / entities / clipboard / replay frames. `npm run demo` sets both — use it (or set both env vars) for any capture that exercises chat/generation.
+- **Seed with the demo script — BOTH seeders.** A blank profile is EMPTY, so any flow that _generates_ (chat, especially the "All memory" scope) will error with **"Sorry, something went wrong…"** — a **profile/RAG gap, not a bug**: no seeded memory store means the memory path throws before streaming. There are TWO independent seeders and a flow may need both: **`OFFGRID_SEED=force`** → core `seedDemo` (`src/main/index.ts` → `dev-seed.ts`) seeds chats / knowledge / RAG memory (this is what "All memory" chat queries); **`OFFGRID_SEED_PRO=force`** → pro `seedProDemo` (`pro/main/index.ts` → `pro/main/dev-seed.ts`) seeds observations / entities / clipboard / replay frames. `npm run demo` sets both — use it (or set both env vars) for any capture that exercises chat/generation.
- **Model ports are single-owner.** Only one app instance can bind the model engine ports (`:7878` gateway, `:8439` llama-server, `:7879`). A running `npm run dev` will block a second capture instance's engine → generation errors that look like a bug but aren't. Free the ports (stop the dev app) before an e2e capture, or the recording exercises the error path only.
- **Never upload private data.** Screenshots/video from real-profile runs (real chats, memories) must not be published. Capture runs must be synthetic-seeded so anything that lands in a PR or the public showcase is demo data.
@@ -141,8 +141,9 @@ The `pro/` directory is a **git submodule** pointing at the private `desktop-pro
Design to abstractions, not concrete types. When implementations are interchangeable (model backends, TTS/STT engines, image/diffusion runtimes, connectors), the rest of the app depends on one service/interface — never branch on a concrete type in UI/stores (`if (engine === 'kokoro')`, `instanceof X`). Push the decision behind the abstraction; adding an implementation should need zero changes to callers. Normalize capability gaps inside the service, not the UI.
**Before every code edit, stop and ask three questions — out loud, in the response:**
+
1. **Is there enough here to abstract?** Two or more concrete cases handled by the same caller (text vs vision vs image models, Slack vs Mail surfaces, kokoro vs piper TTS) means there's a seam. One case, used once, is not — don't abstract speculatively (YAGNI).
-2. **Can we apply SOLID here?** Mainly: does one thing own one responsibility (SRP), and do callers depend on an interface rather than the concretes (DSP)? A `kind === 'x'` / `instanceof` / per-type `switch` in a caller — *especially in the renderer* — is the tell that the decision belongs behind a service.
+2. **Can we apply SOLID here?** Mainly: does one thing own one responsibility (SRP), and do callers depend on an interface rather than the concretes (DSP)? A `kind === 'x'` / `instanceof` / per-type `switch` in a caller — _especially in the renderer_ — is the tell that the decision belongs behind a service.
3. **Are we actually using it?** A mapping or rule must be defined ONCE and reused. If the same kind→modality map, the same routing `if`, or the same capability check appears in two layers (e.g. main process AND renderer), that's duplication, not abstraction — collapse it to a single source of truth and have both sides call it.
If the answer to 1 is "no", say so and write the simple version. If "yes", build the seam before piling on the second concrete branch — retrofitting after drift is the expensive path.
diff --git a/README.md b/README.md
index 552efe6a..56809c40 100644
--- a/README.md
+++ b/README.md
@@ -38,7 +38,6 @@
floor (95/90/93/96) is enforced on pre-push AND in CI (.github/workflows/ci.yml).
Refresh these when the floor rises — coverage only ever climbs. -->
-
Off Grid AI Mobile — the same on-device AI, on your phone ·
@@ -62,8 +61,8 @@ Three things in one app:
Claude/LM-Studio/Ollama with everything on-device.
2. **A gateway** — one local OpenAI-compatible API (`http://127.0.0.1:7878/v1`, no key)
for chat, vision, image, audio, and embeddings. Run it headless as just the gateway.
-3. **Off Grid Pro** — an always-on private layer that *sees* your work (screen → OCR),
- *remembers* it, helps you *reflect*, and *acts* with your approval. On-device, opt-in.
+3. **Off Grid Pro** — an always-on private layer that _sees_ your work (screen → OCR),
+ _remembers_ it, helps you _reflect_, and _acts_ with your approval. On-device, opt-in.
## A look inside
@@ -118,14 +117,14 @@ A full breakdown is in [docs/FEATURES.md](docs/FEATURES.md).
One local server (`http://127.0.0.1:7878`) speaks the OpenAI API:
-| Capability | Endpoint |
-|---|---|
-| Chat (text + vision) | `POST /v1/chat/completions` |
-| Text → Image | `POST /v1/images` (`/generations`, `/edits`) |
-| Speech → Text | `POST /v1/audio/transcriptions` |
-| Text → Speech | `POST /v1/audio/speech` |
-| Embeddings | `POST /v1/embeddings` |
-| Models | `GET /v1/models` |
+| Capability | Endpoint |
+| -------------------- | -------------------------------------------- |
+| Chat (text + vision) | `POST /v1/chat/completions` |
+| Text → Image | `POST /v1/images` (`/generations`, `/edits`) |
+| Speech → Text | `POST /v1/audio/transcriptions` |
+| Text → Speech | `POST /v1/audio/speech` |
+| Embeddings | `POST /v1/embeddings` |
+| Models | `GET /v1/models` |
```bash
curl http://127.0.0.1:7878/v1/chat/completions \
@@ -157,14 +156,14 @@ OFFGRID_SERVER_ONLY=1 npm run gateway
It's **self-sufficient** — manage models over HTTP, no UI required:
-| Action | Endpoint |
-|---|---|
-| List the catalog | `GET /v1/models/catalog` |
-| List installed | `GET /v1/models/installed` |
-| Active model per modality | `GET /v1/models/active` |
-| **Pull** a model | `POST /v1/models/pull` `{ "id": "…" }` → poll `GET /v1/models/pull/status?id=…` |
-| **Activate** a model | `POST /v1/models/activate` `{ "id": "…", "kind"?: "image\|speech\|transcription" }` |
-| **Delete** a model | `POST /v1/models/delete` `{ "id": "…" }` |
+| Action | Endpoint |
+| ------------------------- | ----------------------------------------------------------------------------------- |
+| List the catalog | `GET /v1/models/catalog` |
+| List installed | `GET /v1/models/installed` |
+| Active model per modality | `GET /v1/models/active` |
+| **Pull** a model | `POST /v1/models/pull` `{ "id": "…" }` → poll `GET /v1/models/pull/status?id=…` |
+| **Activate** a model | `POST /v1/models/activate` `{ "id": "…", "kind"?: "image\|speech\|transcription" }` |
+| **Delete** a model | `POST /v1/models/delete` `{ "id": "…" }` |
```bash
# pull a model into a headless gateway, then chat
diff --git a/ROADMAP_DESKTOP.md b/ROADMAP_DESKTOP.md
index 755ae0ab..b8069d17 100644
--- a/ROADMAP_DESKTOP.md
+++ b/ROADMAP_DESKTOP.md
@@ -2,19 +2,21 @@
The desktop product view of the plan. The shared, package-oriented plan lives in `../shared/ROADMAP.md`; this is the same arc re-cut around the **desktop app**, with honest current status. Sources folded in: `shared/ROADMAP.md`, root `CLAUDE.md`, `website/vision.md`.
-**North star:** a private, **local-first** layer for knowledge workers that **sees** your work, **remembers** it, helps you **reflect**, and **acts** on your behalf *with approval* — data stays on the device, intelligence runs on-device. Mission: *democratize intelligence for knowledge workers* (the broader vision: a proactive, private Personal AI OS for everyone — `website/vision.md`).
+**North star:** a private, **local-first** layer for knowledge workers that **sees** your work, **remembers** it, helps you **reflect**, and **acts** on your behalf _with approval_ — data stays on the device, intelligence runs on-device. Mission: _democratize intelligence for knowledge workers_ (the broader vision: a proactive, private Personal AI OS for everyone — `website/vision.md`).
Legend: ✅ done · 🟡 partial / in progress · ⬜ not started
---
## Phase 0 — Foundation ✅
+
- ✅ App shell, Off Grid design (Menlo / black / emerald), unified `userData` path, dock + tray icons
- ✅ Bundled local runtimes: `llama-server` (gemma-4 vision), `whisper.cpp`, `ffmpeg`, `sharp`
- ✅ Local LLM plumbing: grammar-constrained JSON, `enable_thinking:false`, single-flight init, port 8439
- 🟡 Full design pass on every screen (ongoing polish)
## Phase 1 — Capture spine ✅
+
- ✅ Focus loop via `get-windows` (no fragile per-helper TCC)
- ✅ Active-screen capture, **multi-monitor correct** (display under the focused window)
- ✅ Window-bounds crop → OCR → gemma distill → observations + entities
@@ -23,6 +25,7 @@ Legend: ✅ done · 🟡 partial / in progress · ⬜ not started
- ✅ Menu-bar tray (pause/recalibrate)
## Phase 2 — See & Remember surfaces 🟡
+
- ✅ **Day** — persisted journal (keep-old-while-updating) + time blocks
- ✅ **Entities** — CRM-for-everything (merge/hide/reassign/hierarchy, synthesis summaries)
- ✅ **Replay** — "movie of your day" (scrub/play/speed, busiest-day default, blanks filtered)
@@ -32,50 +35,60 @@ Legend: ✅ done · 🟡 partial / in progress · ⬜ not started
- ⬜ Replay polish (filmstrip, jump-to-entity, collapse gaps)
## Phase 3 — Meeting recorder 🟡 (building now)
+
- 🟡 **Meeting recorder** — **Google Meet + Zoom** for now (user has access to those two). Records **screen video + system/speaker audio (Electron loopback) + mic**, mixed into one webm; transcribes locally with bundled whisper; LLM title/summary/people; folds a summary into memory (surface=Meeting) so it hits Day/Reflect. Explicit start/stop + recording indicator (consent). FIRST CUT: `main/meetings.ts` + `MeetingsScreen.tsx` + `setDisplayMediaRequestHandler` loopback. Next: auto-arm on detecting a Meet/Zoom window, diarization, align transcript to the frame timeline, Teams later.
- ⬜ Encryption at rest for the memory DB
## Parked: remaining connectors (later)
+
3 connectors verified (Notion, Linear, Jira/Confluence). Parked for now, revisit after meetings:
+
- **Per-connector arg/prereq generalization** — generalize the cloudId resolver to any required id (teamId/workspaceId/orgId) so Vercel/Asana-style `list_*` tools work (Vercel `list_projects` needs `teamId`; partial code in `ingest.ts`).
- **Verify/finish**: Sentry, Vercel, Attio, ClickUp, Trello, GitHub (PAT), GitLab — onboard one-at-a-time with the disabled-by-default rule.
- **Slack** — skipped (bot must be invited per-channel; capture covers it). Adapter built but parked.
- **Google (Gmail/Cal/Drive)** — needs a GCP OAuth client (Testing mode); parked until the client exists. Capture covers it meanwhile.
## Phase 4 — The "Act" pillar ⬜ (foundation-first) ← NEXT
+
**Foundation (build first — everything authorized hangs off these):**
+
- ✅ **Identity anchor** — who the user is (name + email); Settings → "You"; `isMe()` for ownership (`main/identity.ts`)
- ✅ **Secure secret storage** — Electron `safeStorage`/Keychain; encrypted at rest, values never reach the renderer (`main/secrets.ts`)
- 🟡 **Consent / permission model** — per-connector enable/disable + the local-processing guarantee (connectors fetch, local model reasons). Full per-source consent UI + revoke still to deepen
- ✅ **Approval queue + audit log** — proposed → approve/reject → execute → logged; nothing acts without a logged approval (`crm/approvals.ts`, Approvals screen)
**Sources & connectors (MCP):**
+
- ✅ **Integrations / Connectors page** — add/enable/test MCP connectors (stdio + HTTP), discover tools, status (`main/mcp.ts` via `@modelcontextprotocol/sdk`, `ConnectorsScreen.tsx`). Approved tool calls execute through `callConnectorTool`
- ⬜ **Connector auth rule** — MCP connectors only for clean local-friendly auth: **DCR-OAuth** (Notion✓, Linear, Atlassian, Sentry…) or **token** (Slack, Airtable, Postgres…). **No central OAuth client.**
- ⬜ **Google (Gmail/Calendar) via SCREEN CAPTURE, not an OAuth client** (decided June 2026, fully-offline). Google MCP has no DCR → would need a registered GCP client = anti-offline; rejected. We already OCR Gmail/Calendar on screen — zero setup, nothing leaves the device.
-- ⬜ **Capture ↔ connector intelligence (source-of-truth priority).** Be smart: capture is the universal SIGNAL of interest ("you're on a Notion page / a Linear issue / company XYZ"); when a connector exists for what you're looking at, **pull the authoritative data from the connector (source of truth) instead of leaning on OCR/AX.** Connector data **takes priority** over captured/OCR data in synthesis + dedup, and lets us **throttle/skip OCR** for those apps (cheaper, cleaner). Capture tells us *what you care about*; the connector gives the *correct* version.
+- ⬜ **Capture ↔ connector intelligence (source-of-truth priority).** Be smart: capture is the universal SIGNAL of interest ("you're on a Notion page / a Linear issue / company XYZ"); when a connector exists for what you're looking at, **pull the authoritative data from the connector (source of truth) instead of leaning on OCR/AX.** Connector data **takes priority** over captured/OCR data in synthesis + dedup, and lets us **throttle/skip OCR** for those apps (cheaper, cleaner). Capture tells us _what you care about_; the connector gives the _correct_ version.
- ⬜ **Cross-source synthesis** — join email ↔ calendar event ↔ person ↔ project ↔ ticket into one entity, across capture + connectors
-- ⬜ **File system access (local file catalogue + auto-retrieval).** Index opt-in, **scoped folders** on-device — file metadata (path / name / type / size / mtime) + content embeddings into the RAG store (Phase 5) — so Off Grid knows *what files exist* and *what's in them*. Then **fetch the right file at the right time instead of making the user attach it**: capture/context signals *what you're working on* → the catalogue surfaces or auto-attaches the relevant document (meeting prep pulls the deck; a chat about project X pulls its docs; "the contract we discussed" resolves to the file). Local-only, per-folder enable/revoke under the consent model, incremental re-index via an fs watcher. The local-first peer of the source-of-truth-priority rule above: **capture tells us what you care about; the file system gives the actual document — no manual attach.**
+- ⬜ **File system access (local file catalogue + auto-retrieval).** Index opt-in, **scoped folders** on-device — file metadata (path / name / type / size / mtime) + content embeddings into the RAG store (Phase 5) — so Off Grid knows _what files exist_ and _what's in them_. Then **fetch the right file at the right time instead of making the user attach it**: capture/context signals _what you're working on_ → the catalogue surfaces or auto-attaches the relevant document (meeting prep pulls the deck; a chat about project X pulls its docs; "the contract we discussed" resolves to the file). Local-only, per-folder enable/revoke under the consent model, incremental re-index via an fs watcher. The local-first peer of the source-of-truth-priority rule above: **capture tells us what you care about; the file system gives the actual document — no manual attach.**
**Skills & action:**
+
- ⬜ **Skills framework** (`@offgrid/skills`) — trigger → action, on-device, model-driven
-- 🟡 **Intent → tool bridge** — action-item *detection* shipped (`crm/actions.ts`); next: propose a connector tool call (args from context) → approval queue
-- ⬜ **Day flips from retrospective → prospective** (KEY shift, user-confirmed June 2026). Today "Day" = what happened / what you did (a rear-view mirror). Once Calendar + tasks + email flow, Day gains an **"Ahead"** half = *what your day SHOULD look like*:
+- 🟡 **Intent → tool bridge** — action-item _detection_ shipped (`crm/actions.ts`); next: propose a connector tool call (args from context) → approval queue
+- ⬜ **Day flips from retrospective → prospective** (KEY shift, user-confirmed June 2026). Today "Day" = what happened / what you did (a rear-view mirror). Once Calendar + tasks + email flow, Day gains an **"Ahead"** half = _what your day SHOULD look like_:
- meetings as the skeleton (Calendar) → **prep per meeting** (past conversations + open items + docs about the attendees, from memory/connectors)
- **priorities** — action items + connector to-dos slotted into the day
- **protected deep-work blocks** + **overcommitment nudges**, informed by the Reflect/attention signal
- The "Behind" half (current Day) stays as the feedback loop that makes "Ahead" smarter.
- This is the tool→assistant pivot and the vision.md promise ("your devices already know your day; the briefing is ready, you didn't ask for it").
+ This is the tool→assistant pivot and the vision.md promise ("your devices already know your day; the briefing is ready, you didn't ask for it").
- ⬜ **Proactive delivery** — morning briefing, meeting prep ~20 min before, right person/right time (notifications), tuned by Reflect. Needs the connectors flowing (Phase 4 sources).
## Phase 5 — Intelligence depth 🟡
+
- ✅ **Projects + RAG + chat** over all memory + ingested docs — file upload (txt/md/PDF/DOCX/image/audio/video) → MiniLM embeddings + better-sqlite3 vector store; cited sources; "include captured memory" toggle spans uploads + everything captured (`@offgrid/rag`, `rag/index.ts`, `ProjectsScreen.tsx`)
- ⬜ Unified search
- ✅ **Models** — HF browser / provider abstraction / download manager (`@offgrid/models`, `ModelsScreen.tsx`)
- ⬜ **Expose a local model server** — surface the on-device runtimes (the bundled `llama-server` on 8439, whisper, embeddings) as a **local, OpenAI-compatible API endpoint** so other apps on the machine — and, over the mesh, other paired devices — can use Off Grid AI Desktop as their private inference backend. Off Grid Desktop becomes the household's on-device model server (no cloud, no API keys). Auth-gated + opt-in (off by default); ties into the consent model and the cross-device mesh (Phase 6).
### Phase 5b — The Off Grid chat as a local AI Studio (in progress, June 2026)
+
The Off Grid chat (`MemoryChat.tsx`) is becoming a full local-first studio — like Claude/LM Studio/Ollama, but everything on-device. Brand: brutalist/terminal (Menlo, emerald, flat). Done + planned:
+
- ✅ **Chat redesign** — brutalist composer, clickable example prompts, mode segmented control; light + dark.
- ✅ **On-device image generation** — `stable-diffusion.cpp` (`sd-cli`, Metal) in `resources/bin/sd`; txt2img + img2img; per-model size/steps/seed/negative-prompt; **live per-step preview + progress bar + ETA + Stop/cancel**; **lightbox** (zoom/download/delete); **artifacts gallery** of every generated image (`main/imagegen.ts`, `MemoryChat.tsx`).
- ✅ **Image models** — SDXL, **SDXL-Lightning** (few-step, default-fast), SD 1.5/2.1; per-model step/cfg/sampler auto-tuning; catalog curated toward latest models.
@@ -100,36 +113,42 @@ The Off Grid chat (`MemoryChat.tsx`) is becoming a full local-first studio — l
- 🟡 **Tool-calling loop + connectors in chat** — agentic loop done for **built-in local tools** (calculator, datetime), isolated + opt-in via the composer "+" menu; the model calls them mid-chat with results shown inline (`main/tools.ts`). Next: web search, read-URL, KB search, and **MCP connectors** in the picker.
- ✅ **Composer redesign** — Claude/ChatGPT-style: "+" menu (add image / generate / add-to-project / tools), Gemini-style **image style presets** (Sketch/Cinematic/Anime/…), centered welcome + example chips.
- ⬜ **Thinking controls** — toggle `enable_thinking` on the local reasoning model + collapsible reasoning blocks in messages.
-- ⬜ **Project cross-chat memory** — chats live *inside* a project; a chat can **reference information from other chats in the same project** (project-scoped retrieval across its conversations), while "All memory" remains available.
+- ⬜ **Project cross-chat memory** — chats live _inside_ a project; a chat can **reference information from other chats in the same project** (project-scoped retrieval across its conversations), while "All memory" remains available.
- ✅ **Model-settings control in chat** — **temperature** (per-request) + **context window** (re-spawns `llama-server` with new `--ctx-size`), persisted, surfaced in a chat-header settings popover (`llm.ts` getSettings/setSettings). Next: top_p / max-tokens.
## Phase 6 — Cross-device (Personal AI OS) 🟡
+
- ✅ Engine exists: `@offgrid/sync` + `@offgrid/memory` (pairing, anti-entropy op-log)
- ⬜ Carry the new memory (observations/actions/entities/reflect) across the mesh
- ⬜ Embed sync + memory + clipboard into Desktop; desktop↔desktop converge; universal clipboard
- ⬜ **One brain across devices** — laptop (work) + phone (life) unify into a single working model, syncing over the home network, no cloud relay (`vision.md`)
## Phase 7 — Org / B2B distribution ⬜
-- ⬜ Team/org identity + roles; **scoped-sharing model** (share *intelligence*, never raw frames); right-person-right-time distribution across a team. The layer neither screenpipe nor Littlebird has
+
+- ⬜ Team/org identity + roles; **scoped-sharing model** (share _intelligence_, never raw frames); right-person-right-time distribution across a team. The layer neither screenpipe nor Littlebird has
## Phase 8 — Productization ⬜
-- ⬜ Onboarding permission ladder (screen → Google OAuth → MCP) — *deferred, not now*
+
+- ⬜ Onboarding permission ladder (screen → Google OAuth → MCP) — _deferred, not now_
- ⬜ Settings consolidation; ✅ theme toggle wiring
- ⬜ Packaging: signed/notarized DMG + auto-update
-- ⬜ Licensing: AGPL + CLA + open-core; device cap (2 free / 3+ paid) — *deferred, not now*
+- ⬜ Licensing: AGPL + CLA + open-core; device cap (2 free / 3+ paid) — _deferred, not now_
---
## Parked (explicitly deferred — not now, per product call June 2026)
+
- **Storage & retention** (cleanup/budget/auto-prune). Revisit when running all-day for weeks becomes the norm.
- **Continuous ScreenCaptureKit → H.264 video** (smooth DVR vs current PNG frames). Current per-tick screenshots are good enough for now.
-- **Replay: scene grouping below the scrubber.** Group the timeline's frames into visible scenes/sessions (the `session.ts` windowing we built for entity carry-over) shown as labelled bands under the time slider, so it's not one flat strip of frames — helps people understand what a stretch of time *was*. (Parked 2026-06-29.)
+- **Replay: scene grouping below the scrubber.** Group the timeline's frames into visible scenes/sessions (the `session.ts` windowing we built for entity carry-over) shown as labelled bands under the time slider, so it's not one flat strip of frames — helps people understand what a stretch of time _was_. (Parked 2026-06-29.)
- **Entity → scene replay.** From an entity record, pull up the full scene(s) involving that entity and play them back — "show me everything around Nowshad" reconstructs and replays the relevant captured scene. Builds on scene detection + entity links. (Parked 2026-06-29.)
## Engineering track (parallel)
+
Desktop implements capture/reflect/act **inline** today. For mobile reuse, extract into `@offgrid/*` packages (`capture`, `memory`, `skills`, `models`, `imagegen`, `ui`) once proven in-app. **Mobile is built last.**
## Status (June 2026)
+
Phases 0–2 largely done — the full **see → remember → reflect → act(detect)** loop runs on one machine. Frontier: **Phase 4 (authorized sources + approved actions)**, starting with the **foundation** (identity → secrets → consent → approval queue), then **Gmail/Calendar via MCP**.
## Later phase — UI standardization audit (design philosophy)
diff --git a/docs/API.md b/docs/API.md
index 976fa30f..96b576ba 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -45,18 +45,18 @@ speech are always ready (models download on first use). Image generation/edit re
## Capabilities at a glance
-| Modality | Endpoint | Method | Backend |
-|---|---|---|---|
-| Text → Text | `/v1/chat/completions` | POST | llama-server (bundled VLM) |
-| Image → Text (vision) | `/v1/chat/completions` | POST | llama-server (VLM + mmproj) |
-| Text completion (legacy) | `/v1/completions` | POST | llama-server |
-| Embeddings | `/v1/embeddings` | POST | llama-server |
-| List models | `/v1/models` | GET | llama-server |
-| Speech → Text (STT) | `/v1/audio/transcriptions` | POST | whisper.cpp |
-| Text → Speech (TTS) | `/v1/audio/speech` | POST | Kokoro-82M (ONNX) |
-| List TTS voices | `/v1/audio/voices` | GET | Kokoro-82M |
-| Text → Image | `/v1/images` (or `/v1/images/generations`) | POST | stable-diffusion.cpp / Core ML |
-| Image → Image | `/v1/images` (or `/v1/images/edits`) | POST | stable-diffusion.cpp |
+| Modality | Endpoint | Method | Backend |
+| ------------------------ | ------------------------------------------ | ------ | ------------------------------ |
+| Text → Text | `/v1/chat/completions` | POST | llama-server (bundled VLM) |
+| Image → Text (vision) | `/v1/chat/completions` | POST | llama-server (VLM + mmproj) |
+| Text completion (legacy) | `/v1/completions` | POST | llama-server |
+| Embeddings | `/v1/embeddings` | POST | llama-server |
+| List models | `/v1/models` | GET | llama-server |
+| Speech → Text (STT) | `/v1/audio/transcriptions` | POST | whisper.cpp |
+| Text → Speech (TTS) | `/v1/audio/speech` | POST | Kokoro-82M (ONNX) |
+| List TTS voices | `/v1/audio/voices` | GET | Kokoro-82M |
+| Text → Image | `/v1/images` (or `/v1/images/generations`) | POST | stable-diffusion.cpp / Core ML |
+| Image → Image | `/v1/images` (or `/v1/images/edits`) | POST | stable-diffusion.cpp |
This surface follows the [OpenRouter multimodal](https://openrouter.ai/docs/guides/overview/multimodal/overview)
conventions: images go in as `image_url` content parts (data URLs **or** `http(s)://` /
@@ -92,7 +92,14 @@ grammar-constrained `response_format` and disable thinking:
```json
{
"messages": [{ "role": "user", "content": "..." }],
- "response_format": { "type": "json_schema", "json_schema": { "schema": { /* ... */ } } },
+ "response_format": {
+ "type": "json_schema",
+ "json_schema": {
+ "schema": {
+ /* ... */
+ }
+ }
+ },
"chat_template_kwargs": { "enable_thinking": false }
}
```
@@ -108,12 +115,12 @@ print(r.choices[0].message.content)
```
```javascript
-import OpenAI from "openai";
-const client = new OpenAI({ baseURL: "http://127.0.0.1:7878/v1", apiKey: "not-needed" });
+import OpenAI from 'openai'
+const client = new OpenAI({ baseURL: 'http://127.0.0.1:7878/v1', apiKey: 'not-needed' })
const r = await client.chat.completions.create({
- model: "local",
- messages: [{ role: "user", content: "hello" }],
-});
+ model: 'local',
+ messages: [{ role: 'user', content: 'hello' }]
+})
```
---
@@ -196,11 +203,11 @@ converted to 16 kHz mono via ffmpeg and transcribed with auto language detection
**Form fields**
-| Field | Required | Notes |
-|---|---|---|
-| `file` | yes | Audio file (wav, mp3, m4a, …). Max 200 MB. |
-| `response_format` | no | `json` (default) or `text`. |
-| `model` | no | Accepted for compatibility; the installed whisper model is used. |
+| Field | Required | Notes |
+| ----------------- | -------- | ---------------------------------------------------------------- |
+| `file` | yes | Audio file (wav, mp3, m4a, …). Max 200 MB. |
+| `response_format` | no | `json` (default) or `text`. |
+| `model` | no | Accepted for compatibility; the installed whisper model is used. |
```bash
curl http://127.0.0.1:7878/v1/audio/transcriptions \
@@ -223,12 +230,12 @@ onnxruntime. **Returns raw `audio/wav` bytes** by default (like OpenAI).
**JSON body**
-| Field | Required | Notes |
-|---|---|---|
-| `input` | yes | Text to speak. Capped at ~2000 chars per call. (`text` also accepted.) |
-| `voice` | no | Voice id, default `af_heart`. See `/v1/audio/voices`. |
-| `response_format` | no | Omit/`wav` → raw WAV bytes. `json`/`b64_json` → `{ "audio": "data:audio/wav;base64,…", "format": "wav" }`. |
-| `model` | no | Accepted for compatibility. |
+| Field | Required | Notes |
+| ----------------- | -------- | ---------------------------------------------------------------------------------------------------------- |
+| `input` | yes | Text to speak. Capped at ~2000 chars per call. (`text` also accepted.) |
+| `voice` | no | Voice id, default `af_heart`. See `/v1/audio/voices`. |
+| `response_format` | no | Omit/`wav` → raw WAV bytes. `json`/`b64_json` → `{ "audio": "data:audio/wav;base64,…", "format": "wav" }`. |
+| `model` | no | Accepted for compatibility. |
```bash
# Raw WAV to a file
@@ -250,7 +257,7 @@ curl http://127.0.0.1:7878/v1/audio/voices
---
-## 6. Text → Image & Image → Image (`/v1/images`)
+## 6. Text → Image & Image → Image (`/v1/images`)
`POST /v1/images` — one JSON endpoint for both text-to-image and image-to-image, matching
OpenRouter's image API. On-device diffusion via stable-diffusion.cpp (GGUF / safetensors)
@@ -258,21 +265,21 @@ or the Core ML ANE helper. Returns base64 PNG.
**JSON body**
-| Field | Required | Notes |
-|---|---|---|
-| `prompt` | yes | Text prompt. |
-| `input_references` | no | Array — **presence makes it image-to-image.** Each item is `{ "type": "image_url", "image_url": { "url": "…" } }` (or a bare URL string). The `url` may be a data URL, `http(s)://`, or `file://`. The first reference is used as the init image. |
-| `strength` | no | img2img only. 0–1, how far from the init image (default ~0.75). Lower = closer to original. |
-| `aspect_ratio` | no | e.g. `"16:9"`, `"1:1"`. Combined with `resolution`. |
-| `resolution` | no | `"1K"` (default), `"2K"`, or `"512"` — sets the long edge. |
-| `size` | no | OpenAI-style `"WIDTHxHEIGHT"`, e.g. `"1024x1024"`. |
-| `width` / `height` | no | Explicit dimensions (numbers); override the above. |
-| `negative_prompt` | no | Things to avoid. A sensible default is applied if omitted. |
-| `steps` | no | Sampling steps. Per-model default (few-step turbo/lightning models use ~4–8). |
-| `seed` | no | Integer seed; omit or `-1` for random. The resolved seed is returned. |
-| `cfg_scale` | no | Guidance scale. Per-model default. |
-| `model` | no | Model filename in the models dir; otherwise the preferred installed model. |
-| `response_format` | no | `b64_json` (default) → base64 PNG. `url` → `file://` path on disk. |
+| Field | Required | Notes |
+| ------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `prompt` | yes | Text prompt. |
+| `input_references` | no | Array — **presence makes it image-to-image.** Each item is `{ "type": "image_url", "image_url": { "url": "…" } }` (or a bare URL string). The `url` may be a data URL, `http(s)://`, or `file://`. The first reference is used as the init image. |
+| `strength` | no | img2img only. 0–1, how far from the init image (default ~0.75). Lower = closer to original. |
+| `aspect_ratio` | no | e.g. `"16:9"`, `"1:1"`. Combined with `resolution`. |
+| `resolution` | no | `"1K"` (default), `"2K"`, or `"512"` — sets the long edge. |
+| `size` | no | OpenAI-style `"WIDTHxHEIGHT"`, e.g. `"1024x1024"`. |
+| `width` / `height` | no | Explicit dimensions (numbers); override the above. |
+| `negative_prompt` | no | Things to avoid. A sensible default is applied if omitted. |
+| `steps` | no | Sampling steps. Per-model default (few-step turbo/lightning models use ~4–8). |
+| `seed` | no | Integer seed; omit or `-1` for random. The resolved seed is returned. |
+| `cfg_scale` | no | Guidance scale. Per-model default. |
+| `model` | no | Model filename in the models dir; otherwise the preferred installed model. |
+| `response_format` | no | `b64_json` (default) → base64 PNG. `url` → `file://` path on disk. |
**Text-to-image:**
@@ -301,9 +308,7 @@ curl http://127.0.0.1:7878/v1/images \
```json
{
"created": 1750000000,
- "data": [
- { "b64_json": "iVBORw0KGgo...", "seed": 42, "model": "z-image-turbo.gguf" }
- ],
+ "data": [{ "b64_json": "iVBORw0KGgo...", "seed": 42, "model": "z-image-turbo.gguf" }],
"usage": { "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0 }
}
```
@@ -344,13 +349,13 @@ Errors use the OpenAI shape:
{ "error": { "message": "…", "type": "invalid_request_error" } }
```
-| Status | Meaning |
-|---|---|
-| `400` | Bad request (missing field, wrong content type, invalid JSON). |
-| `413` | Upload exceeds the 200 MB cap. |
-| `500` | The model run failed (see `message`). |
-| `501` | Modality not installed (e.g. no diffusion model, transcription runtime missing). |
-| `502` | llama-server not ready (chat/embeddings upstream unavailable). |
+| Status | Meaning |
+| ------ | -------------------------------------------------------------------------------- |
+| `400` | Bad request (missing field, wrong content type, invalid JSON). |
+| `413` | Upload exceeds the 200 MB cap. |
+| `500` | The model run failed (see `message`). |
+| `501` | Modality not installed (e.g. no diffusion model, transcription runtime missing). |
+| `502` | llama-server not ready (chat/embeddings upstream unavailable). |
---
@@ -392,7 +397,7 @@ curl http://127.0.0.1:7878/v1/requests/cee1c78f-…
{
"request_id": "cee1c78f-…",
"kind": "image",
- "status": "completed", // queued | running | completed | failed
+ "status": "completed", // queued | running | completed | failed
"created_at": 1782191813736,
"updated_at": 1782191840000,
"result": { "created": 1782191840, "data": [{ "b64_json": "…" }] }
@@ -423,12 +428,12 @@ memory carefully (Apple Silicon shares RAM between CPU/GPU/ANE):
## Backends & ports
-| Service | Where | Used by |
-|---|---|---|
-| `llama-server` | `127.0.0.1:8439` (long-running) | chat, vision, completions, embeddings, models |
-| `whisper.cpp` (`whisper-cli`) | one-shot subprocess | transcription |
-| Kokoro-82M via `kokoro-js` | in-process (onnxruntime) | speech, voices |
-| `sd-cli` / `coreml-sd` | one-shot subprocess | image generation & edits |
+| Service | Where | Used by |
+| ----------------------------- | ------------------------------- | --------------------------------------------- |
+| `llama-server` | `127.0.0.1:8439` (long-running) | chat, vision, completions, embeddings, models |
+| `whisper.cpp` (`whisper-cli`) | one-shot subprocess | transcription |
+| Kokoro-82M via `kokoro-js` | in-process (onnxruntime) | speech, voices |
+| `sd-cli` / `coreml-sd` | one-shot subprocess | image generation & edits |
The gateway (`src/main/model-server.ts`) is the only port you call (`7878`); it proxies
or invokes the right backend per route. Models live in the app's `userData/models`
diff --git a/docs/CHAT_UX_SPEC.md b/docs/CHAT_UX_SPEC.md
index 38b34771..bd0c0b2e 100644
--- a/docs/CHAT_UX_SPEC.md
+++ b/docs/CHAT_UX_SPEC.md
@@ -24,7 +24,7 @@ appears at the right time, and nothing feels like a tool you have to operate.)
2. **The model picks the modality at the right point.** It emits a typed block and the
UI renders it seamlessly inline:
- `\`\`\`image` → on-device generation, rendered as it completes
- - `\`\`\`html` / `\`\`\`svg` / `\`\`\`mermaid` → artifact in the side canvas
+ - `\`\`\`html`/`\`\`\`svg`/`\`\`\`mermaid` → artifact in the side canvas
- `\`\`\`ask` (JSON) → inline clickable multiple-choice
- otherwise → streamed text
3. **Everything streams.** Text types in; images show a tasteful generating state then
diff --git a/docs/CODE_AUDIT.md b/docs/CODE_AUDIT.md
index b16c00e1..4ba23eee 100644
--- a/docs/CODE_AUDIT.md
+++ b/docs/CODE_AUDIT.md
@@ -8,12 +8,14 @@ verification. Land each as its own reviewed change with real verification — no
(the merge gate forbids shipping an unverified "done").
## Structural (prevents a whole bug class)
+
- **Renderer has no store layer** (`src/renderer/src/stores/` doesn't exist). Every screen
re-fetches + holds its own `useState` copy — the root of the "local copy drifts" bug class
(image composer, ProjectsScreen doc-toggle, …). A thin per-domain store (owns the fetch +
write-through) prevents it structurally instead of fixing it screen-by-screen.
## Core — DIP / SRP (engine contracts; need live verification)
+
- **`ImageRuntime` interface** — `imagegen.runImageGen` chooses among 4 interchangeable runtimes
(mflux/coreml/sd-server/sd-cli) via a predicate cascade in one ~350-line fn; a 5th needs edits
in ≥3 places. Fold `models-manager` `runtime==='mflux'` install/delete/isCached into the same
@@ -26,14 +28,17 @@ verification. Land each as its own reviewed change with real verification — no
connection/schema/per-domain repos + `vector-math.ts`. Native-DB path; behavior-risk.
## Core — correctness (needs live image-gen)
-- **Z-Image guard-vs-run regex** — the memory guard sizes a *different* match than the run loads,
+
+- **Z-Image guard-vs-run regex** — the memory guard sizes a _different_ match than the run loads,
so residency estimates can be wrong for the Z-Image stack.
## Core — brand (needs screenshot verification)
+
- **`@tabler/icons-react` → Phosphor** in ModelsScreen / StoragePanel / ConnectorsScreen
(CLAUDE.md mandates Phosphor-only). Many-icon swap; each glyph + weight verified visually.
## Pro — DIP / SRP
+
- **`ConnectorIngestor` interface** — `ingest.ts` dispatches on connector identity (URL substring +
tool-name sniff); adding a connector edits ≥4 fns. Per-connector object declares
category/buildQuery/pickTool; dispatcher picks first `matches()`. Needs live MCP connectors.
@@ -41,6 +46,7 @@ verification. Land each as its own reviewed change with real verification — no
`extract.extractObservationFromScreen`, `vault-service` unlock/recovery (inject a VaultStore).
## Lint backlog surfaced by the new gates (warn-ratchet — grind down, tighten to error as areas clear)
+
- **`@typescript-eslint/no-unnecessary-condition`**: ~289 dead-branch findings (190 core, 99 pro),
concentrated in the god-files (MemoryChat 42, ipc 12, App 12). Triage — some are dead branches to
delete, some are guards at untyped boundaries where the fix is to correct the TYPE.
@@ -50,6 +56,7 @@ verification. Land each as its own reviewed change with real verification — no
a "dead" dep may be used in a build/runtime path knip can't trace; never blind-`npm remove`.
## Evaluated and intentionally NOT changing (don't re-flag)
+
`isMe` (token-overlap, DB-sourced) vs `isSelfName` (substring-position, injected list) — different
matchers, not a dup. `markdownComponents` maps — intentionally styled per surface. `dayKey` (string)
vs `dayKeyOf` (epoch-sec number) — different functions. dictation `buildSinks` — it's the factory
diff --git a/docs/CONSOLE_PLAN.md b/docs/CONSOLE_PLAN.md
index 9aff0ba0..ec1143d8 100644
--- a/docs/CONSOLE_PLAN.md
+++ b/docs/CONSOLE_PLAN.md
@@ -7,8 +7,8 @@ the "app that connects to all the nodes" (Off Grid Desktop/Mobile). Next.js.
This is a **new, separate product** from Off Grid Desktop. The nodes already carry the
gateway and enforce policy locally (see `ENTERPRISE_BUILD_PLAN.md`). The Console does **not**
run the intelligence or enforce policy — it **defines and observes**: provisions policy +
-knowledge + config *down* to the fleet, aggregates audit + telemetry + distilled learnings
-*up*.
+knowledge + config _down_ to the fleet, aggregates audit + telemetry + distilled learnings
+_up_.
---
@@ -46,6 +46,7 @@ customer takes any subset and never the whole ecosystem to use one part:
- **All of it** — the full common control plane.
Baked into the build:
+
- **API-first.** Every module exposes its function over a documented API; the Console UI is
one consumer of that same API. "API only" is free — it's the contract the UI uses.
- **Independent modules.** Each plane/service (Gateway, Brain, Agents, Data/ingest, Fleet,
@@ -88,15 +89,15 @@ below, then wiring real nodes once Stage 1 ships.
Define this API first — both sides build against it. Pull-based, versioned, mTLS or
device-token auth.
-| Direction | Endpoint (Console side) | Purpose |
-|---|---|---|
-| Enroll | `POST /v1/devices/enroll` (with admin-issued enrollment token) | Node registers; Console issues a device identity/token (C4) |
-| Policy down | `GET /v1/devices/{id}/policy` | Node pulls current policy bundle (guardrails, egress rules, RBAC, AI-use policy) |
-| Config + knowledge down | `GET /v1/devices/{id}/provision` | Intelligence config + SOPs/KB refs the node's role gets (from Brain) |
-| Audit up | `POST /v1/devices/{id}/audit` | Node pushes audit batches (calls, what-left-device, tool use) |
-| Telemetry up | `POST /v1/devices/{id}/telemetry` | Tokens, latency, eval results, drift signals |
-| Learnings up | `POST /v1/devices/{id}/learnings` | Distilled SOPs/patterns (never raw capture) → Brain |
-| Commands | `GET /v1/devices/{id}/commands` (+ optional SSE) | Kill switch, re-provision, revoke — node polls/streams |
+| Direction | Endpoint (Console side) | Purpose |
+| ----------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| Enroll | `POST /v1/devices/enroll` (with admin-issued enrollment token) | Node registers; Console issues a device identity/token (C4) |
+| Policy down | `GET /v1/devices/{id}/policy` | Node pulls current policy bundle (guardrails, egress rules, RBAC, AI-use policy) |
+| Config + knowledge down | `GET /v1/devices/{id}/provision` | Intelligence config + SOPs/KB refs the node's role gets (from Brain) |
+| Audit up | `POST /v1/devices/{id}/audit` | Node pushes audit batches (calls, what-left-device, tool use) |
+| Telemetry up | `POST /v1/devices/{id}/telemetry` | Tokens, latency, eval results, drift signals |
+| Learnings up | `POST /v1/devices/{id}/learnings` | Distilled SOPs/patterns (never raw capture) → Brain |
+| Commands | `GET /v1/devices/{id}/commands` (+ optional SSE) | Kill switch, re-provision, revoke — node polls/streams |
---
@@ -105,29 +106,30 @@ device-token auth.
Navigation mirrors the planes (and the `ENTERPRISE_BUILD_PLAN.md` component map):
- **Fleet** — device inventory, enrollment, groups/roles, per-role policy + intelligence
- assignment, kill switch. *(C4, C9c, provisioning.)*
+ assignment, kill switch. _(C4, C9c, provisioning.)_
- **Control plane** — gateway config (model routing, leashed cloud), guardrail rules
(input/output), **egress rules**, **audit log explorer**, observability dashboards, RBAC
- authoring. *(C1, C2, C3, C5, C7, C8, C16.)*
+ authoring. _(C1, C2, C3, C5, C7, C8, C16.)_
- **Data plane** — connectors to DBs/warehouses/SaaS, ingest jobs + status, PII/masking
- rules, data catalog, retention/erasure (DSAR). *(A1, A3, A5, A7, A9, A11, A12a.)*
+ rules, data catalog, retention/erasure (DSAR). _(A1, A3, A5, A7, A9, A11, A12a.)_
- **AI plane (Brain)** — KB/SOP management (review, edit, publish "what good is"), model
- registry, retrieval config, **eval + drift** dashboards. *(B2a, B3, B5a, B16, C9, C9a.)*
+ registry, retrieval config, **eval + drift** dashboards. _(B2a, B3, B5a, B16, C9, C9a.)_
- **Regulatory** — the **DPO single view**: compliance status, framework→control mapping,
- one-click audit/DPIA export, AI-use-policy authoring. *(E1, E2, E6, C7 rollup.)*
+ one-click audit/DPIA export, AI-use-policy authoring. _(E1, E2, E6, C7 rollup.)_
---
## Standards (decision locked)
We follow the Wednesday **Standards Kit** for engineering and component sourcing, and the
-**Off Grid brutalist brand** (`docs/DESIGN.md`) for visual identity. Where the kit's *visual*
+**Off Grid brutalist brand** (`docs/DESIGN.md`) for visual identity. Where the kit's _visual_
identity conflicts with Off Grid (it uses green→teal gradients, Instrument Serif, DM Sans,
shimmer, card-lift, rich animation), **Off Grid wins** — the Console is one product family
with the Desktop/Mobile nodes it manages, and a dense compliance/audit tool suits the flat,
information-first look.
**Engineering standards (from the kit — adopted as-is):**
+
- Cyclomatic complexity **< 8** (no exceptions); refactor over nesting.
- Naming: **PascalCase** (components/types), **camelCase** (logic).
- Strict **import ordering**: React → Next → state → UI → alias → relative.
@@ -136,6 +138,7 @@ information-first look.
`aria-label` on icons, `alt` on images; 4.5:1 contrast.
**Visual identity (Off Grid `docs/DESIGN.md` — overrides the kit):**
+
- Menlo mono everywhere; single emerald accent (`#34D399`/`#059669`), **no gradients**.
- Flat 8px radius, hairline borders, no shadow/lift; hierarchy via size+opacity, not color.
- **No decorative animation** — loading spinner only. (So the kit's animation-timing table
@@ -147,22 +150,24 @@ information-first look.
`wednesday-solutions/component-library-animations` is a **catalog** — an index of the ~399
components that exist across the ecosystem (shadcn, aceternity, animate-ui, cult-ui,
eldora-ui, magic-ui, motion-primitives), with **example implementations**. Use the catalog to
-find the *right* component for each need; then bring that component in **from its real source
+find the _right_ component for each need; then bring that component in **from its real source
library**, and re-theme it. The repo's `Button` is an example — we don't import it; we use
the actual library's component.
**Setup (one-time):**
+
1. Clone `wednesday-solutions/component-library-animations` **inside** the console repo as a
**reference catalog**.
2. **`.gitignore` that clone** — it's for discovery, not a committed dependency. Checked out
fresh per environment.
**Workflow (every UI need):**
+
1. **Discover** — search `skills/component-library-index.md` (indexed **by use case**) +
`src/componentRegistry.tsx`. Find the right component and note **which library it's from**.
2. **Source it from that library** — install/add the real component from its upstream
(shadcn via its CLI, aceternity/magic-ui/cult-ui from their source). The catalog's file is
- the *example*; the real library is the *source of truth*.
+ the _example_; the real library is the _source of truth_.
3. **Re-theme to `docs/DESIGN.md`** — the brand overrides the library's defaults.
If a need isn't in the catalog, find the **closest catalog component** and adapt it — still
@@ -177,7 +182,7 @@ gradients**, **single emerald accent**, Menlo mono, **no decorative animation**)
admin console is dense and information-first, not a landing page.
- **Skip the decorative pieces** (AuroraBackground, NeonGradientCard, RainbowButton,
ShinyText, Meteors, GlowingStars…) — they fight the brand.
-- **Re-theme on use:** Menlo `font-mono`, emerald `#34D399/#059669` as the *only* accent,
+- **Re-theme on use:** Menlo `font-mono`, emerald `#34D399/#059669` as the _only_ accent,
flat 8px radius, hairline borders, hierarchy via size+opacity not color. Animation only
where functional (loading), never decorative.
@@ -208,11 +213,11 @@ Sequenced so the Console is demoable early (against mocks) and useful as soon as
the component-library-animations repo as a **discovery catalog**; source components from
their real libraries per the workflow above (no custom, no vendored repo copies); apply
`@offgrid/design` / `docs/DESIGN.md` theme; OIDC login, RBAC, Postgres schema (devices,
- policies, audit, users). Mocked node API. *Demoable console with a fake fleet, brand-
- matched, components sourced from the library.*
+ policies, audit, users). Mocked node API. _Demoable console with a fake fleet, brand-
+ matched, components sourced from the library._
2. **M1 — Fleet foundation.** Enrollment flow, device inventory, push a policy bundle,
ingest audit, the audit log explorer, kill switch. Wire to **real nodes** once node-side
- Stage 1 ships. *Done when an admin governs a real device from the console.*
+ Stage 1 ships. _Done when an admin governs a real device from the console._
- ✅ **Contract API + OpenAPI/Scalar docs shipped** — the full node↔console lifecycle
(enroll · pull policy · push audit · poll commands · admin token/policy/kill · fleet
audit) on a swappable in-memory store; OpenAPI 3.1 at `/openapi.json`, interactive
@@ -223,15 +228,15 @@ Sequenced so the Console is demoable early (against mocks) and useful as soon as
3. **M2 — Control plane UI.** ✅ Policy editor (egress toggle + editable guardrails + allowed
models, published as a **versioned** policy), **policy history**, **RBAC** (users + role
change, validated), and the audit log. Gateway section reads the live node gateway
- (`:7878`). *Observability dashboards deferred to the Analytics module.*
+ (`:7878`). _Observability dashboards deferred to the Analytics module._
4. **M3 — Data plane UI.** ✅ Connectors (add/sync/delete), ingest jobs, PII/masking rules
(add/toggle), data catalog with classification, and retention/erasure (DSAR). Real
- Postgres tables + admin APIs. *Next: wire ingest into Brain (M4).*
+ Postgres tables + admin APIs. _Next: wire ingest into Brain (M4)._
5. **M4 — Brain UI.** ✅ LanceDB ingestion→retrieval (RAG): KB/SOP list + add-document
(embed+index), semantic search with scored citations. Embeddings via the gateway
- `/v1/embeddings` (deterministic fallback). *Next: eval/drift + model registry.*
+ `/v1/embeddings` (deterministic fallback). _Next: eval/drift + model registry._
6. **M5 — Regulatory / DPO.** Compliance view, framework mapping, one-click audit/DPIA
- export, AI-use-policy authoring. *Done when a DPO can export a defensible pack.*
+ export, AI-use-policy authoring. _Done when a DPO can export a defensible pack._
7. **M6 — Self-host packaging.** Docker Compose bundle, install docs, backup/restore.
---
@@ -268,7 +273,7 @@ diagrams + OSS map). All on real Postgres + LanceDB + SSO + the live `:7878` gat
1. **Desktop grounding of the story** — survey `../desktop` so Fleet Control, auto-SOP
creation, and org-knowledge sync messaging matches what the app actually does
- (capture → synthesize → SOPs; memory + `@offgrid/sync`). *(in progress)*
+ (capture → synthesize → SOPs; memory + `@offgrid/sync`). _(in progress)_
2. **Multi-tenant Admin module + ABAC/RBAC** — tenants/orgs, provisioning (who the console
is for + their access), ABAC (tenant/purpose/data-class) layered on existing RBAC. Do now
to avoid a retrofit. Single interface (ours) — no white-labeling underlying tools.
diff --git a/docs/CONSOLIDATION_PLAN.md b/docs/CONSOLIDATION_PLAN.md
index af87ae0b..1a2df7f4 100644
--- a/docs/CONSOLIDATION_PLAN.md
+++ b/docs/CONSOLIDATION_PLAN.md
@@ -30,7 +30,6 @@ NOT DONE (deferred, deliberate):
forked-pipeline. NOT done - needs its own PR. See C7 below.
-->
-
**Scope:** 76 raw findings from parallel audits, deduped to **29 surviving actions** across 5 patterns. **9 findings dropped** as false positives or already-single-source (listed at the end). Four items are **live disagreements shipping today** (P0). Two audit claims were factually wrong on inspection and are corrected inline.
---
@@ -40,30 +39,35 @@ NOT DONE (deferred, deliberate):
These are not "risk of drift"; the two sides already disagree. Fix first, each with a regression test asserting the shared constant.
### P0.1 — `RagConversation.project_id` missing on the preload/renderer boundary
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** high
- **Locations:** `src/main/database.ts:1129` (has `project_id?: string | null`), `src/preload/index.d.ts:46` (omits it), `src/renderer/src/env.d.ts:76` (omits it) — **verified**.
- **Fix:** Export `RagConversation` from `database.ts`; import in preload + renderer. Do not redeclare.
- **Drift risk (already real):** project-scoped conversations lose `project_id` at the preload type boundary — the frontend never sees it. Project chat routing silently degrades. This is a live bug, not hypothetical.
### P0.2 — `saveArtifact` kind union diverges preload vs renderer
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** high
- **Locations:** `src/preload/index.ts:248` allows `'html'|'svg'|'mermaid'|'react'`; `src/renderer/src/env.d.ts:187` allows those **plus `'text'|'image'`** — **verified**.
- **Fix:** Use the canonical `ArtifactKind` (`src/main/artifacts.ts:33`) — better, the shared `@offgrid/artifacts` type (see D2) — everywhere. Align preload to the full union.
- **Drift risk (already real):** renderer can call `saveArtifact({kind:'text'})`, which the preload type rejects — a compile-time contract split; `text`/`image` artifacts hit an untyped path.
### P0.3 — `ctxSize` default: backend 16384 vs UI 32768
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** medium (raised to P0: values disagree now)
- **Locations:** `src/main/llm.ts:70` (`ctxSize = 16384`), `src/renderer/src/components/SettingsPanel.tsx:151` (`s.ctxSize ?? 32768`, and the option list labels `65536` as "(default)") — **verified, three-way disagreement**.
- **Fix:** `src/shared/llm-defaults.ts` exporting `DEFAULT_CTX_SIZE`; import in both. Reconcile which value is truly the default (the option-list label says 65536 — a third number, decide deliberately).
- **Drift risk (already real):** a user with no stored setting sees 32768 in the UI while inference runs at 16384; "reset to defaults" changes actual behavior.
### P0.4 — Advanced sampler defaults: backend `undefined` vs UI hardcoded
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** high
- **Locations:** `src/main/llm.ts:77-80` (`topP/topK/minP/repeatPenalty` = `undefined`, intentionally "let llama.cpp default" — confirmed by the source comment), `src/renderer/src/components/SettingsPanel.tsx:20` (`DEFAULTS` hardcodes `topP:0.95, topK:40, minP:0.05, repeatPenalty:1.1`) — **verified**.
- **Fix:** Put the decision in `src/shared/llm-defaults.ts` **once**. Either (a) initialize the backend fields from it, or (b) drop them from UI `DEFAULTS` so `undefined` stays `undefined`. Pick one; the backend comment says the intent is "let llama.cpp decide," so (b) is likely correct.
- **Drift risk (already real):** UI "Reset to defaults" pushes sampler overrides the backend never intended — fresh instances and post-reset instances infer differently.
### P0.5 — Two incompatible `Modality` types with the same name
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** high
- **Locations:** `src/main/active-models.ts:10` = `'image'|'speech'|'transcription'`; `src/main/runtime-residency.ts:18` = `'llm'|'image'|'stt'|'tts'` — **verified**. Same name, different members, `speech`≠`tts`, `transcription`≠`stt`.
- **Fix:** One `Modality` in a shared module. Reconcile the vocabulary (`speech`/`tts`, `transcription`/`stt`) to a single set; `active-models` already owns the canonical `modalityForKind()` dispatch (`:18`), so anchor there and let residency extend it with the `llm` tier explicitly.
@@ -74,13 +78,16 @@ These are not "risk of drift"; the two sides already disagree. Fix first, each w
## Foundational shared modules (do these next — everything else imports them)
### F1 — `src/main/constants.ts`: engine ports
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** high
- **`LLAMA_SERVER_PORT = 8439`** — `llm.ts:51`, `tools.ts:14`, `model-server.ts:39`, `setup.ts:44` — **verified (4 sites)**.
- **`GATEWAY_PORT = 7878`** — `GatewayScreen.tsx:6`, `setup.ts:45`, `model-server.ts:919` — **verified (3 sites)**. Renderer needs it too, so this constant (or a preload-exposed copy) must be reachable from the renderer.
- **Fix:** Single `src/shared/ports.ts`; import in main + expose to renderer. **Drift risk:** a port change misses one site → gateway UI snippets point at the wrong port; upstream proxy breaks.
### F2 — `src/shared/llm-defaults.ts`: sampling + timeouts + mode presets
+
Merges four findings into one config module:
+
- Sampler/ctx defaults (P0.3, P0.4).
- `temperature 0.7`, `maxTokens 2048`, tool `temperature 0.3 / max_tokens 1024`, `timeoutMs 300000` — `llm.ts:69,81,554-555`, `tools.ts:303`.
- `LLAMA_RELOAD_TIMEOUT_MS = 45_000` — `model-server.ts:569,576`.
@@ -88,6 +95,7 @@ Merges four findings into one config module:
- **Principle:** DRY · **Severity:** high (aggregate). **Fix:** one `LLAMA_DEFAULTS` object + `MODE_PRESETS`; backend fields and UI both import. **Drift risk:** tool-chat's deliberate 0.3 temp is an invisible magic number; mode-preset ctx and RAM-budget fractions drift apart across `llm.ts`/`setup.ts`.
### F3 — `src/main/types/model-kinds.ts`: model-kind / capability source of truth
+
- **Pattern:** branch-on-concrete-type · **Principle:** OCP · **Severity:** medium
- **Locations:** `model-server.ts:638` (`'vision'`/`'chat'` string literals), `setup.ts:183` (`performanceMode` triple-check), `models-manager.ts:301,305` (`kind === 'image'|'speech'|'transcription'` dispatch), `ModelsScreen.tsx:88,231,296` (`m.kind === 'vision'`).
- **Fix:** const-object `MODEL_KINDS` + `isVisionKind()`/capability guards; **route all modality dispatch through the existing `modalityForKind()`** (`active-models.ts:18`) — models-manager should call it, not re-branch. Renderer asks "supports vision?" via a capability check, not `kind ===`.
@@ -98,6 +106,7 @@ Merges four findings into one config module:
## Group A — duplicate-config-or-type (remaining)
### A1 — Cross-boundary type triplication (UserProfile / RagMessage / AppSettings / PermissionStatus / ReprocessProgress)
+
- **Principle:** DRY · **Severity:** high
- **UserProfile:** `database.ts:1090`, `preload/index.d.ts:3`, `renderer/env.d.ts:3`
- **RagMessage:** `database.ts:1138`, `preload/index.d.ts:54`, `renderer/env.d.ts:84`
@@ -108,22 +117,26 @@ Merges four findings into one config module:
- **Coupling:** touches `preload/index.d.ts` and `renderer/env.d.ts` — the SAME two files as P0.1, P0.2. **Sequence these; do not parallelize.**
### A2 — Artifact kind→label/icon/runtime maps duplicated
+
- **Principle:** DRY · **Severity:** medium
- **Locations:** `artifacts.ts:37` (runtime if-chain), `ArtifactCanvas.tsx:13`, `ProjectsScreen.tsx:29`, plus the wider renderer `KIND_LABEL/KIND_ICON` spread in `ModelsScreen.tsx:110`, `StoragePanel.tsx:30-32`, `SetupPanel.tsx:15-20`, `CommandPalette.tsx:10`.
- **Fix:** one `ARTIFACT_CONFIG` (kind → {label, icon, runtime}); ideally sourced from `@offgrid/artifacts` (see D2). Renderer kind-maps → `src/renderer/src/constants/kinds.ts`. **Drift risk:** Projects label ≠ Canvas label; new kind labeled in one place only.
- **Coupling:** overlaps D2 (both edit `artifacts.ts` + `ArtifactCanvas.tsx`) — do D2's import swap and A2's map extraction together.
### A3 — Notification type descriptors duplicated across switch/union/array
+
- **Principle:** DRY / OCP · **Severity:** high
- **Locations:** `useNotifications.tsx:5` (union), `NotificationList.tsx:13,15-25,46-53,69,227`.
- **Fix:** `NOTIFICATION_TYPE_DESCRIPTORS` (type → {label, icon}) in a shared `notificationUtil.ts`; derive `FilterType` and `filterOptions` via `keyof`. Mirror VaultScreen's `TYPES` pattern (which the audit correctly flags as the good example). **Drift risk:** new type → missing icon/label, raw enum in badge (`:227`).
### A4 — App route ↔ viewMode maps defined twice (with real gaps)
+
- **Principle:** DRY · **Severity:** medium
- **Locations:** `App.tsx:221-241` (path→view) and `App.tsx:266-288` (view→path). Audit reports `/clipboard` + `/vault` missing from the forward map.
- **Fix:** one `ROUTES` catalog; generate both maps from it. **Drift risk:** deep links reach a route that never activates the view. **Coupling:** same file as A9 (App.tsx core-screen chain).
### A5 — Strictness settings + prompt-key strings fetched ad hoc
+
- **Principle:** DRY · **Severity:** low/medium
- **Locations:** strictness `ipc.ts:361,418`; prompt keys `ipc.ts:23,38,82,113,130,363,422,480,511,933`.
- **Fix:** `getStrictness(category)` helper + `PROMPT_KEYS` constants. **Drift risk:** mistyped prompt key silently falls back to empty instructions; strictness default fixed in one call site only. **Note:** low priority — bundle into the ipc.ts refactor (A8) since it touches the same file.
@@ -133,16 +146,19 @@ Merges four findings into one config module:
## Group B — branch-on-concrete-type (remaining)
### B1 — Artifact kind dispatch in `artifacts.ts` + gallery
+
- **Principle:** OCP · **Severity:** low/medium
- **Locations:** `artifacts.ts:37,38,67,71` (`artifactRuntime`/`deriveTitle` parallel chains); `MemoryChat.tsx:2587-2598` (gallery onClick + thumbnail by kind).
- **Fix:** the `ARTIFACT_CONFIG` map from A2 + an `openArtifactPanel(artifact, handlers)` helper. Folds into A2/D2.
### B2 — Attachment kind branching scattered in MemoryChat
+
- **Principle:** OCP · **Severity:** medium
- **Locations:** `MemoryChat.tsx:666,1220,1607-1625,2182-2210`.
- **Fix:** `renderAttachmentPreview(att)` + viewer map keyed by kind. **Note:** lands inside the MemoryChat decomposition (C3) — do together, same file.
### B3 — Transcription engine guards (`entry.engine === 'parakeet'`)
+
- **Principle:** OCP · **Severity:** medium
- **Locations:** `transcription/whisper-cli.ts:61`, `transcription/parakeet-cli.ts:113`.
- **Fix:** `modelsByEngine(engine)` pure fn in `select.ts`; both CLIs call it. Pairs with C1 (transcription dispatcher).
@@ -152,23 +168,27 @@ Merges four findings into one config module:
## Group C — god-module / forked-pipeline
### C1 — Transcription selection fork (`select.ts`) + engine dispatch
+
- **Pattern:** forked-pipeline · **Principle:** OCP · **Severity:** medium
- **Locations:** `select.ts:32-45,77-80,85-87,94-100` (`pickTranscription` + 4 callers re-deciding), plus B3's engine guards.
- **Fix:** one dispatcher `resolveTranscription(engine, mode?, services?) → {service, engine, fellBack}`; all callers invoke it. **Drift risk:** whisper-resident→whisper fallback priority spread across 4 fns.
### C2 — Image-runtime god-path in `imagegen.ts`
+
- **Pattern:** god-module + branch-on-concrete-type · **Principle:** SRP/OCP · **Severity:** high
- **Locations:** `imagegen.ts:40-48` (`isCoreMLModelDir`), `72-79`, `330` (`mfluxAvailable`), `416` (`isMfluxModelId`), `477`, `485-509`, `537-644`, `602-714`. `runImageGen` is 500+ LOC interleaving mflux/sd-cli/Core ML/Z-Image memory guards + arg building + preview parsing.
- **Fix:** `ImageRuntime` interface (`generate/validateMemory/buildArgs/parseProgress`) with `ImageRuntimeMflux/SdCli/CoreML`; dispatch once at the top of `runImageGen`. Move `isCoreMLModelDir` to a shared `runtime-model-detect.ts`.
- **Coupling with C5 (models-manager runtime dispatch):** both introduce a per-runtime handler abstraction — design ONE `RuntimeHandler`/`ImageRuntime` shape and reuse across `imagegen.ts` and `models-manager.ts` so we don't build two parallel registries. Sequence C5 after C2's interface lands.
### C3 — `MemoryChat.tsx` god component (2600 LOC, 63 useState, 286-LOC `sendMessage`)
+
- **Pattern:** god-module · **Principle:** SRP · **Severity:** high
- **Locations:** `MemoryChat.tsx:200-330` (state), `652-937` (`sendMessage`), plus B2, and the citation/source dispatch (C6) and RagContext render (A-adjacent).
- **Fix:** extract hooks — `useMessageSender`, `useImageGeneration`, `useVoiceInput`, `useGallery`, `useComposerPrefs`, `useAttachments`, `useStreamingChat`. MemoryChat becomes a coordinator.
- **Largest deliberate refactor here.** Do LAST, after the renderer shared helpers (timeAgo D3, SearchHit handler C6, attachment map B2) exist so the extracted hooks import them instead of carrying the duplication forward.
### C4 — `ipc.ts` `rag:chat` god-handler + parallel SQL filters
+
- **Pattern:** god-module + forked-pipeline · **Principle:** SRP/DRY · **Severity:** high
- **Locations:** `ipc.ts:656-970` (314-line handler, 5 intent paths); parallel `source_app`/`app_name LIKE` filters `ipc.ts:547-548,780-781,797-798,814-815,830-831,844-852,868-869` and the 5-query block `774-802,805-818,821-834,837-856,859-872`; also `database.ts:395-396,701-702,722-723,754-755` and `search.ts:150-157`.
- **Fix (two layers, do the cheap one first):**
@@ -177,16 +197,19 @@ Merges four findings into one config module:
- **Coupling:** A5 (strictness/prompt keys), A6-embeddings, and B/broadcast all live in `ipc.ts`. **One owner for `ipc.ts` per round.**
### C5 — `models-manager.ts` per-runtime `=== 'mflux'` branching ×3
+
- **Pattern:** duplicate-config-or-type/OCP · **Severity:** high
- **Locations:** `models-manager.ts:55,111-123,212-226` (list/download/delete each branch on runtime).
- **Fix:** `RuntimeHandler {isCached, download, delete}` map — the SAME abstraction as C2. **Drift risk:** third runtime → edits in 3 functions.
### C6 — SearchHit / unified-source navigation dispatch duplicated
+
- **Pattern:** copy-pasted-helper + branch-on-concrete-type · **Severity:** high
- **Locations:** `App.tsx:440-452`, `MemoryChat.tsx:432-438`, `MemoryChat.tsx:1859-1864`.
- **Fix:** `useSearchHitHandler({onEntity,onMemory,onMeeting,onReplay})` in renderer nav utils; call from all three. **Drift risk:** one site adds screen-replay, another doesn't. Prerequisite helper for C3.
### C7 — `toolChat` is a SECOND generation pipeline forked from `ragChat`/`streamAnswer` (MISSED BY THE ORIGINAL AUDIT)
+
- **Pattern:** forked-pipeline · **Principle:** SRP/DRY · **Severity:** high · **Status:** NOT done (deferred - behavioral change)
- **This is the fork that started the whole effort** (the "chat doesn't stream / no thinking bubble" bug). The audit scanned for behavior-preserving mechanical dedup and bucketed this as a "product fix," so it never became a plan item - a real gap. Recording it here as the canonical forked-pipeline.
- **Locations:** `src/main/tools.ts` `toolChat()` runs its OWN blocking `fetch` loop to `/v1/chat/completions` (its own port const, `max_tokens:1024`, `temperature:0.3`, no streaming, no thinking) - parallel to `ipc.ts` `streamAnswer()` -> `llm.chatStream()` (streams tokens + reasoning over `rag:stream`, honors `thinking`, abortable). Renderer forks at `MemoryChat.tsx:811`: `if (toolsOn || connectorsOn) -> window.api.toolChat(...)` (blocking) else the streaming path.
@@ -195,23 +218,27 @@ Merges four findings into one config module:
- **Why deferred from this PR:** every other consolidation item is behavior-preserving; this one changes what the tool path DOES (starts streaming + thinking), so it needs its own PR + on-device verification. It is the highest-value remaining forked-pipeline.
### C8 — `rag:chat` runs TWO retrieval pipelines on the same query (found by the C7 follow-up sweep)
+
- **Pattern:** forked-pipeline · **Principle:** DRY · **Severity:** high · **Status:** NOT done (behavioral)
- **Locations:** `ipc.ts:771-802` (inline SQL vector search on memories, threshold >=0.2) + `ipc.ts:804-872` (inline FTS on messages/summaries/entities/facts) AND then `ipc.ts:904-905` `universalSearch()` (search.ts - hybrid FTS + LanceDB semantic + RRF fusion + recency boost) on the SAME query.
- **Drift/risk:** the same memories/entities are retrieved twice with DIFFERENT ranking (BM25/cosine + threshold vs RRF, no threshold). The CONTEXT_BLOCK (from inline SQL) and the SOURCES cards (from universalSearch) can disagree; an item below the SQL 0.2 threshold is dropped from context but shown as a source. Project mode (`ipc.ts:737` `ragService.searchProject`) is yet a third retrieval path that misses the hybrid fusion.
- **Fix:** make `universalSearch()` the single retrieval engine for all three chat modes; delete the inline SQL block; pass a `sources`/appName filter param. (Overlaps C4 - same handler.)
### C9 — image-generation intent decided in 3 places that can disagree
+
- **Pattern:** forked-pipeline + duplicated-decision-rule · **Principle:** DRY/SRP · **Severity:** medium · **Status:** NOT done (behavioral)
- **Locations:** renderer regex `looksLikeImageRequest` (`src/renderer/src/lib/image-intent.ts`) auto-switches to image mode at `MemoryChat.tsx:746`; main LLM classifier `classifyIntent` (`ipc.ts:254-280`) decides `intent==='image'`; the model itself can emit a ` ```image ` fenced block parsed at `MemoryChat.tsx:863`. The ` ```image ` format is PRODUCED in `ipc.ts:668` and PARSED by a separate regex in the renderer (not via `parseArtifact`).
- **Drift/risk:** renderer regex and main LLM can disagree on "is this an image request" (e.g. "make a dashboard" - regex no, LLM maybe); the fenced-block format has no single producer/parser definition.
- **Fix:** one intent decision (renderer asks main via an intent IPC, or shares one rule module); define the ` ```image ` fence once and parse it through `parseArtifact`.
### C10 — `maxTokens` default duplicated (same class as P0.3, but MECHANICAL/behavior-preserving)
+
- **Pattern:** duplicate-config-or-type · **Principle:** DRY · **Severity:** low · **Status:** NOT done (cheap, safe)
- **Locations:** `llm.ts` `private maxTokens = 2048` and `SettingsPanel.tsx` DEFAULTS `maxTokens: 2048`. Currently AGREE, but there is no shared constant (unlike ctxSize, which we already moved to `shared/llm-defaults.ts` as `DEFAULT_CTX_SIZE`).
- **Fix:** add `DEFAULT_MAX_TOKENS = 2048` to `shared/llm-defaults.ts`, import in both. This one IS behavior-preserving - could fold into this PR or a trivial follow-up. (The audit also flagged tool-chat's `max_tokens:1024`/`temperature:0.3` magic numbers - fold those into the shared defaults too.)
### C11 — summarization/entity extraction is 4+ independent LLM calls with independently-set strictness
+
- **Pattern:** forked-pipeline · **Principle:** SRP · **Severity:** medium · **Status:** NOT done (behavioral)
- **Locations:** `ipc.ts:371` (memory-eval, applies `memoryStrictness`), `ipc.ts:515` (session summary), `ipc.ts:426` (entity extraction, applies `entityStrictness`), `ipc.ts:488` (per-entity fact summary), plus `updateMasterMemoryIncremental`. Each is its own prompt + LLM call.
- **Drift/risk:** memory-eval strictness and entity strictness are set independently and can disagree on what is worth keeping; no dedup across repeated summarizations. Not a correctness bug today, but a coherence/cost fork.
@@ -224,43 +251,51 @@ Merges four findings into one config module:
## Group D — copy-pasted-helper / forked shared packages
### D1 — `extractJson` duplicated across 10+ pro modules, 3 fallback variants
+
- **Pattern:** copy-pasted-helper · **Principle:** DRY · **Severity:** high
- **Locations (verified, larger than reported):** `pro/main/meetings.ts:71`, `ingest.ts:39`, `crm/agent.ts:66`, `crm/extract.ts:68`, `crm/preferences.ts:92`, `crm/actions.ts:135`, `dictation/sinks/memory-ingest.ts:16`, **plus** `crm/calendar.ts:58` (`[`/`]` array variant), `crm/layout.ts:99`, `crm/organize.ts:73`.
- **Fix:** one helper in a shared pro util (`pro/main/crm/json.ts` or a `@offgrid/core` util) with `mode: 'object'|'array'` and configurable fallback. **Drift risk (real today):** `preferences.ts` returns `s` on failure, others return `'{}'`, array variants return `']'`-slice — inconsistent silent-fail behavior across every LLM JSON parse.
### D2 — Desktop reimplements `@offgrid/artifacts` and `@offgrid/rag` extraction
+
- **Pattern:** forked-pipeline/duplicate-config-or-type · **Principle:** DRY · **Severity:** high
- **Locations:** `src/main/artifacts.ts:33-40` + `ArtifactCanvas.tsx:11-18,85-130` reimplement `ArtifactKind`/`isLiveKind`/`artifactTitle`/`buildSrcDoc` — **verified those exist in `../shared/packages/artifacts/src/index.ts:31,52,95` and desktop does NOT depend on `@offgrid/artifacts` at all**. Also `src/main/files.ts:13-76` re-routes file-kind detection that `@offgrid/rag` `extract.ts` owns.
- **Correction to the audit:** the claim that `files.ts` `AUDIO_EXT` is "missing `oga` and `aiff`" is **false** — `files.ts:14` includes both. The routing duplication is real; that specific drift-example is not.
- **Fix:** add `@offgrid/artifacts` dep; import `isLiveKind/artifactTitle/buildSrcDoc` and the canonical `ArtifactKind`. Reuse `@offgrid/rag`'s `extractContent` in `files.ts`, wrapping with desktop temp-file/userData persistence. **Coupling:** anchors A2, B1, P0.2 — do the import swap first, then those maps reference the shared type.
### D3 — `timeAgo` duplicated (renderer) + `formatTimestamp` (pro)
+
- **Pattern:** copy-pasted-helper · **Severity:** medium/low
- **Locations:** `MemoryChat.tsx:183-195`, `ProjectsScreen.tsx:104-114`, and pro `NotificationList.tsx:27-44` vs `clipboard/clipboardUtil.ts:113-122`.
- **Fix:** `src/renderer/src/lib/time.ts` `timeAgo(input: string|number|Date)`; pro imports (or a pro `timeUtils.ts`). **Drift risk:** timezone/format fix applied to one copy only.
### D4 — Pro CRM helper trio: `today()`, `hasColumn()`, `notify()`
+
- **Pattern:** copy-pasted-helper · **Severity:** medium
- **Locations:** `today()` `crm/skills-engine.ts:30-32` + `crm/proactive.ts:18-20`; `hasColumn()` `crm/preferences.ts:38-40` + `crm/actions.ts:74-76`; `notify()` `crm/skills-engine.ts:19-28` + `crm/proactive.ts:24-33` (**with variance** — skills truncates body to 240, proactive doesn't).
- **Fix:** `pro/main/crm/utils.ts` (or `time.ts`/`schema.ts`/`notify.ts`); `notify(opts, {truncate?})`. **Drift risk:** inconsistent notification truncation UX today.
### D5 — Image-data-URL + message-construction + thinking-payload helpers
+
- **Pattern:** copy-pasted-helper · **Severity:** medium/low
- **Locations:** data-URL build `llm.ts:576-586,652-656`, `model-server.ts:263-265`, `tools.ts:284-286`; message-array build `llm.ts:569-594` vs `651-663`; thinking payload `llm.ts:675-680`.
- **Caveat (verified):** `tools.ts` already imports `buildUserContent` from `tool-content.ts` — a partial seam exists. **Check `tool-content.ts` first** and extend it rather than making a fourth copy. Extract `imageToDataUrl()`, `buildMessages()`, `buildThinkingPayload()` around it. **Drift risk:** webp/MIME support added to one path, missed in another.
### D6 — HTTP retry-with-deadline duplicated (`model-server.ts`)
+
- **Pattern:** copy-pasted-helper · **Severity:** medium
- **Locations:** `model-server.ts:187-217` (`proxyToLlama`) + `504-543` (`callLlamaJson`).
- **Fix:** `retryWithDeadline(deadline, attempt)`. **Drift risk:** off-by-one on the deadline check affects only one of streaming/buffered paths.
### D7 — Embeddings serialization + service coupling + BrowserWindow broadcast
+
- **Pattern:** copy-pasted-helper · **Principle:** DRY/DIP · **Severity:** medium
- **Locations:** serialize/parse `ipc.ts:318-319,565-566,591-592,771-772`, `database.ts:50-72`, `search.ts:335-336`, rag/store parseEmbedding; broadcast idiom `ipc.ts:338-344,465-473,1328-1335`; `embed()` wrapper `embeddings.ts:29`.
- **Fix:** `serializeVector`/`deserializeVector` + `embed(text)` in `embeddings.ts`; `broadcast(channel, payload)` module helper. **Drift risk:** format/caching change misses a call site → incompatible stored vectors, score=0 results vanish. **Note:** deferred embedding dimension validation is a nice-to-have, not dedup — keep it out of scope unless bundled.
- **Coupling:** all in `ipc.ts` — bundle with C4 under one owner.
### D8 — `existing()` binary-resolver + pro `TypeIcon` component
+
- **Pattern:** copy-pasted-helper · **Severity:** low/high
- **Locations:** `existing()` `whisper-cli.ts:19-28` + `parakeet-cli.ts:24-33` → `transcription/bin-resolution.ts`. `TypeIcon` `pro/renderer/ClipboardPopup.tsx:14-19` + `ClipboardScreen.tsx:74-79` → `clipboardUtil.ts` (alongside existing `CONTENT_TYPE_FILTERS`/`typeLabel`). Pro notification filter/count `NotificationList.tsx:59-61,117-119` → `notificationMatches()` (folds into A3).
@@ -298,9 +333,9 @@ Merges four findings into one config module:
3. **`whisper/parakeet isAvailable()`** — correct polymorphism; each service owns its own check. Audit itself marked it intended.
4. **`mfluxAvailable()` "duplicated"** — actually defined once in `mflux.ts` and imported; already DRY.
5. **Skill trigger branching (`skills-engine.ts:122`)** — single centralized dispatch; exemplary, not a violation.
-6. **ModelsScreen vision-kind checks as "single boundary"** (finding #57) — the *renderer-boundary* framing is right, but the SAME lines appear in the branch-on-concrete finding (#38); folded into F3, not double-counted. The "no fix needed" duplicate is dropped.
+6. **ModelsScreen vision-kind checks as "single boundary"** (finding #57) — the _renderer-boundary_ framing is right, but the SAME lines appear in the branch-on-concrete finding (#38); folded into F3, not double-counted. The "no fix needed" duplicate is dropped.
7. **`VaultScreen TYPES` "only used locally"** — correct single-source pattern; speculative export (YAGNI). Keep as-is.
-8. **`ArtifactCanvas` kind branching for rendering** — a legitimate single rendering boundary; not scattered. Only the *gallery caller* (B1) is the real finding.
+8. **`ArtifactCanvas` kind branching for rendering** — a legitimate single rendering boundary; not scattered. Only the _gallery caller_ (B1) is the real finding.
9. **Embedding dimension schema validation** (part of finding #28) — a correctness/robustness feature, not deduplication; out of scope for this plan (note it in the gaps backlog instead).
-**Corrections to audit claims:** `files.ts:14` AUDIO_EXT is NOT missing `oga`/`aiff` (both present) — that drift example is wrong, routing dup is still valid (D2). `extractJson` is 10+ sites with `[`/`]` variants, not 7 (D1). `tools.ts` already uses `buildUserContent` — D5 must extend that seam, not add a fourth copy.
\ No newline at end of file
+**Corrections to audit claims:** `files.ts:14` AUDIO_EXT is NOT missing `oga`/`aiff` (both present) — that drift example is wrong, routing dup is still valid (D2). `extractJson` is 10+ sites with `[`/`]` variants, not 7 (D1). `tools.ts` already uses `buildUserContent` — D5 must extend that seam, not add a fourth copy.
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 68a93373..ce4eb735 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -1,6 +1,6 @@
# Off Grid AI Desktop — Design
-The desktop adaptation of the Off Grid design philosophy. The brand canon is the mobile docs (`../../mobile/docs/design/DESIGN_PHILOSOPHY_SYSTEM.md` + `VISUAL_HIERARCHY_STANDARD.md`); **this doc keeps the same soul and adapts it for a desktop app.** Where this conflicts with the mobile docs on *layout/interaction*, desktop wins; where it conflicts on *brand* (font, color, voice), the brand wins.
+The desktop adaptation of the Off Grid design philosophy. The brand canon is the mobile docs (`../../mobile/docs/design/DESIGN_PHILOSOPHY_SYSTEM.md` + `VISUAL_HIERARCHY_STANDARD.md`); **this doc keeps the same soul and adapts it for a desktop app.** Where this conflicts with the mobile docs on _layout/interaction_, desktop wins; where it conflicts on _brand_ (font, color, voice), the brand wins.
---
@@ -9,7 +9,7 @@ The desktop adaptation of the Off Grid design philosophy. The brand canon is the
**Brutalist, minimal, terminal-inspired.** Functionality over decoration. Clarity, density, respect for attention. Silence over noise. Remove before adding.
- **Typeface: Menlo (monospace) everywhere.** No sans UI font, no mixed families.
-- **Single accent: emerald** — `#34D399` (dark) / `#059669` (light). Used *sparingly* — active states, focus, primary actions, links, success. Everything else is monochrome.
+- **Single accent: emerald** — `#34D399` (dark) / `#059669` (light). Used _sparingly_ — active states, focus, primary actions, links, success. Everything else is monochrome.
- **Base:** pure black `#0A0A0A` (dark) / white `#FFFFFF` (light). Three surface tiers: `background → surface → surfaceLight`.
- **Hierarchy through size + weight + opacity, never color.** Weights stay light (≤ medium); avoid bold for emphasis.
- **Flat & sharp:** 8px radius, hairline borders, no gradients, no heavy shadows, no emojis, no decorative animation.
@@ -20,15 +20,15 @@ The desktop adaptation of the Off Grid design philosophy. The brand canon is the
Desktop is a **wide, mouse-driven, multi-window** canvas. Adapt accordingly:
-| Concern | Mobile | **Desktop** |
-|---|---|---|
-| Canvas | one narrow column, vertical scroll | **wide** — multi-column grids, master-detail, side panels, dense tables. Use the width; don't center a phone-width strip. |
-| Navigation | bottom tabs | **persistent left icon-rail sidebar** (active = emerald). |
-| Pointer | touch, ≥44px targets, `hitSlop` | **mouse** — precise targets fine; **hover is a first-class state** (reveal actions, brighten borders/text on hover). |
-| Density | compact | **denser still** — desktop shows more at once (5-col galleries, 3-col dashboards, long lists). |
-| Input | on-screen | **keyboard** — shortcuts (space/arrows in Replay, Cmd+[ / Cmd+] nav), Enter-to-submit. |
-| Chrome | full-screen flows | **window + menu-bar tray** (pause/recalibrate), title is always "Off Grid AI Desktop". |
-| Detail | push a screen | **detail screens / side panels** (e.g. click a connector row → its own detail view). |
+| Concern | Mobile | **Desktop** |
+| ---------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
+| Canvas | one narrow column, vertical scroll | **wide** — multi-column grids, master-detail, side panels, dense tables. Use the width; don't center a phone-width strip. |
+| Navigation | bottom tabs | **persistent left icon-rail sidebar** (active = emerald). |
+| Pointer | touch, ≥44px targets, `hitSlop` | **mouse** — precise targets fine; **hover is a first-class state** (reveal actions, brighten borders/text on hover). |
+| Density | compact | **denser still** — desktop shows more at once (5-col galleries, 3-col dashboards, long lists). |
+| Input | on-screen | **keyboard** — shortcuts (space/arrows in Replay, Cmd+[ / Cmd+] nav), Enter-to-submit. |
+| Chrome | full-screen flows | **window + menu-bar tray** (pause/recalibrate), title is always "Off Grid AI Desktop". |
+| Detail | push a screen | **detail screens / side panels** (e.g. click a connector row → its own detail view). |
---
@@ -53,12 +53,12 @@ Font font-mono everywhere
Same 5 roles as mobile, scaled for a monitor:
-| Role | Tailwind | Use |
-|---|---|---|
-| **TITLE** | `text-lg tracking-tight text-white` | one per screen (page title) |
-| **BODY** | `text-sm text-neutral-200/300` | primary content, list items, inputs, buttons |
-| **SUBTITLE** | `text-sm text-white` / section `
` | section/card/modal titles |
-| **DESCRIPTION** | `text-xs text-neutral-500` | explanatory text under a title |
+| Role | Tailwind | Use |
+| ---------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------ |
+| **TITLE** | `text-lg tracking-tight text-white` | one per screen (page title) |
+| **BODY** | `text-sm text-neutral-200/300` | primary content, list items, inputs, buttons |
+| **SUBTITLE** | `text-sm text-white` / section `
` | section/card/modal titles |
+| **DESCRIPTION** | `text-xs text-neutral-500` | explanatory text under a title |
| **META / LABEL** | `text-[11px]` or `text-[10px]`, labels `uppercase tracking-wide text-neutral-500/600` | timestamps, counts, tags, section markers ("whispers") |
Rules: hierarchy from size+opacity (not color); section labels are tiny, **uppercase**, widely tracked, muted; metadata whispers.
@@ -101,7 +101,7 @@ Rules: hierarchy from size+opacity (not color); section labels are tiny, **upper
## Checklist
- [ ] Menlo (`font-mono`) everywhere; weights light.
-- [ ] Emerald is the *only* accent, used sparingly; rest monochrome.
+- [ ] Emerald is the _only_ accent, used sparingly; rest monochrome.
- [ ] Uses the full width — multi-column / master-detail where it helps.
- [ ] Hierarchy from size + opacity, not color; labels tiny/uppercase/muted.
- [ ] Hover states reveal/brighten; focus is emerald.
diff --git a/docs/DEVICE_TEST_LOG.md b/docs/DEVICE_TEST_LOG.md
index 53d2452f..a866e0d8 100644
--- a/docs/DEVICE_TEST_LOG.md
+++ b/docs/DEVICE_TEST_LOG.md
@@ -67,7 +67,7 @@ value had zero coverage.
- 🔁 **D30 PRIVACY / SECURITY** — FIXED (needs on-device confirm). "Delete all" never cleared `secrets` (OAuth tokens) or `connectors` → live third-party credentials remained. Now both are in the core registry. Test: `data-privacy-delete-all.dbtest.ts` (connectors + secrets → 0 after wipe; red on HEAD, falsified). **On-device:** connect an OAuth connector, "Delete all my data", confirm the connector is gone and reconnect requires fresh auth. `data-privacy.ts`
- ⏳ **D31 ARCH (CONFIRMED — deferred to a dedicated dual-build pass)** — Pro sections `ProactiveSection`/`SecretaryPrefs`/`ProPlanSection` (+ PlanInfo/PlanDevice, ~230 lines) are defined & rendered inline in CORE gated by `isPro`; `registerProSettings` is a stub and core never consumes `getRegisteredSettingsSections()`. Real open-core violation (pro logic in the public repo). All 3 use only `window.api`, so the MOVE is mechanical — but the correct wiring + verification is not, and getting open-core wrong is high-cost. PLAN: (1) new `pro/renderer/settings-sections.tsx` — the 3 components, each self-wrapping in `SettingsCard` (title+summary), exported as registerable sections with ids `proactive`/`secretary`/`pro-plan`; (2) `registerProSettings` registers them via `api.registerSettingsSection`; (3) core `Settings.tsx` deletes the 3 inline components + the `isPro ? : ` blocks, and instead for each pro SLOT renders the registered section if present else a `ProPlaceholder` (slot→registered-or-placeholder); (4) VERIFY BOTH: `OFFGRID_PRO=0` electron-vite build → placeholders, no pro import in core bundle; pro build → sections registered (check `registerProSettings` runs in the pro renderer bootstrap before Settings mounts) + render. Deferred because that dual-build/no-leak confidence bar isn't reachable in the current deep session — it deserves a fresh, dedicated pass.
- ⚠️ **D32 MISSING-FEATURE (needs product decision)** — `memoryStrictness`/`entityStrictness` are plumbed, read (`ipc.ts:205,262`, default `balanced`) and persist correctly (guarded by `database-integration.dbtest.ts:344`), but no renderer control surfaces them → stuck at `balanced`. NOT a bug (the default works + saveSetting round-trips); it's an unexposed knob. Surfacing it is a feature-add needing a product/design decision (where in Settings, labels, whether to expose at all) — not a fix. Left open as a feature request.
-- ❌ **D33 NOT-A-BUG** (investigated, dismissed). Claim: `resetDefaults` diverges shown-vs-used because it sets `{...prev, ...DEFAULTS}` locally but persists `DEFAULTS` alone. FALSE — `llm.setSettings` is PATCH-MERGE (`llm.ts` applies each present field, leaves absent ones unchanged; that's why per-field `update()` works). So the DEFAULTS patch resets its 13 fields on the backend and leaves `performanceMode` (absent from DEFAULTS) unchanged — exactly matching the local `{...prev,...DEFAULTS}` which also keeps `prev.performanceMode`. No divergence. (Whether "Reset to defaults" *should* also reset performanceMode is a separate minor product choice — it has its own mode-preset control.)
+- ❌ **D33 NOT-A-BUG** (investigated, dismissed). Claim: `resetDefaults` diverges shown-vs-used because it sets `{...prev, ...DEFAULTS}` locally but persists `DEFAULTS` alone. FALSE — `llm.setSettings` is PATCH-MERGE (`llm.ts` applies each present field, leaves absent ones unchanged; that's why per-field `update()` works). So the DEFAULTS patch resets its 13 fields on the backend and leaves `performanceMode` (absent from DEFAULTS) unchanged — exactly matching the local `{...prev,...DEFAULTS}` which also keeps `prev.performanceMode`. No divergence. (Whether "Reset to defaults" _should_ also reset performanceMode is a separate minor product choice — it has its own mode-preset control.)
- 🔁 **D34 PANEL-DESYNC §A** — FIXED (needs on-device confirm). Optimistic toggles (proactive/residency/auto-update/beta/tool-enable/TTS-voice) did setX + fire-and-forget persist → on a (rare) persist failure the control lied then flipped back on next mount. Fix: shared `persistToggle` reverts to prev if the persist rejects. Test: `persist-toggle.test.ts` (revert contract; falsified). **On-device:** hard to trigger (needs a persist failure); low-severity. `lib/persist-toggle.ts`
- ⏳ **D35 RACE (low-severity, deferred with plan)** — delete-all/clearCategory don't pause the capture pipeline. Assessed: SQLite serializes writes (no mid-statement interleaving — capture just accrues NEW rows after the wipe, not "resurrected" ones), and an in-flight vector read holds its own resolved lancedb handle (resetVectors only nulls the cache; a new read reopens). The one real transient is a concurrent vector read during the ~ms `clearDirs(lancedb)` window → self-recovers on reopen. Low severity on a rare deliberate action. Correct fix is cross-cutting + open-core-sensitive: core `deleteAllData` can't reach pro's capture loop, so add a `callHook('data:before-wipe')`/`'data:after-wipe')` seam that pro registers to pause+resume capture around the wipe — needs live capture verification, so deferred (same confidence-bar reason as D31).
diff --git a/docs/ENTERPRISE_BUILD_PLAN.md b/docs/ENTERPRISE_BUILD_PLAN.md
index 41a0ddf8..c15697ae 100644
--- a/docs/ENTERPRISE_BUILD_PLAN.md
+++ b/docs/ENTERPRISE_BUILD_PLAN.md
@@ -6,15 +6,15 @@ This plan **inherits the entire 5-plane agentic architecture** from the stack na
Physical plane, is accounted for below and mapped to Off Grid.
The navigator was written for a multi-tenant regulated bank on cloud/on-prem. Off Grid is
-**local-first, on-device, single-org-per-deployment, on-prem.** That changes *how* each
-layer is realized, not *whether* it exists. Six rules drive every mapping:
+**local-first, on-device, single-org-per-deployment, on-prem.** That changes _how_ each
+layer is realized, not _whether_ it exists. Six rules drive every mapping:
1. **The work is the data source.** The "data plane" is capture (screen / messages / calls
on org time) + optional connectors — not a cloud data lake.
2. **The substrate is distributed.** Data lands per-device (SQLite) plus a thin org **Brain**
that holds only distilled knowledge — not one central lake.
3. **The AI plane came first.** On a laptop there's no model serving unless we build it, so
- we built it first (`model-server.ts`). The control plane *wraps* the AI plane we already
+ we built it first (`model-server.ts`). The control plane _wraps_ the AI plane we already
have — the inverse of the bank's "gateway first" order.
4. **Worker owns raw; org sees distilled.** Raw capture never leaves the worker's device by
default. Only distilled SOPs/patterns go up. This is the access-control spine **and** the
@@ -37,8 +37,8 @@ data never leaves your control.
**But the wedge is different, and it's ours.** Those players sell top-down into compliance,
back-office, document workflows. We land **bottom-up through a frontline/sales-productivity
-tool people actually want** — the on-device node that helps the worker *and* observes the
-work. The control plane rides in *with* the node. So we don't have to win a 12-month
+tool people actually want** — the on-device node that helps the worker _and_ observes the
+work. The control plane rides in _with_ the node. So we don't have to win a 12-month
compliance procurement to get installed; the productivity tool gets us in, and the common
control plane is what we already are once we're there. The frontline use case is the
distribution mechanism for the control-plane vision — not a separate bet.
@@ -47,14 +47,14 @@ distribution mechanism for the control-plane vision — not a separate bet.
## The six products (who owns what)
-| Product | Role | In navigator terms |
-|---|---|---|
-| **Personal AI** (Desktop + Mobile) | Each person's private copilot, memory, and capture. The worker's benefit. | Consumption (D) + local Data (A) + local AI plane (B) |
-| **Gateway** (`:7878`, per device) | Routes every model/tool call. Controls egress. Logs everything. | Control plane (C) + AI-plane serving (B15/B7) |
-| **Brain** (org) | The distilled org knowledge — SOPs, patterns, "what good is." Serves it back. | AI-plane substrate (B3/B5a/B11) + Data landing (A10) |
-| **Fleet Control** (on-prem admin **app**) | **MDM for AI.** Connects to the fleet of nodes. Enrolls them, provisions policy + knowledge + intelligence config down, pulls audit + telemetry + distilled learnings up, renders the DPO/compliance view. Does **not** run the intelligence or enforce policy itself — the nodes do. Never cloud. | Control plane (C) *define/observe* + Regulatory (E) |
-| **Sync** (EasyShare) | Desktop ↔ mobile, device ↔ org transport. | cross-cutting transport |
-| **Learning loop** (batch) | Observe → find patterns → write SOPs → grade quality. | Consumption flywheel (D8) + AI orchestration (D1) |
+| Product | Role | In navigator terms |
+| ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
+| **Personal AI** (Desktop + Mobile) | Each person's private copilot, memory, and capture. The worker's benefit. | Consumption (D) + local Data (A) + local AI plane (B) |
+| **Gateway** (`:7878`, per device) | Routes every model/tool call. Controls egress. Logs everything. | Control plane (C) + AI-plane serving (B15/B7) |
+| **Brain** (org) | The distilled org knowledge — SOPs, patterns, "what good is." Serves it back. | AI-plane substrate (B3/B5a/B11) + Data landing (A10) |
+| **Fleet Control** (on-prem admin **app**) | **MDM for AI.** Connects to the fleet of nodes. Enrolls them, provisions policy + knowledge + intelligence config down, pulls audit + telemetry + distilled learnings up, renders the DPO/compliance view. Does **not** run the intelligence or enforce policy itself — the nodes do. Never cloud. | Control plane (C) _define/observe_ + Regulatory (E) |
+| **Sync** (EasyShare) | Desktop ↔ mobile, device ↔ org transport. | cross-cutting transport |
+| **Learning loop** (batch) | Observe → find patterns → write SOPs → grade quality. | Consumption flywheel (D8) + AI orchestration (D1) |
---
@@ -65,18 +65,18 @@ the personal AI, capture, the learning loop, and **enforces its own policy — e
Fleet Control is the **app that connects to the fleet** to define and observe; it does not
run the intelligence or enforce policy itself. Every control has two halves:
-| Control | Node — *enforce / emit* | Fleet Control — *define / observe* |
-|---|---|---|
-| Gateway (C1) | routes & runs locally | — |
-| Input/output policy (C2/C3) | enforces locally | authors the rules |
-| Egress gate (C16) | blocks/redacts on-device | sets what's allowed out |
-| Audit (C7) | writes every call locally | aggregates + DPO view + export (E1/E6) |
-| Observability (C8) | emits traces | fleet dashboards |
-| RBAC (C5) | enforces on-device | authors who-sees-what |
-| Identity (C4) | holds its device token | issues tokens, enrolls devices |
-| Kill switch (C9c) | executes the halt | triggers it across the fleet |
-| Eval / drift (C9/C9a) | runs local checks | fleet-wide gates + rollup |
-| Intelligence config | runs the SOPs / models / agents it was given | **provisions which intelligence each role gets** (from Brain) |
+| Control | Node — _enforce / emit_ | Fleet Control — _define / observe_ |
+| --------------------------- | -------------------------------------------- | ------------------------------------------------------------- |
+| Gateway (C1) | routes & runs locally | — |
+| Input/output policy (C2/C3) | enforces locally | authors the rules |
+| Egress gate (C16) | blocks/redacts on-device | sets what's allowed out |
+| Audit (C7) | writes every call locally | aggregates + DPO view + export (E1/E6) |
+| Observability (C8) | emits traces | fleet dashboards |
+| RBAC (C5) | enforces on-device | authors who-sees-what |
+| Identity (C4) | holds its device token | issues tokens, enrolls devices |
+| Kill switch (C9c) | executes the halt | triggers it across the fleet |
+| Eval / drift (C9/C9a) | runs local checks | fleet-wide gates + rollup |
+| Intelligence config | runs the SOPs / models / agents it was given | **provisions which intelligence each role gets** (from Brain) |
**Fleet Control pushes down:** policy + knowledge (from Brain) + intelligence config.
**Fleet Control pulls up:** audit + telemetry + distilled learnings.
@@ -86,15 +86,15 @@ Enforcement is node-local by design — a node governs itself with or without a
The system **doubles as both** a work-observation layer and an org-data ingestion layer. Two
sources feed Brain, so the intelligence baked into nodes is grounded in **what people do
-*and* the org's real data** — not observation alone:
+_and_ the org's real data** — not observation alone:
-1. **Observed work (push, from nodes).** Capture on org time → distilled learnings flow *up*
- from devices. Worker-owns-raw; only the distilled layer leaves the device. *(A1 capture.)*
+1. **Observed work (push, from nodes).** Capture on org time → distilled learnings flow _up_
+ from devices. Worker-owns-raw; only the distilled layer leaves the device. _(A1 capture.)_
2. **Org digital data (pull, via connectors).** An **org-side ingest service** pulls from
databases, warehouses, SaaS, and document stores → ETL → PII mask → index into Brain.
This is org-owned data by definition; access is governed by A11/C5. Runs next to Brain on
- the org's infra (warehouses are org infra), behind the same policy. *(A1 connectors, A3
- CDC, A3a contracts, A7/A9 mask.)*
+ the org's infra (warehouses are org infra), behind the same policy. _(A1 connectors, A3
+ CDC, A3a contracts, A7/A9 mask.)_
```
Org systems (DBs · warehouses · SaaS · docs) Nodes (Desktop / Mobile)
@@ -113,18 +113,18 @@ sources feed Brain, so the intelligence baked into nodes is grounded in **what p
> Bank version: get data out of source systems, prep, govern, land. Off Grid version: the
> work itself is the source; it lands on-device and (distilled) in Brain.
-| # | Navigator component | Off Grid realization | Owner | Status |
-|---|---|---|---|---|
-| A1 | Source systems | **Two first-class sources.** (1) **Capture** (screen→OCR, messages, calls) — the work itself, on the node. (2) **Org digital data** — databases, warehouses, SaaS, document stores — via connectors on the org-side ingest service. | Personal AI capture · Brain-side connectors | ✅ capture (`watcher.ts`, `vision.ts`, `ocr.swift`, meeting recorder); ❌ enterprise connectors build |
-| A3 | CDC / ingestion | Node: continuous on-device ingest of capture. Org: CDC/connector pulls from DBs & warehouses → ETL → mask → Brain. | Personal AI ingest · Brain ingest service | ✅ `watcher.ts`, `ingest.ts`; ❌ org ingest service |
-| A3a | Schema registry / contracts | The observation/entity schema — stable contract for what capture emits. | Brain · ingest | ✅ `crm/schema.ts` |
-| A5 | Data catalog | Index of what's known — entities, sources, where each came from. | Brain | ⚠️ partial (`EntityGraph`, `database.ts`) |
-| A7 | PII discovery + classification | Detect & tag PII in captured content. **Critical** — capture sees everything on screen. On-device (Presidio-class). | Gateway input policy · ingest | ❌ build |
-| A7a | Consent management | Per-device, per-purpose opt-in; the **"on org time" boundary**; visible recording indicator. | Fleet Control policy · Personal AI | ⚠️ consumer opt-in pattern exists; org policy build |
-| A9 | PII masking + synthetic | Redact PII **before anything leaves the device**. Same engine the egress gate uses. | Gateway egress (C16) | ❌ build |
-| A10 | Data lake (zones) | No cloud lake. **Per-device SQLite** (raw, stays local) + **Brain** (distilled, org-shared). The zone boundary is the device edge. | Personal AI · Brain | ✅ SQLite; ❌ Brain build |
-| A11 | Fine-grained access | Who in the org sees which distilled knowledge. Enforces **worker-owns-raw / org-sees-distilled**. | Fleet Control · Brain | ❌ build |
-| A12a | Retention + erasure | "Delete this person" across device + Brain + memory. File-based markdown memory makes erasure `git revert`, not a vector rebuild (navigator's own note). | Fleet Control · Brain | ❌ build |
+| # | Navigator component | Off Grid realization | Owner | Status |
+| ---- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
+| A1 | Source systems | **Two first-class sources.** (1) **Capture** (screen→OCR, messages, calls) — the work itself, on the node. (2) **Org digital data** — databases, warehouses, SaaS, document stores — via connectors on the org-side ingest service. | Personal AI capture · Brain-side connectors | ✅ capture (`watcher.ts`, `vision.ts`, `ocr.swift`, meeting recorder); ❌ enterprise connectors build |
+| A3 | CDC / ingestion | Node: continuous on-device ingest of capture. Org: CDC/connector pulls from DBs & warehouses → ETL → mask → Brain. | Personal AI ingest · Brain ingest service | ✅ `watcher.ts`, `ingest.ts`; ❌ org ingest service |
+| A3a | Schema registry / contracts | The observation/entity schema — stable contract for what capture emits. | Brain · ingest | ✅ `crm/schema.ts` |
+| A5 | Data catalog | Index of what's known — entities, sources, where each came from. | Brain | ⚠️ partial (`EntityGraph`, `database.ts`) |
+| A7 | PII discovery + classification | Detect & tag PII in captured content. **Critical** — capture sees everything on screen. On-device (Presidio-class). | Gateway input policy · ingest | ❌ build |
+| A7a | Consent management | Per-device, per-purpose opt-in; the **"on org time" boundary**; visible recording indicator. | Fleet Control policy · Personal AI | ⚠️ consumer opt-in pattern exists; org policy build |
+| A9 | PII masking + synthetic | Redact PII **before anything leaves the device**. Same engine the egress gate uses. | Gateway egress (C16) | ❌ build |
+| A10 | Data lake (zones) | No cloud lake. **Per-device SQLite** (raw, stays local) + **Brain** (distilled, org-shared). The zone boundary is the device edge. | Personal AI · Brain | ✅ SQLite; ❌ Brain build |
+| A11 | Fine-grained access | Who in the org sees which distilled knowledge. Enforces **worker-owns-raw / org-sees-distilled**. | Fleet Control · Brain | ❌ build |
+| A12a | Retention + erasure | "Delete this person" across device + Brain + memory. File-based markdown memory makes erasure `git revert`, not a vector rebuild (navigator's own note). | Fleet Control · Brain | ❌ build |
---
@@ -132,17 +132,17 @@ sources feed Brain, so the intelligence baked into nodes is grounded in **what p
> The engine. This is largely **already built** — it's what `model-server.ts` is.
-| # | Navigator component | Off Grid realization | Owner | Status |
-|---|---|---|---|---|
-| B1 | Doc parsing + chunking | OCR + audio transcription + doc extractors. | Gateway (AI plane) | ✅ `rag/extractors`, whisper, vision |
-| B2a | Reranking + hybrid search | Retrieval over captured + Brain content; BM25 + vector + rerank. | Gateway | ⚠️ embeddings exist; hybrid + rerank build |
-| B3 | Vector store / KB index | On-device index (personal) + Brain index (org). | Personal AI · Brain | ⚠️ embeddings + SQLite; dedicated index build |
-| B5a | Provenance + citation | **Every SOP/answer traces to the captured source** (which screen, which call, when). The auditability beam. *(This is the "SANN-equivalent.")* | Brain · Gateway output policy | ❌ build |
-| B7 | Tool layer (MCP) | `/mcp` — on-device models + actions + org connectors as scoped, audited tools. | Gateway | ✅ `mcp-server.ts`, extend with scope+audit |
-| B9 | Sandboxed code execution | Agents run untrusted code in isolation (microVM), never the host. | Gateway | ❌ build (later) |
-| B11 | Memory (sidecar, 4 flavors) | Exactly Off Grid's memory: short-term, long-term vector, entity graph, file-based markdown. **Personal memory** + **org memory (Brain)**. | Personal AI · Brain | ✅ strong (`crm/*`, observations, memory) |
-| B15 | Model serving / inference | Bundled llama-server, whisper, TTS, diffusion — unified at `:7878`. | Gateway | ✅ strong (`model-server.ts`) |
-| B16 | Fine-tuning + privacy ML | Adapt the local SLM to the org's domain & SOPs (LoRA), on-device or in Brain. | Brain | ❌ build (later) |
+| # | Navigator component | Off Grid realization | Owner | Status |
+| --- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | --------------------------------------------- |
+| B1 | Doc parsing + chunking | OCR + audio transcription + doc extractors. | Gateway (AI plane) | ✅ `rag/extractors`, whisper, vision |
+| B2a | Reranking + hybrid search | Retrieval over captured + Brain content; BM25 + vector + rerank. | Gateway | ⚠️ embeddings exist; hybrid + rerank build |
+| B3 | Vector store / KB index | On-device index (personal) + Brain index (org). | Personal AI · Brain | ⚠️ embeddings + SQLite; dedicated index build |
+| B5a | Provenance + citation | **Every SOP/answer traces to the captured source** (which screen, which call, when). The auditability beam. _(This is the "SANN-equivalent.")_ | Brain · Gateway output policy | ❌ build |
+| B7 | Tool layer (MCP) | `/mcp` — on-device models + actions + org connectors as scoped, audited tools. | Gateway | ✅ `mcp-server.ts`, extend with scope+audit |
+| B9 | Sandboxed code execution | Agents run untrusted code in isolation (microVM), never the host. | Gateway | ❌ build (later) |
+| B11 | Memory (sidecar, 4 flavors) | Exactly Off Grid's memory: short-term, long-term vector, entity graph, file-based markdown. **Personal memory** + **org memory (Brain)**. | Personal AI · Brain | ✅ strong (`crm/*`, observations, memory) |
+| B15 | Model serving / inference | Bundled llama-server, whisper, TTS, diffusion — unified at `:7878`. | Gateway | ✅ strong (`model-server.ts`) |
+| B16 | Fine-tuning + privacy ML | Adapt the local SLM to the org's domain & SOPs (LoRA), on-device or in Brain. | Brain | ❌ build (later) |
---
@@ -151,21 +151,21 @@ sources feed Brain, so the intelligence baked into nodes is grounded in **what p
> The gateway spine. Wraps the AI plane we already have. **This is the bulk of the new
> build**, and where Fleet Control plugs in.
-| # | Navigator component | Off Grid realization | Owner | Status |
-|---|---|---|---|---|
-| C1 | AI gateway | `:7878` becomes a real chokepoint: routing local + **leashed cloud** (rule 5). | Gateway | ⚠️ started — single local model, add routing |
-| C2 | Input policy / guardrails | Injection scan — **especially of captured content** (hostile indirect input). | Gateway pre-hook | ❌ build |
-| C3 | Output policy + grounding | No ungrounded SOP ships — must cite a real observation (B5a) or it's blocked/flagged. | Gateway post-hook | ❌ build |
-| C4 | Identity + token issuance | Per-device identity (collapses for one user, **re-emerges for the fleet**). | Fleet Control | ❌ build |
-| C5 | RBAC / ABAC | Who sees which distilled knowledge; **enforces worker-owns-raw** (rule 4). | Fleet Control · Gateway | ❌ build |
-| C7 | Audit log + lineage | Every model/tool call + **what left the device**. The DPO's single evidence stream. | Gateway → Fleet Control | ❌ build — **START HERE** |
-| C8 | Observability + tracing | Tokens, latency, full trace per person/feature; replay any answer. | Gateway → Fleet Control | ❌ build |
-| C9 | Eval + red teaming | Quality gate on the local model **and** on SOP quality. Drift checks. | Fleet Control · Brain | ❌ build |
-| C9a | Bias + fairness | For consequential frontline decisions. | Fleet Control | ❌ build (later) |
-| C9c | Incident response + runbooks | **Kill switch** ("stop all AI / pause capture") + runbooks. | Fleet Control | ❌ build — kill switch early |
-| C14 | FinOps + cost | Per-device compute/battery budget; $ only matters once cloud fallback is on. | Fleet Control | ❌ build (light) |
-| C16 | DLP + exfil prevention | **The egress gate** — what may leave to a cloud model / connector / other device. The privacy guarantee (rule 5). | Gateway | ❌ build — **START HERE** |
-| C21 | Durable execution | Long agent runs (a batch SOP-mining job over a week of capture) resume, not restart. | Gateway / runtime | ❌ build (later) |
+| # | Navigator component | Off Grid realization | Owner | Status |
+| --- | ---------------------------- | ----------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------- |
+| C1 | AI gateway | `:7878` becomes a real chokepoint: routing local + **leashed cloud** (rule 5). | Gateway | ⚠️ started — single local model, add routing |
+| C2 | Input policy / guardrails | Injection scan — **especially of captured content** (hostile indirect input). | Gateway pre-hook | ❌ build |
+| C3 | Output policy + grounding | No ungrounded SOP ships — must cite a real observation (B5a) or it's blocked/flagged. | Gateway post-hook | ❌ build |
+| C4 | Identity + token issuance | Per-device identity (collapses for one user, **re-emerges for the fleet**). | Fleet Control | ❌ build |
+| C5 | RBAC / ABAC | Who sees which distilled knowledge; **enforces worker-owns-raw** (rule 4). | Fleet Control · Gateway | ❌ build |
+| C7 | Audit log + lineage | Every model/tool call + **what left the device**. The DPO's single evidence stream. | Gateway → Fleet Control | ❌ build — **START HERE** |
+| C8 | Observability + tracing | Tokens, latency, full trace per person/feature; replay any answer. | Gateway → Fleet Control | ❌ build |
+| C9 | Eval + red teaming | Quality gate on the local model **and** on SOP quality. Drift checks. | Fleet Control · Brain | ❌ build |
+| C9a | Bias + fairness | For consequential frontline decisions. | Fleet Control | ❌ build (later) |
+| C9c | Incident response + runbooks | **Kill switch** ("stop all AI / pause capture") + runbooks. | Fleet Control | ❌ build — kill switch early |
+| C14 | FinOps + cost | Per-device compute/battery budget; $ only matters once cloud fallback is on. | Fleet Control | ❌ build (light) |
+| C16 | DLP + exfil prevention | **The egress gate** — what may leave to a cloud model / connector / other device. The privacy guarantee (rule 5). | Gateway | ❌ build — **START HERE** |
+| C21 | Durable execution | Long agent runs (a batch SOP-mining job over a week of capture) resume, not restart. | Gateway / runtime | ❌ build (later) |
---
@@ -173,14 +173,14 @@ sources feed Brain, so the intelligence baked into nodes is grounded in **what p
> Where humans meet it. Mostly **already built** on the Personal AI side.
-| # | Navigator component | Off Grid realization | Owner | Status |
-|---|---|---|---|---|
-| D1 | Agent runtime / orchestration | The agents that do the work + the **learning-loop batch jobs** (observe→SOP). | Personal AI · Brain | ⚠️ partial (`crm/agent.ts`); loop build |
-| D2 | Human-in-the-loop | Approval-gated actions on anything consequential. | Personal AI | ✅ `crm/approvals.ts` |
-| D3 | Conversational + generative UI | The copilot UI, desktop-first. | Personal AI | ✅ React app |
-| D3b | Trust indicators | Citation chips — **"why this SOP / where it was observed"**, confidence. | Personal AI · Fleet Control | ❌ build |
-| D4 | Voice + telephony | Capture & understand calls (your explicit ask — phone, meetings). | Personal AI capture | ⚠️ meeting recorder + whisper; extend |
-| D8 | Feedback + data flywheel | Thumbs/corrections on SOPs feed eval + Brain. Start day one. | Personal AI → Brain | ❌ build |
+| # | Navigator component | Off Grid realization | Owner | Status |
+| --- | ------------------------------ | ----------------------------------------------------------------------------- | --------------------------- | --------------------------------------- |
+| D1 | Agent runtime / orchestration | The agents that do the work + the **learning-loop batch jobs** (observe→SOP). | Personal AI · Brain | ⚠️ partial (`crm/agent.ts`); loop build |
+| D2 | Human-in-the-loop | Approval-gated actions on anything consequential. | Personal AI | ✅ `crm/approvals.ts` |
+| D3 | Conversational + generative UI | The copilot UI, desktop-first. | Personal AI | ✅ React app |
+| D3b | Trust indicators | Citation chips — **"why this SOP / where it was observed"**, confidence. | Personal AI · Fleet Control | ❌ build |
+| D4 | Voice + telephony | Capture & understand calls (your explicit ask — phone, meetings). | Personal AI capture | ⚠️ meeting recorder + whisper; extend |
+| D8 | Feedback + data flywheel | Thumbs/corrections on SOPs feed eval + Brain. Start day one. | Personal AI → Brain | ❌ build |
---
@@ -188,19 +188,19 @@ sources feed Brain, so the intelligence baked into nodes is grounded in **what p
> Functions, not just tools. Realized mostly inside **Fleet Control** (the DPO's product).
-| # | Navigator component | Off Grid realization | Owner | Status |
-|---|---|---|---|---|
-| E1 | Framework mapping | Map controls → DPDP/etc clauses. **The DPO single compliant view + one-click export.** | Fleet Control | ❌ build |
-| E2 | AI use policy | What staff may do; authored and **pushed to every device** by Fleet Control. | Fleet Control | ❌ build |
-| E5 | Ethics / review board | Process; supported by Fleet Control's audit + eval evidence. | Fleet Control (workflow) | process |
-| E6 | DPIA / FRIA | Per use case; Fleet Control **generates the assessment pack** from the audit stream. | Fleet Control | ❌ build |
+| # | Navigator component | Off Grid realization | Owner | Status |
+| --- | --------------------- | -------------------------------------------------------------------------------------- | ------------------------ | -------- |
+| E1 | Framework mapping | Map controls → DPDP/etc clauses. **The DPO single compliant view + one-click export.** | Fleet Control | ❌ build |
+| E2 | AI use policy | What staff may do; authored and **pushed to every device** by Fleet Control. | Fleet Control | ❌ build |
+| E5 | Ethics / review board | Process; supported by Fleet Control's audit + eval evidence. | Fleet Control (workflow) | process |
+| E6 | DPIA / FRIA | Per use case; Fleet Control **generates the assessment pack** from the audit stream. | Fleet Control | ❌ build |
---
## Phase 0 — PHYSICAL PLANE
-| Navigator | Off Grid realization | Status |
-|---|---|---|
+| Navigator | Off Grid realization | Status |
+| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
| GPUs / nodes / power / K8s | The **devices themselves** (laptops, phones) run inference on-device. Optional **on-prem org server** hosts Fleet Control + Brain. No hyperscaler in the path. | deployment |
---
@@ -211,17 +211,17 @@ The navigator's "pick the gateway first" inverts for us — the AI plane (B) is
so Stage 1 is **wrapping it with control (C7 + C16)**, then standing up Fleet Control.
- **Stage 1 — Control the chokepoint.** C1 routing · **C7 audit log** · **C16 egress gate** ·
- C9c kill switch. Pre/post hook pipeline in `model-server.ts`. *Done when every call is
- logged and nothing leaves the device unless policy allows.*
+ C9c kill switch. Pre/post hook pipeline in `model-server.ts`. _Done when every call is
+ logged and nothing leaves the device unless policy allows._
- **Stage 2 — Fleet Control foundation.** C4 identity · enroll a device · push policy (E2)
- down · pull audit (C7) up · the DPO view (E1) v0. On-prem. *Done when an admin can govern
- a device from one console.*
+ down · pull audit (C7) up · the DPO view (E1) v0. On-prem. _Done when an admin can govern
+ a device from one console._
- **Stage 3 — The learning loop.** D1 batch orchestration · A7/A9 PII tag+mask on ingest ·
- B5a provenance · SOP/pattern synthesis (D8 flywheel). *Done when the system writes a cited
- SOP from a week of observed work.*
+ B5a provenance · SOP/pattern synthesis (D8 flywheel). _Done when the system writes a cited
+ SOP from a week of observed work._
- **Stage 4 — Brain.** B3 org index · A10 distilled store · A11/C5 access control · push SOPs
- back to every device (D3b trust indicators at point of work). *Done when "what good is"
- flows back to everyone, worker-owns-raw enforced.*
+ back to every device (D3b trust indicators at point of work). _Done when "what good is"
+ flows back to everyone, worker-owns-raw enforced._
- **Stage 5 — Hardening.** C8 observability · C9/C9a eval+bias gates · C21 durable runs ·
A12a erasure · E6 DPIA packs · Sync at org scale · mobile parity.
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index d812de1d..a6348cca 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -7,24 +7,24 @@ device** — no cloud inference, no account, no API key. Each feature has its ow
## The studio
-| Feature | What it does |
-|---|---|
-| [The Gateway](features/gateway.md) | One local OpenAI-compatible API for every model — chat, vision, image, audio, embeddings. |
-| [Chat](features/chat.md) | Text + vision + reasoning, streaming, tabs, tools, voice mode, project scoping. |
-| [Image generation](features/image-generation.md) | Text→image and image→image (SDXL GGUFs), styles, LoRA, live previews. |
-| [Voice & speech](features/voice.md) | Speech→text (whisper) and text→speech (Kokoro), hands-free voice mode. |
-| [Projects (RAG)](features/projects.md) | Group chats, upload docs, chat grounded in them with cited retrieval. |
-| [Artifacts](features/artifacts.md) | HTML / React / SVG / Mermaid / Markdown rendered in a sandboxed canvas. |
-| [Connectors (MCP)](features/connectors.md) | Add Model Context Protocol servers (none / token / OAuth), use them in chat. |
-| [Models](features/models.md) | Curated, size-bucketed catalog + Hugging Face search; per-modality active model. |
+| Feature | What it does |
+| ------------------------------------------------ | ----------------------------------------------------------------------------------------- |
+| [The Gateway](features/gateway.md) | One local OpenAI-compatible API for every model — chat, vision, image, audio, embeddings. |
+| [Chat](features/chat.md) | Text + vision + reasoning, streaming, tabs, tools, voice mode, project scoping. |
+| [Image generation](features/image-generation.md) | Text→image and image→image (SDXL GGUFs), styles, LoRA, live previews. |
+| [Voice & speech](features/voice.md) | Speech→text (whisper) and text→speech (Kokoro), hands-free voice mode. |
+| [Projects (RAG)](features/projects.md) | Group chats, upload docs, chat grounded in them with cited retrieval. |
+| [Artifacts](features/artifacts.md) | HTML / React / SVG / Mermaid / Markdown rendered in a sandboxed canvas. |
+| [Connectors (MCP)](features/connectors.md) | Add Model Context Protocol servers (none / token / OAuth), use them in chat. |
+| [Models](features/models.md) | Curated, size-bucketed catalog + Hugging Face search; per-modality active model. |
## Platform
-| Topic | What it covers |
-|---|---|
-| [Privacy & data](features/privacy.md) | 100% local inference, encryption at rest, fully offline. |
+| Topic | What it covers |
+| ---------------------------------------------------- | --------------------------------------------------------------- |
+| [Privacy & data](features/privacy.md) | 100% local inference, encryption at rest, fully offline. |
| [Architecture (open core)](features/architecture.md) | The AGPL core, the `pro/` submodule, and the `activate()` seam. |
-| [Off Grid Pro](features/pro.md) | The sees / remembers / acts layer — **coming July 2026**. |
+| [Off Grid Pro](features/pro.md) | The sees / remembers / acts layer — **coming July 2026**. |
---
diff --git a/docs/FUNCTIONAL_TEST_STRATEGY.md b/docs/FUNCTIONAL_TEST_STRATEGY.md
index df40e5e4..0879e478 100644
--- a/docs/FUNCTIONAL_TEST_STRATEGY.md
+++ b/docs/FUNCTIONAL_TEST_STRATEGY.md
@@ -1,7 +1,7 @@
# Functional test strategy — ensure features actually work, across every surface
-The unit-coverage ratchet (vitest, ~54%) proves *pure logic* is exercised. It does NOT prove
-*a feature works* - the boot crash and the "tools don't stream" fork both passed typecheck +
+The unit-coverage ratchet (vitest, ~54%) proves _pure logic_ is exercised. It does NOT prove
+_a feature works_ - the boot crash and the "tools don't stream" fork both passed typecheck +
unit tests + build. This doc tracks FUNCTIONAL tests (unit + integration) per product surface,
including the native (Swift) code, so "does capture -> OCR -> memory -> chat actually work" has
an answer that a test enforces. Bring in whatever framework a surface needs.
@@ -39,6 +39,7 @@ really a test of a third-party framework or engine we merely depend on:
e2e-covered UI components (MemoryChat.tsx, VaultScreen.tsx).
## Test types
+
- **unit (vitest)** - pure TS logic, no I/O. Fast, always-on. The ratchet floor.
- **integration (vitest, real collaborators)** - run the real implementation against a temp
SQLite DB / temp userData / a spawned native binary / the local engine. `skipIf` the
@@ -50,24 +51,25 @@ really a test of a third-party framework or engine we merely depend on:
## Surface matrix (status - keep current)
-| Surface | Kind | How | Status |
-|---|---|---|---|
-| RAG/chat pure logic (ranking, prompts, routing, tool loop) | unit | vitest | DONE (growing) |
-| Streaming tool loop (C7) | integration-ish | vitest, faked model boundary | DONE (`tools-stream.test.ts`) |
-| **Native OCR (Vision, `ocr.swift`)** | integration | vitest spawns the built binary on a rendered fixture | **DONE (`ocr-helper.integration.test.ts`)** |
-| Gateway `/v1` (chat stream, embeddings, image) | smoke | `npm run smoke` vs running app | DONE (manual/pre-release) |
-| STT (whisper-cli / parakeet) + TTS (kokoro) | integration | vitest: TTS->STT round-trip via the gateway; skip if gateway down | **DONE (`audio-engines.integration.test.ts`)** |
-| Image gen (sd-cli / mflux) | integration | vitest: gateway `/v1/images/generations` on the installed model; heavy - local-gated, skip if no image model | TODO (local only) |
-| coreml-sd Swift package | - | **EXCLUDED - not our code.** ~50-line `main.swift` shim over Apple ml-stable-diffusion; testing it tests Apple's SD, not our logic. Covered indirectly by the gateway image-gen integration test when it is the active runtime. |
-| Accessibility watcher (`main.swift`) | integration | needs TCC (accessibility) - local-gated, spawn + assert JSON shape | TODO (local only) |
-| text-extractor / inspect_layout `.swift` | integration | spawn on a fixture; assert output shape | TODO |
-| Capture -> OCR -> memory ingest seam | integration | vitest: feed a fixture frame through the ingest path into a temp DB | TODO |
-| RAG end-to-end (retrieve -> prompt -> answer) | integration | vitest: seed a temp SQLite, run retrieval, assert grounded context | TODO |
-| Vault (kdbx + Argon2) | integration | vitest vs temp dir | DONE (`vault-service.test.ts`) |
-| Renderer surfaces render | e2e | Playwright tour | DONE (22) |
-| Pro dictation / clipboard / replay | e2e + unit | Playwright (`pro.spec.ts`) + pro unit | PARTIAL |
+| Surface | Kind | How | Status |
+| ---------------------------------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
+| RAG/chat pure logic (ranking, prompts, routing, tool loop) | unit | vitest | DONE (growing) |
+| Streaming tool loop (C7) | integration-ish | vitest, faked model boundary | DONE (`tools-stream.test.ts`) |
+| **Native OCR (Vision, `ocr.swift`)** | integration | vitest spawns the built binary on a rendered fixture | **DONE (`ocr-helper.integration.test.ts`)** |
+| Gateway `/v1` (chat stream, embeddings, image) | smoke | `npm run smoke` vs running app | DONE (manual/pre-release) |
+| STT (whisper-cli / parakeet) + TTS (kokoro) | integration | vitest: TTS->STT round-trip via the gateway; skip if gateway down | **DONE (`audio-engines.integration.test.ts`)** |
+| Image gen (sd-cli / mflux) | integration | vitest: gateway `/v1/images/generations` on the installed model; heavy - local-gated, skip if no image model | TODO (local only) |
+| coreml-sd Swift package | - | **EXCLUDED - not our code.** ~50-line `main.swift` shim over Apple ml-stable-diffusion; testing it tests Apple's SD, not our logic. Covered indirectly by the gateway image-gen integration test when it is the active runtime. |
+| Accessibility watcher (`main.swift`) | integration | needs TCC (accessibility) - local-gated, spawn + assert JSON shape | TODO (local only) |
+| text-extractor / inspect_layout `.swift` | integration | spawn on a fixture; assert output shape | TODO |
+| Capture -> OCR -> memory ingest seam | integration | vitest: feed a fixture frame through the ingest path into a temp DB | TODO |
+| RAG end-to-end (retrieve -> prompt -> answer) | integration | vitest: seed a temp SQLite, run retrieval, assert grounded context | TODO |
+| Vault (kdbx + Argon2) | integration | vitest vs temp dir | DONE (`vault-service.test.ts`) |
+| Renderer surfaces render | e2e | Playwright tour | DONE (22) |
+| Pro dictation / clipboard / replay | e2e + unit | Playwright (`pro.spec.ts`) + pro unit | PARTIAL |
## Rules
+
- An integration test that needs a binary/model MUST `skipIf` it is absent and say so - never
fail because CI lacks the artifact, never silently pass by mocking the thing under test.
- Prefer a real round-trip (e.g. TTS->STT) over a hand-crafted fixture where it makes the test
diff --git a/docs/GAPS_BACKLOG.md b/docs/GAPS_BACKLOG.md
index c0359925..fa4de48e 100644
--- a/docs/GAPS_BACKLOG.md
+++ b/docs/GAPS_BACKLOG.md
@@ -30,7 +30,7 @@ the coverage floor held (~97/92/96/98) throughout.
`saveSetting('imageParams', …)`; a model change never clobbers a typed value. Render test asserts
the payload carries the user's steps (10), not the model default (28).
- **T1c. `imgSeed`/`imgNegative`/`imgStrength`/`imgStyle` not persisted** → FIXED. Persisted +
- reloaded through the data layer (`MemoryChat.tsx:314-317, 332-335`). (`imgInit` stays transient -
+ reloaded through the data layer (`MemoryChat.tsx:314-317, 332-335`). (`imgInit` stays transient -
a per-turn init-image path, correctly not persisted.)
- **T1d. Image params had no persisted owner** → FIXED (subsumed by T1a–T1c). Image-gen params now
have a single persisted owner (the settings store); the composer binds to it and writes through.
@@ -50,7 +50,7 @@ the coverage floor held (~97/92/96/98) throughout.
- **Preload `setLlmSettings` type omitted kvCacheType/flashAttn/gpuLayers/threads/batchSize/mode** →
FIXED (`src/preload/index.ts:244` - the type now carries every field the handler accepts;
runtime was always passing the whole object, this closes the type-check blind spot).
-- **Settings identity fields saved on `blur` only (edit lost if closed without blurring)** → FIXED -
+- **Settings identity fields saved on `blur` only (edit lost if closed without blurring)** → FIXED -
now also commits on Enter (`Settings.tsx:472-473`), the standard keyboard commit, calling the same
`saveIdentity`.
- **`ctxSize` halved + persisted by crash recovery (`llm.ts:479-483`)** → BY DESIGN, not a bug. This
diff --git a/docs/GATEWAY_SPINE.md b/docs/GATEWAY_SPINE.md
index f62c8413..4f7787dd 100644
--- a/docs/GATEWAY_SPINE.md
+++ b/docs/GATEWAY_SPINE.md
@@ -2,7 +2,7 @@
How the 5-layer agentic stack (`wednesdayai/knowledge-base/architecture.md`) maps onto
the local-first desktop gateway at `127.0.0.1:7878` (`src/main/model-server.ts`), and the
-plan to grow that gateway from a request *router* into a control-plane *spine*.
+plan to grow that gateway from a request _router_ into a control-plane _spine_.
References: Portkey OSS gateway (TS, plugin-based) and Bifrost (Go, high-perf) — what to
steal from each is called out below.
@@ -12,8 +12,8 @@ steal from each is called out below.
## Thesis
The knowledge base says it plainly: **Phase C (the AI gateway) is the spine — pick it
-first, not last.** The other four phases are *wired through* the chokepoint, not *stuffed
-into* one process.
+first, not last.** The other four phases are _wired through_ the chokepoint, not _stuffed
+into_ one process.
So "build all of this in the gateway" resolves to two different moves:
@@ -52,8 +52,8 @@ short-circuit (block, redact, rewrite) or annotate. This is Portkey's plugin mod
expressed in-process. Keep it synchronous and cheap — this is loopback, not 5k RPS.
```ts
-type HookResult = { action: 'pass' | 'block' | 'rewrite'; body?: unknown; reason?: string };
-type Hook = (ctx: GatewayCtx) => Promise | HookResult;
+type HookResult = { action: 'pass' | 'block' | 'rewrite'; body?: unknown; reason?: string }
+type Hook = (ctx: GatewayCtx) => Promise | HookResult
// preHooks: identity, budget, inputPolicy
// postHooks: outputPolicy, egressDlp, audit
```
@@ -62,18 +62,18 @@ type Hook = (ctx: GatewayCtx) => Promise | HookResult;
## What to steal from Portkey vs Bifrost
-| | Portkey OSS | Bifrost |
-|---|---|---|
-| Language | **TypeScript** (matches our gateway) | Go (separate process) |
-| Deploy | Node / edge / Docker, 122kb | NPX / Docker binary |
-| Model | **Config-driven + `/plugins` middleware** | Plugin middleware, ~15µs overhead @ 5k RPS |
-| Has | Guardrails (40+), fallbacks, retries, load-balance, semantic cache, virtual keys, **MCP gateway** | Virtual keys, hierarchical budgets, OIDC, semantic cache, **MCP gateway**, Prometheus |
+| | Portkey OSS | Bifrost |
+| -------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
+| Language | **TypeScript** (matches our gateway) | Go (separate process) |
+| Deploy | Node / edge / Docker, 122kb | NPX / Docker binary |
+| Model | **Config-driven + `/plugins` middleware** | Plugin middleware, ~15µs overhead @ 5k RPS |
+| Has | Guardrails (40+), fallbacks, retries, load-balance, semantic cache, virtual keys, **MCP gateway** | Virtual keys, hierarchical budgets, OIDC, semantic cache, **MCP gateway**, Prometheus |
- **Mirror Portkey's plugin/guardrail architecture in-process.** It's TypeScript like our
gateway, the plugin shape (pre/post verifiers returning pass/block/transform) is exactly
the pipeline above, and its MCP-gateway design (centralized auth + tool-call
observability + identity forwarding) is the model for our `/mcp` endpoint. Don't vendor
- Portkey; copy the *shape*.
+ Portkey; copy the _shape_.
- **Treat Bifrost as the "externalize later" reference.** If the gateway ever leaves the
Electron main process to become a standalone local daemon (shared by mobile/paired
devices, or to stop blocking the event loop), Bifrost's Go architecture, weighted-key
@@ -88,27 +88,27 @@ type Hook = (ctx: GatewayCtx) => Promise | HookResult;
The BFSI model assumes multi-tenant, regulated, cloud. On a single-user local device most
of the multi-tenant/regulatory machinery **collapses** — but a surprising amount stays
-**load-bearing**, often in a new guise. The privacy stakes are *higher*, not lower:
+**load-bearing**, often in a new guise. The privacy stakes are _higher_, not lower:
capture sees everything on screen.
-| BFSI layer | In Off Grid | Verdict | Where it lives |
-|---|---|---|---|
-| **A · Data plane** (CDC, lake, PII mask) | capture → OCR → entities → SQLite | Exists | `watcher.ts`, `vision.ts`, `database.ts`, `crm/*` — gateway *consumes*, doesn't own |
-| **A · PII masking** | redact before anything leaves the device | **Survives, critical** | post-hook egress DLP (see C16) |
-| **B · Model serving** | llama / whisper / TTS / diffusion | Exists | gateway already fronts it |
-| **B · KB + retrieval** | RAG extractors + embeddings | Promote | expose retrieval as a first-class gateway tool |
-| **B · Memory (sidecar)** | observations / entities / memory | Promote | memory read/write through the gateway, not baked into one agent |
-| **B · Tool layer (MCP)** | `/mcp` endpoint | Exists, extend | per-tool scope + audit, Portkey-style |
-| **C · Gateway** | `:7878` | **This whole doc** | `model-server.ts` |
-| **C · Audit log** | every chat/tool/model call recorded | **Survives, high value** | new SQLite table; "what did my AI see and do" is a *feature* of a memory product |
-| **C · Input policy** | injection scan of captured/retrieved text | **Survives** — captured screen text is hostile indirect-injection input | pre-hook |
-| **C · Output/DLP** | gate egress to cloud fallback / outbound MCP / paired device | **Survives, critical** | post-hook; the privacy guarantee of the product |
-| **C · Kill switch** | "stop all AI / pause capture now" | **Survives** | pipeline flag + tray |
-| **C · Observability** | tokens, latency, per-feature traces | **Survives** | local dashboard off the audit stream |
-| **C · Identity / RBAC** | single user | Collapses now, **re-emerges for paired devices** | per-device token (gateway already anticipates "a paired device later") |
-| **C · FinOps** | compute/battery budget; $ only if cloud fallback added | Mostly collapses | budget pre-hook, optional |
-| **D · Consumption** | React UI + approval-gated actions | Exists | `crm/approvals.ts`; gateway feeds it traces/citations/confidence |
-| **E · Org / regulatory** | local-first guarantee, recording indicator, user-owned data, AGPL | Collapses to product posture | not gateway code; "AIBOM" → a manifest of bundled models |
+| BFSI layer | In Off Grid | Verdict | Where it lives |
+| ---------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
+| **A · Data plane** (CDC, lake, PII mask) | capture → OCR → entities → SQLite | Exists | `watcher.ts`, `vision.ts`, `database.ts`, `crm/*` — gateway _consumes_, doesn't own |
+| **A · PII masking** | redact before anything leaves the device | **Survives, critical** | post-hook egress DLP (see C16) |
+| **B · Model serving** | llama / whisper / TTS / diffusion | Exists | gateway already fronts it |
+| **B · KB + retrieval** | RAG extractors + embeddings | Promote | expose retrieval as a first-class gateway tool |
+| **B · Memory (sidecar)** | observations / entities / memory | Promote | memory read/write through the gateway, not baked into one agent |
+| **B · Tool layer (MCP)** | `/mcp` endpoint | Exists, extend | per-tool scope + audit, Portkey-style |
+| **C · Gateway** | `:7878` | **This whole doc** | `model-server.ts` |
+| **C · Audit log** | every chat/tool/model call recorded | **Survives, high value** | new SQLite table; "what did my AI see and do" is a _feature_ of a memory product |
+| **C · Input policy** | injection scan of captured/retrieved text | **Survives** — captured screen text is hostile indirect-injection input | pre-hook |
+| **C · Output/DLP** | gate egress to cloud fallback / outbound MCP / paired device | **Survives, critical** | post-hook; the privacy guarantee of the product |
+| **C · Kill switch** | "stop all AI / pause capture now" | **Survives** | pipeline flag + tray |
+| **C · Observability** | tokens, latency, per-feature traces | **Survives** | local dashboard off the audit stream |
+| **C · Identity / RBAC** | single user | Collapses now, **re-emerges for paired devices** | per-device token (gateway already anticipates "a paired device later") |
+| **C · FinOps** | compute/battery budget; $ only if cloud fallback added | Mostly collapses | budget pre-hook, optional |
+| **D · Consumption** | React UI + approval-gated actions | Exists | `crm/approvals.ts`; gateway feeds it traces/citations/confidence |
+| **E · Org / regulatory** | local-first guarantee, recording indicator, user-owned data, AGPL | Collapses to product posture | not gateway code; "AIBOM" → a manifest of bundled models |
**The three that matter most locally:** audit log (C7), input policy against indirect
injection from captured content (C2), and egress DLP (C16). Those are the spine's
@@ -123,40 +123,45 @@ Staged like the knowledge base's path — each stage earns the next. Don't build
machinery before the pipeline exists.
### Stage 1 — Make it a pipeline (the unlock)
+
- Add `Hook` type + `pipeline()` around `serve()` / `proxyToLlama()`.
- Add the **audit log**: one SQLite table, every request (prompt, modality, model, tokens,
latency, tool calls, outcome). Start logging before any policy exists — it's the
evidence stream everything else reads from.
- Add the **kill switch**: a single pipeline flag, wired to the tray.
-- *Done when:* every call through `:7878` produces an audit row and can be halted instantly.
+- _Done when:_ every call through `:7878` produces an audit row and can be halted instantly.
### Stage 2 — Policy (the privacy beams)
+
- **Input pre-hook:** Presidio-style PII tag + prompt-injection heuristics, run especially
- over *retrieved/captured* content, not just the user prompt. Treat screen text as
+ over _retrieved/captured_ content, not just the user prompt. Treat screen text as
hostile.
- **Egress post-hook (DLP):** before any byte leaves the device — cloud model fallback,
- outbound MCP tool call, paired-device sync — redact/block per policy. This is *the*
+ outbound MCP tool call, paired-device sync — redact/block per policy. This is _the_
privacy guarantee; make it the one hook that cannot be bypassed.
-- *Done when:* nothing leaves the device unredacted, and injected instructions in captured
+- _Done when:_ nothing leaves the device unredacted, and injected instructions in captured
content are caught.
### Stage 3 — Consolidate Phase B through the gateway
+
- Promote **RAG retrieval** and **memory read/write** to first-class gateway tools/endpoints
(today they're libraries the renderer calls directly).
- Extend **`/mcp`** with per-tool scope + audit (Portkey MCP-gateway shape).
- Add **semantic cache** (embed prompt, short-circuit near-duplicates).
-- *Done when:* CRM agents and the renderer reach memory/KB/tools *through* the gateway, so
+- _Done when:_ CRM agents and the renderer reach memory/KB/tools _through_ the gateway, so
every reach is audited and policy-checked.
### Stage 4 — Observability + routing
+
- **Local dashboard** off the audit stream: tokens, latency, per-feature traces, "why this
answer" + citations into the UI (D3b trust indicators).
- **Routing/fallback** (Portkey/Bifrost): local-first, optional cloud (Claude) fallback for
- hard reasoning — and *that* call goes through the egress DLP hook by construction.
-- *Done when:* you can replay any single answer's full trace, and cloud fallback is
+ hard reasoning — and _that_ call goes through the egress DLP hook by construction.
+- _Done when:_ you can replay any single answer's full trace, and cloud fallback is
governed, not a side channel.
### Stage 5 — Paired-device identity
+
- Per-device token issuance (C4 re-emerges). The gateway already says "a paired device
later" in its header comment — this is where RBAC stops being a no-op.
@@ -165,6 +170,6 @@ machinery before the pipeline exists.
## The one rule
Every model call, tool call, memory read, and outbound byte goes through `:7878`. The
-moment a feature reaches a model or leaves the device *around* the gateway, the spine stops
+moment a feature reaches a model or leaves the device _around_ the gateway, the spine stops
reading true — exactly the knowledge base's warning about shadow AI. One chokepoint, no
exceptions.
diff --git a/docs/IMAGE_GEN_OPTIMIZATION.md b/docs/IMAGE_GEN_OPTIMIZATION.md
index e7852f57..d2360c1f 100644
--- a/docs/IMAGE_GEN_OPTIMIZATION.md
+++ b/docs/IMAGE_GEN_OPTIMIZATION.md
@@ -9,12 +9,12 @@ dpm++2m, `--diffusion-fa`.
Two independent costs stack up, and only the second is quality-optional:
-| Steps | Quality | Wall clock (768²) |
-|------:|---------|-------------------|
-| 4 | blank / blob (unusable) | ~19s |
-| 8 | garbage (rainbow banding) | ~47s |
-| 12 | usable | ~64s |
-| 20 | good (the target) | ~105s |
+| Steps | Quality | Wall clock (768²) |
+| ----: | ------------------------- | ----------------- |
+| 4 | blank / blob (unusable) | ~19s |
+| 8 | garbage (rainbow banding) | ~47s |
+| 12 | usable | ~64s |
+| 20 | good (the target) | ~105s |
- **Sampling (UNet) dominates:** ~4.2-4.6 s/step at 768² cfg 7. 20 steps ≈ 92s.
This is the wall. It's the `ggml_conv_2d` operator - the 2D convolutions in the
@@ -40,16 +40,16 @@ actually beats the ANE - so ANE is not the desktop ceiling either.)
## Levers evaluated
-| Lever | Result | Status |
-|-------|--------|--------|
-| **Persistent `sd-server`** (keep model resident) | Warm images skip the ~13s Metal shader warmup + ~5s model reload | **DONE** - `src/main/sd-server.ts`, wired into `imagegen.ts`, tested |
-| **taesd fast VAE** (`--taesd taesdxl`) | VAE decode 1.47s vs ~10-16s (verified live, non-black) | **DONE** - opt-in `fastVae` param, wired both paths, tested. Needs `taesdxl.safetensors` in models dir |
-| **Rebuild from latest upstream** (6314af4 vs bundled 92a3b73) | 4.15-4.5 s/step - NO speedup. Same generic conv kernels | **Ruled out** for perf (would still bring newer model support) |
-| **Winograd conv fork** (arXiv 2412.05781, `SealAILab/stable-diffusion-cpp`, claimed 3-4.79× SDXL on Metal) | Repo is 404 / org gone - not publicly available | **Unavailable**; implementing Winograd in ggml ourselves is a major effort |
-| **`--conv-direct`** flags | 9× SLOWER (33 s/step) - ggml's direct conv is worse | **Rejected** |
-| **f16 instead of q8_0** | Untested (needs ~13GB f16 GGUF, not in our repo) | **Open** - may cut per-step (no dequant); worth a benchmark if a file is produced |
-| **Fewer steps** on the full model | Unusable below ~12 steps | **Rejected** (quality) |
-| **Distilled model** (Lightning/DMD2) | Not yet produced | **Open - the real quality-preserving speed answer** |
+| Lever | Result | Status |
+| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
+| **Persistent `sd-server`** (keep model resident) | Warm images skip the ~13s Metal shader warmup + ~5s model reload | **DONE** - `src/main/sd-server.ts`, wired into `imagegen.ts`, tested |
+| **taesd fast VAE** (`--taesd taesdxl`) | VAE decode 1.47s vs ~10-16s (verified live, non-black) | **DONE** - opt-in `fastVae` param, wired both paths, tested. Needs `taesdxl.safetensors` in models dir |
+| **Rebuild from latest upstream** (6314af4 vs bundled 92a3b73) | 4.15-4.5 s/step - NO speedup. Same generic conv kernels | **Ruled out** for perf (would still bring newer model support) |
+| **Winograd conv fork** (arXiv 2412.05781, `SealAILab/stable-diffusion-cpp`, claimed 3-4.79× SDXL on Metal) | Repo is 404 / org gone - not publicly available | **Unavailable**; implementing Winograd in ggml ourselves is a major effort |
+| **`--conv-direct`** flags | 9× SLOWER (33 s/step) - ggml's direct conv is worse | **Rejected** |
+| **f16 instead of q8_0** | Untested (needs ~13GB f16 GGUF, not in our repo) | **Open** - may cut per-step (no dequant); worth a benchmark if a file is produced |
+| **Fewer steps** on the full model | Unusable below ~12 steps | **Rejected** (quality) |
+| **Distilled model** (Lightning/DMD2) | Not yet produced | **Open - the real quality-preserving speed answer** |
## Net effect of what shipped tonight
@@ -86,7 +86,7 @@ re-quantize) and wiring it as the "Fast" image model, keeping full animagine as
Tried to bake SDXL-Lightning into animagine to ship a few-step q8 Fast model:
-- **sd.cpp's runtime LoRA for SDXL is broken.** LCM *and* Lightning both report
+- **sd.cpp's runtime LoRA for SDXL is broken.** LCM _and_ Lightning both report
`2364/2364 tensors applied` but produce corrupted (banded) output - proven across
q8 AND f16-GGUF, Metal AND CPU, with/without flash-attn. Same models with no LoRA
are clean. So a runtime LoRA is a dead end here; distillation must be baked into the
diff --git a/docs/MARKETING_ANGLES.md b/docs/MARKETING_ANGLES.md
index 5913b14b..07a16fac 100644
--- a/docs/MARKETING_ANGLES.md
+++ b/docs/MARKETING_ANGLES.md
@@ -3,6 +3,7 @@
Background intelligence for knowledge workers. Completely offline, completely private.
**Source docs to pull from / stay consistent with:**
+
- `../../website/vision.md` — "The world we're building toward" (the long-form ambient/proactive Personal-AI-OS essay: morning briefing, one-brain-across-devices, private-by-architecture, intelligence for everyone).
- `../../website/ethos.md` and `../../website/guides/vision-ai.md` — ethos + vision detail.
- `../../mobile/docs/brand_tone_voice.md` — the voice rules (proof-first, privacy-as-mechanism, no exclamation/em-dash/slop). Mac's launch note (the three-products thesis) lives in the section below.
@@ -11,7 +12,7 @@ Background intelligence for knowledge workers. Completely offline, completely pr
One private intelligence layer that lives across your laptop and your phone, learns your work and your life in the background, and gets ahead of you. A truly smart assistant: it remembers everything, syncs device to device over your own network, and acts to make your day easier before you ask. All of it on your hardware. None of it on anyone's cloud.
-The arc: it starts by *seeing and remembering* (capture + memory), then *reflects* (where your attention goes), then *acts* (drafts, files, reminds, with your approval), and finally becomes *proactive* (the briefing, the meeting prep, the nudge) - the same assistant the powerful have always had, now private and in everyone's hands.
+The arc: it starts by _seeing and remembering_ (capture + memory), then _reflects_ (where your attention goes), then _acts_ (drafts, files, reminds, with your approval), and finally becomes _proactive_ (the briefing, the meeting prep, the nudge) - the same assistant the powerful have always had, now private and in everyone's hands.
Voice rules (from `mobile/docs/brand_tone_voice.md`): proof-first, privacy stated as a mechanism not a promise, plain words, no exclamation marks, no em dashes, no hype-slop. Emotional arc: Recognition -> Return -> Freedom. Mix and match for tweets, a landing hero, a launch thread, or a waitlist page.
diff --git a/docs/MASTER_PLAN.md b/docs/MASTER_PLAN.md
index eb6ee710..d4963f1b 100644
--- a/docs/MASTER_PLAN.md
+++ b/docs/MASTER_PLAN.md
@@ -15,6 +15,7 @@ Desktop/Mobile), grounds them in the **organizational brain**, and proves compli
regulator.
**Two halves, one story:**
+
- **Frontline value** — a private, on-device copilot for every worker; democratize the best
people's know-how to the whole field force (sales productivity, frontline enablement).
- **Enterprise control** — the auditable, on-prem control plane a DPO/CISO can defend.
@@ -35,17 +36,17 @@ is how we land where the top-down compliance vendors (e.g. Pints) can't reach.
## 3. The nine planes (and the 5-layer reference mapping)
-| Plane (our module) | Reference layer | Status |
-|---|---|---|
-| **Gateway** (Off Grid AI Gateway, :7878) | AI plane / Control chokepoint | ✅ built (desktop) + console reads it |
-| **Fleet** | Control (devices) | ✅ console (enroll/policy/kill/audit) |
-| **Control** (policy, guardrails, egress, audit, RBAC) | Control plane (C) | ✅ console |
-| **Data** (connectors, ingest, masking, catalog, DSAR) | Data plane (A) | ✅ console |
-| **Brain** (LanceDB ingestion→retrieval) | AI plane (B) | ✅ console |
-| **Agents** (pre-built use cases) | Consumption (D) | ⬜ scaffold |
-| **Analytics** (usage, latency, drift, perf) | Control observability | ✅ console |
-| **Reports** (regulator-ready exports) | Consumption (D) | ⬜ scaffold |
-| **Regulatory** (framework mapping, DPIA export) | Org/Regulatory (E) | ✅ console |
+| Plane (our module) | Reference layer | Status |
+| ----------------------------------------------------- | ----------------------------- | ------------------------------------- |
+| **Gateway** (Off Grid AI Gateway, :7878) | AI plane / Control chokepoint | ✅ built (desktop) + console reads it |
+| **Fleet** | Control (devices) | ✅ console (enroll/policy/kill/audit) |
+| **Control** (policy, guardrails, egress, audit, RBAC) | Control plane (C) | ✅ console |
+| **Data** (connectors, ingest, masking, catalog, DSAR) | Data plane (A) | ✅ console |
+| **Brain** (LanceDB ingestion→retrieval) | AI plane (B) | ✅ console |
+| **Agents** (pre-built use cases) | Consumption (D) | ⬜ scaffold |
+| **Analytics** (usage, latency, drift, perf) | Control observability | ✅ console |
+| **Reports** (regulator-ready exports) | Consumption (D) | ⬜ scaffold |
+| **Regulatory** (framework mapping, DPIA export) | Org/Regulatory (E) | ✅ console |
Layer order (linear story): **Data → AI → Control → Org/Regulatory → Consumption.**
@@ -68,9 +69,9 @@ Layer order (linear story): **Data → AI → Control → Org/Regulatory → Con
in-repo: gateway over HTTP, Brain via a lib interface (LanceDB swappable), Postgres via
Drizzle.
5. **Tiered integration — native vs embed.** Each capability declares `render: 'native' |
- 'embed'`:
+'embed'`:
- **Tier 1 — commodity:** our UI over the API (secrets, vector store, audit query, basic
- metrics, RBAC). *Built.*
+ metrics, RBAC). _Built._
- **Tier 2 — common-80% native, deep-20% embed:** drift (our cards + deep-link),
policies (Monaco + OPA eval — OPA has no heavy UI anyway).
- **Tier 3 — rich UI, don't rebuild:** Grafana-class dashboards, Keycloak admin, eval
@@ -118,7 +119,7 @@ one compatibility test per adapter.
for, and their access. Build **now** to avoid a retrofit.
- **ABAC on top of RBAC** (RBAC already built): attributes = tenant · purpose · data-class,
enforced at the gateway and on each tool/data slice. Policy-as-code via OPA (Monaco editor
- + OPA eval API — Tier-2 native).
+ - OPA eval API — Tier-2 native).
- **SSO** (Google + Microsoft Entra via Auth.js) shipped; Keycloak at scale.
## 9. What's built & verified (today)
@@ -137,7 +138,7 @@ All `tsc`/lint/prettier clean; APIs validated by curl.
internal Admin module, evaluate endpoint.
3. ✅ **Adapter layer** — `src/lib/adapters/` capability ports (inference/observability/
secrets/guardrails/retrieval) + adapters, swap via `OFFGRID_ADAPTER_`, `render:
- native|embed|headless`; Brain embeds through the inference port; `/admin/adapters` API +
+native|embed|headless`; Brain embeds through the inference port; `/admin/adapters` API +
Admin "Integrations · adapters" surface.
4. ✅ **Evals + golden sets** — golden query→expected-doc sets over the Brain; recall-scored
runs persisted; surfaced in the Brain module; `/admin/golden-cases` + `/admin/evals` API.
@@ -158,19 +159,19 @@ All `tsc`/lint/prettier clean; APIs validated by curl.
9. ✅ **Tier-3 embeds** — SSO'd iframes for rich OSS UIs (SigNoz, OpenBao, Keycloak, Marquez,
Langfuse), driven by `render:'embed'` + `embedUrl`. Mere aggregation → license never touches core.
10. ✅ **Model routing (smart + conditional + cloud leash)** — `routing_rules` evaluator folded into
- the policy bundle; `/admin/routing` + `/evaluate`; Control-plane UI + tester. PII→local, etc.
+ the policy bundle; `/admin/routing` + `/evaluate`; Control-plane UI + tester. PII→local, etc.
11. ✅ **Node↔console wiring** — desktop node client (enroll→policy→audit→commands) + Settings UI.
## 10b. Backlog (approved, sequenced) — toward Portkey/Bedrock parity
a. **Infra:** Redis (cache + rate-limit), OpenSearch (SIEM), Unleash (feature flags) → compose +
- `caching`/`siem`/`flags` capabilities.
+`caching`/`siem`/`flags` capabilities.
b. **Caching** (first-party): exact + semantic response cache, Redis-backed (we own it).
c. **Feature-flag module control**: module/capability enablement routed through flags (Unleash).
d. **Prompt registry**: first-party templates + versioning (Langfuse optional backend).
e. **Evals expansion**: promptfoo (Node) + Ragas/DeepEval (service); golden-set stays baseline.
f. **FinOps + token issuance**: virtual keys scoped to user/project, budgets, cost = tokens×price.
- (Gaps we are NOT closing per decision: reversible tokenization vault, Ranger cell-level policies.)
+(Gaps we are NOT closing per decision: reversible tokenization vault, Ranger cell-level policies.)
## 11. Principles to never break
diff --git a/docs/RELEASE_DESKTOP.md b/docs/RELEASE_DESKTOP.md
index 9ff40ea4..6e2b533d 100644
--- a/docs/RELEASE_DESKTOP.md
+++ b/docs/RELEASE_DESKTOP.md
@@ -19,6 +19,7 @@ mobile licensing model (same Keygen account/product — a key works on both).
synchronously at load via the `pro:is-enabled` IPC (preload → `window.api.isPro`).
Env overrides (dev/contributor only):
+
- `OFFGRID_PRO=0` → force free even in a pro build.
- `OFFGRID_PRO=1` → force pro on **without** a license (for working on pro features).
- unset → the real paid path: license-gated.
@@ -41,6 +42,7 @@ git lfs pull # ensure resources/bin/* are real binaries, no
These builds are **unsigned** (no cert/Apple-ID prompts) and never touch GitHub.
### Smoke test
+
1. Open `dist/OffGrid-core-.dmg`, drag to /Applications, **right-click → Open**
(unsigned → Gatekeeper). Confirm: app launches, a model downloads + chat works,
pro tabs show the UpgradeScreen (no license box, since core).
@@ -73,6 +75,7 @@ Buy Pro on web (RevenueCat checkout)
```
Desktop licensing code (ported from mobile):
+
- `src/main/licensing/keygen-config.ts` — account/product/policy IDs, public key (non-secret).
- `src/main/licensing/keygen-client.ts` — Keygen REST (validate / activate / list / deactivate).
- `src/main/licensing/device-fingerprint.ts` — stable per-install id (userData file).
@@ -89,18 +92,21 @@ fingerprint and reclaim their slot.
> Not yet wired into CI as two artifacts — see TODO at the bottom. The mechanics:
### macOS (working today)
+
Signing uses the Apple Developer ID + notarization (`electron-builder.yml`
`mac.notarize: true`). CI secrets: `CSC_LINK`, `CSC_KEY_PASSWORD`,
`APPLE_API_KEY*`. Do **not** add an afterSign re-sign hook — it invalidates the
notarization staple (this was a past bug).
### Windows — PARKED
+
Windows is on hold and owned elsewhere (the bundled llama-server doesn't start in
the Windows binary; being fixed separately). Azure Trusted Signing setup (§4) is
kept below for when Windows resumes, but it is **not** on the current path. Focus
is macOS core + pro.
### Per-artifact config
+
Core and pro differ only by `OFFGRID_FORCE_CORE`, `productName`, `appId`, and
`artifactName` (see `scripts/build-mac-local.sh` for the exact overrides). They
must publish to **separate update channels** so electron-updater never feeds a
@@ -115,6 +121,7 @@ so a downloadable `.pfx` no longer works in CI. Azure Trusted Signing is the
cheapest CI-native option (~$10/mo) and electron-builder 25+ supports it natively.
### One-time setup (Azure portal — only the org owner can do this)
+
1. **Subscription + provider**: in an Azure subscription, register the
`Microsoft.CodeSigning` resource provider.
2. **Trusted Signing Account**: create one (region: East US / West US3 /
@@ -127,17 +134,19 @@ cheapest CI-native option (~$10/mo) and electron-builder 25+ supports it nativel
it the **"Trusted Signing Certificate Profile Signer"** role on the account.
### Values to collect
-| What | Where it goes |
-|------|----------------|
+
+| What | Where it goes |
+| ------------------------------------------------------ | ----------------------- |
| `endpoint` (e.g. `https://eus.codesigning.azure.net/`) | electron-builder config |
-| `codeSigningAccountName` | electron-builder config |
-| `certificateProfileName` | electron-builder config |
-| `publisherName` (exact validated org name) | electron-builder config |
-| `AZURE_TENANT_ID` | GitHub repo secret |
-| `AZURE_CLIENT_ID` | GitHub repo secret |
-| `AZURE_CLIENT_SECRET` | GitHub repo secret |
+| `codeSigningAccountName` | electron-builder config |
+| `certificateProfileName` | electron-builder config |
+| `publisherName` (exact validated org name) | electron-builder config |
+| `AZURE_TENANT_ID` | GitHub repo secret |
+| `AZURE_CLIENT_ID` | GitHub repo secret |
+| `AZURE_CLIENT_SECRET` | GitHub repo secret |
### electron-builder wiring (when values are in hand)
+
Add to the Windows config (electron-builder 25+ reads `AZURE_*` env via
DefaultAzureCredential and auto-downloads the Trusted Signing dlib):
@@ -145,10 +154,10 @@ DefaultAzureCredential and auto-downloads the Trusted Signing dlib):
win:
executableName: off-grid-ai
azureSignOptions:
- publisherName: ""
- endpoint: "https://.codesigning.azure.net/"
- certificateProfileName: ""
- codeSigningAccountName: ""
+ publisherName: ''
+ endpoint: 'https://.codesigning.azure.net/'
+ certificateProfileName: ''
+ codeSigningAccountName: ''
```
SmartScreen reputation accrues over downloads; Microsoft-backed certs gain trust
@@ -157,6 +166,7 @@ quickly. No EV needed.
---
## TODO before first paid release (macOS)
+
- [ ] Split `.github/workflows/release.yml` into **macOS** core + pro build jobs,
using `OFFGRID_FORCE_CORE` and the per-artifact overrides; separate update channels.
- [ ] Confirm the RevenueCat offering/products issue desktop-valid keys (they're
@@ -165,5 +175,6 @@ quickly. No EV needed.
(`license:list-devices` / `license:deactivate`).
### Parked (owned elsewhere)
+
- [ ] Windows: bundled llama-server doesn't start — fix in progress separately.
- [ ] Azure Trusted Signing validated + `azureSignOptions` wired (§4) — resumes with Windows.
diff --git a/docs/RELEASE_TEST_CHECKLIST.md b/docs/RELEASE_TEST_CHECKLIST.md
index db1b2381..0b34d9b0 100644
--- a/docs/RELEASE_TEST_CHECKLIST.md
+++ b/docs/RELEASE_TEST_CHECKLIST.md
@@ -14,8 +14,10 @@ passed typecheck/tests/build but was broken at runtime).
---
-## 1. Chat stop button (commit `feat(chat): stop button…`)
+## 1. Chat stop button (commit `feat(chat): stop button…`)
+
Highest-value manual check - this was the original bug.
+
- [ ] Ask a question with **All memory** + **Thinking** on. A red **stop** button appears next to Send **immediately** (during "Searching your memory…", before any tokens).
- [ ] Click stop during that pre-stream phase → generation aborts, no error bubble, no half-written answer.
- [ ] Ask again; let it start streaming tokens; click stop mid-stream → partial answer is kept, stream ends cleanly.
@@ -23,23 +25,28 @@ Highest-value manual check - this was the original bug.
- [ ] Queue a second message while one is generating, then stop → queued message is dropped, UI returns to idle.
- [ ] Image mode: start a generation, click **Stop** → image job cancels, no error bubble.
-## 2. ctxSize default (commit `refactor(config)…`, P0.3)
+## 2. ctxSize default (commit `refactor(config)…`, P0.3)
+
- [ ] Fresh profile / Settings → Model: context-window default now reads **16384** (was 32768 in UI while backend ran 16384).
- [ ] "Reset to defaults" sets context to 16384 and inference still runs (no engine restart failure).
-## 3. Engine ports (commit `refactor(config)…`, F1)
+## 3. Engine ports (commit `refactor(config)…`, F1)
+
Values are unchanged (8439/7878/7879) - this only de-duplicated the literals. Confirm no regression:
+
- [ ] App launches; chat model comes up (llama-server on **:8439**).
- [ ] Gateway reachable on **:7878** (Gateway screen / `curl 127.0.0.1:7878/v1/models`).
- [ ] Media playback (replay video) works (media server on **:7879**).
-## 4. Type-boundary fixes (commit `fix(types)…`, P0.1/P0.2)
+## 4. Type-boundary fixes (commit `fix(types)…`, P0.1/P0.2)
+
- [ ] Create a **project**, start a chat inside it, reload → the chat stays associated with the project (project_id no longer lost).
- [ ] Generate/save a **text** and an **image** artifact from chat → both save and reopen (saveArtifact union fix).
---
## 5. Consolidation refactors (behavior-preserving — smoke only)
+
Each is a DRY/SOLID dedup meant to change NO behavior. Listed so you can spot-check the paths they touched.
- **Transcription engine selection** (B3/C1 — `select.ts` dispatcher, `bin-resolution.ts`). Fallback order is unchanged (pickTranscription is byte-identical). Verify: with a Parakeet model active, dictation/file transcription routes to Parakeet; with a whisper ggml model active, whisper runs; STT resident mode still upgrades to the warm whisper-server and degrades to one-shot when the server binary is absent.
@@ -48,7 +55,9 @@ Each is a DRY/SOLID dedup meant to change NO behavior. Listed so you can spot-ch
- **Pro CRM helpers** (D1 extractJson + D4 today/hasColumn/notify, in the `pro` repo). Behavior-preserving per every call site; agent reported NO runtime-visible change. Low-risk, but if you exercise CRM/meetings/dictation LLM-extraction flows, confirm they still parse results and notifications still fire (skills notification body still truncates at 240 chars; proactive unchanged).
### Logic extraction (pure decision logic pulled out of I/O shells — behavior verbatim)
+
These moved pure logic into testable modules; the shell just imports and calls it. Smoke the paths:
+
- **Gateway request handling** (`model-server/*`): image generation with a size/aspect param, a vision request with a remote-URL image (still inlined to the model), async requests (`?async=true` -> 202 + poll), and an error response (e.g. no model installed -> correct error envelope). Chat message sanitization for Gemma still consolidates system messages.
- **LLM streaming** (`llm/sse-stream`, the highest-value path - this is the original-bug area): a chat with Thinking on streams token-by-token AND shows the reasoning bubble; content and reasoning stay separated; stop mid-stream keeps the partial. Payload shape unchanged (images, thinking flag).
- **Search ranking** (`search-ranking`): memory search returns sensibly ordered results (recency + relevance), citations resolve.
@@ -56,5 +65,6 @@ These moved pure logic into testable modules; the shell just imports and calls i
- **Image generation** (`imagegen/*`): generate an image on each runtime you have (sd-cli standard, Z-Image if present, Core ML if present) - same output/quality; the RAM guard still refuses an over-budget model with the same message; progress bar advances (sampling -> decoding); LoRA-on-quantized still refused; img2img still works. sd-server resident fast-path unchanged.
### NOT landed (recorded honestly)
+
- **D8 (pro clipboard TypeIcon dedup)** - the agent's work was lost (worktree auto-pruned before commit). No regression: the existing duplicated icon code remains and behaves as before. Re-do later; nothing to test.
- **D3 notification copy** - deliberately NOT applied. Notification timestamps keep their existing verbose format ("3 minutes ago"); the compact-format change was dropped to avoid a silent UX change.
diff --git a/docs/SYNC_PLAN.md b/docs/SYNC_PLAN.md
index a3dbace1..8120aa16 100644
--- a/docs/SYNC_PLAN.md
+++ b/docs/SYNC_PLAN.md
@@ -1,6 +1,6 @@
# Off Grid — Cross-Device Sync & Offload Plan
-> **Vision:** every Off Grid device is a window onto *all* of your information.
+> **Vision:** every Off Grid device is a window onto _all_ of your information.
> Open the phone, the laptop, the tablet — same chats, same projects, same
> memory, same search — and when a more capable device is nearby, heavy work
> (LLM inference, big search, media) transparently runs there. No cloud, no
@@ -8,7 +8,7 @@
> in vicinity.
>
> **And then it makes sense of all of it:** because every device ends up holding
-> the same unified corpus, the local model on *any* device can search, reason,
+> the same unified corpus, the local model on _any_ device can search, reason,
> and reflect across everything — every chat, project, and memory — no matter
> which device created it.
@@ -67,16 +67,16 @@ Status: **planning**. Nothing here is built yet except the pieces noted as
**Two traffic types, one transport:**
-| | Replication | Live RPC |
-|---|---|---|
-| **What** | chats, projects, memory | LLM offload, global search, media fetch |
-| **Pattern** | bidirectional op-log, converges | request/response (+ token streaming) |
+| | Replication | Live RPC |
+| ------------------ | -------------------------------------- | ---------------------------------------- |
+| **What** | chats, projects, memory | LLM offload, global search, media fetch |
+| **Pattern** | bidirectional op-log, converges | request/response (+ token streaming) |
| **Available when** | always (data is local on every device) | only when the target peer is in vicinity |
-| **Channel** | `@offgrid/sync` `app` channel `state` | `@offgrid/sync` `app` channel `rpc` |
+| **Channel** | `@offgrid/sync` `app` channel `state` | `@offgrid/sync` `app` channel `rpc` |
**Why tunnel RPC through `@offgrid/sync` instead of binding the gateway to
`0.0.0.0` + a token:** the gateway (`:7878`) and `universalSearch()` stay
-`127.0.0.1`-only; the desktop proxies tunneled requests to its *own* localhost.
+`127.0.0.1`-only; the desktop proxies tunneled requests to its _own_ localhost.
That gives us E2E encryption, no new attack surface, and reuses pairing + pro
gating for free. "In vicinity" is simply: the paired peer is visible on mDNS.
@@ -85,6 +85,7 @@ gating for free. "In vicinity" is simply: the paired peer is visible on mDNS.
## 3. What exists vs. what we build
### Already built (leverage)
+
- **`@offgrid/sync`** (`shared/packages/sync`): `SyncEngine`, `TransportBridge`
abstraction, NaCl crypto, passphrase challenge-response pairing, mDNS via
`bonjour-service`, generic `onAppMessage` channel, **Node** TCP + discovery
@@ -97,6 +98,7 @@ gating for free. "In vicinity" is simply: the paired peer is visible on mDNS.
licensing mirroring desktop (same account/product).
### Gaps to close (the actual work)
+
1. **RN `TransportBridge` adapter** for `@offgrid/sync` (only Node exists). ← critical path
2. **RN mDNS adapter** (browse/advertise `_offgrid._tcp`).
3. **Op-log replication layer** for chats + projects (extend ROADMAP 1.2/1.3
@@ -118,24 +120,24 @@ primitive is already cross-platform, so this is two adapter implementations
**Per-primitive support:**
-| Primitive | Desktop adapter (Electron/Node) | Mobile adapter (RN) |
-|---|---|---|
-| TCP transport | Node `net` (macOS ✅ / Windows ✅) | `react-native-tcp-socket` (iOS ✅ / Android ✅) |
+| Primitive | Desktop adapter (Electron/Node) | Mobile adapter (RN) |
+| -------------- | ----------------------------------------------------------------- | --------------------------------------------------------- |
+| TCP transport | Node `net` (macOS ✅ / Windows ✅) | `react-native-tcp-socket` (iOS ✅ / Android ✅) |
| mDNS discovery | `bonjour-service` — pure JS over UDP 5353 (macOS ✅ / Windows ✅) | `react-native-zeroconf` → iOS Bonjour ✅ / Android NSD ✅ |
-| Crypto (NaCl) | `tweetnacl` pure JS (all ✅) | `tweetnacl` + `react-native-get-random-values` (all ✅) |
-| Large media | HTTP-on-dynamic-port (all ✅) | HTTP-on-dynamic-port — bypasses RN bridge (all ✅) |
+| Crypto (NaCl) | `tweetnacl` pure JS (all ✅) | `tweetnacl` + `react-native-get-random-values` (all ✅) |
+| Large media | HTTP-on-dynamic-port (all ✅) | HTTP-on-dynamic-port — bypasses RN bridge (all ✅) |
So **one Node adapter covers macOS + Windows**, **one RN adapter covers iOS +
Android**. `@offgrid/sync`'s wire/crypto/pairing core is shared by both.
**Per-OS config & caveats (the only platform-specific work):**
-| OS | What's needed | Notes |
-|---|---|---|
-| **macOS** | nothing | Bonjour native; works today |
-| **Windows** | Firewall allow-rule for the app (inbound TCP + UDP 5353) | NSIS installer should add it, else first-listen prompt. mDNS binds 5353 with `SO_REUSEADDR` to coexist with the OS responder. **Desktop-as-offload-server also needs the Windows `llama-server` build, which is currently parked in CI** — replication/search work regardless; offloading *to* a Windows desktop waits on that binary. |
-| **iOS** | `NSLocalNetworkUsageDescription` + `NSBonjourServices` listing `_offgrid._tcp` | Declarative Info.plist entries; without them iOS 14+ hides the service. One-time OS permission prompt. |
-| **Android** | `WifiManager.MulticastLock` while browsing; `INTERNET` permission | `react-native-zeroconf` handles NSD; acquire/release the multicast lock around discovery for reliability. |
+| OS | What's needed | Notes |
+| ----------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **macOS** | nothing | Bonjour native; works today |
+| **Windows** | Firewall allow-rule for the app (inbound TCP + UDP 5353) | NSIS installer should add it, else first-listen prompt. mDNS binds 5353 with `SO_REUSEADDR` to coexist with the OS responder. **Desktop-as-offload-server also needs the Windows `llama-server` build, which is currently parked in CI** — replication/search work regardless; offloading _to_ a Windows desktop waits on that binary. |
+| **iOS** | `NSLocalNetworkUsageDescription` + `NSBonjourServices` listing `_offgrid._tcp` | Declarative Info.plist entries; without them iOS 14+ hides the service. One-time OS permission prompt. |
+| **Android** | `WifiManager.MulticastLock` while browsing; `INTERNET` permission | `react-native-zeroconf` handles NSD; acquire/release the multicast lock around discovery for reliability. |
Conclusion: **all four platforms are supported from the existing two codebases.**
Per-OS deltas are config (plist / firewall / multicast lock), not forks.
@@ -145,6 +147,7 @@ Per-OS deltas are config (plist / firewall / multicast lock), not forks.
## 5. Data model & sync semantics
### Convergence model (ROADMAP 1.2)
+
- **Append-only op-log** per record type: each change is an op
`{ id, entity, entityId, field/patch, lamport, deviceId, ts }`.
- **Lamport clock** for causal ordering; **last-writer-wins** on `(lamport, deviceId)`
@@ -155,20 +158,22 @@ Per-OS deltas are config (plist / firewall / multicast lock), not forks.
(the message types ROADMAP already names for memory; reused here).
### ID alignment (must-do before bidirectional)
-| Record | Desktop today | Mobile today | Sync requirement |
-|---|---|---|---|
-| Conversation | `id TEXT` (UUID) ✅ | UUID ✅ | already compatible |
-| **Message** | `id INTEGER AUTOINCREMENT` ❌ | string id | **migrate to UUID** (autoincrement isn't mesh-safe) |
-| Project | `id TEXT` (UUID) ✅ | UUID ✅ | compatible |
-| Thread / project_message | mixed | mixed | give messages UUIDs |
+
+| Record | Desktop today | Mobile today | Sync requirement |
+| ------------------------ | ----------------------------- | ------------ | --------------------------------------------------- |
+| Conversation | `id TEXT` (UUID) ✅ | UUID ✅ | already compatible |
+| **Message** | `id INTEGER AUTOINCREMENT` ❌ | string id | **migrate to UUID** (autoincrement isn't mesh-safe) |
+| Project | `id TEXT` (UUID) ✅ | UUID ✅ | compatible |
+| Thread / project_message | mixed | mixed | give messages UUIDs |
Desktop migration: add a stable `uuid` to `messages` (or switch PK), keep the
old autoincrement for local FK joins. Mobile: messages already have ids; ensure
they're UUIDs. Both stamp `updated_at`/`lamport` on every write.
### What replicates vs. fetches on demand
+
- **Always replicate (small):** chats, projects, memory text/metadata, entities.
- → instant local search on *every* device.
+ → instant local search on _every_ device.
- **Fetch on demand (large):** capture frames/screenshots, recording media,
big files — pulled over the RPC/large-file path only when opened.
@@ -178,7 +183,8 @@ they're UUIDs. Both stamp `updated_at`/`lamport` on every write.
Each phase ends in a checkpoint mirroring the workspace ROADMAP (C4/C5).
-### Phase A — Pairing foundation *(unblocks everything)*
+### Phase A — Pairing foundation _(unblocks everything)_
+
**Goal:** any two of your devices discover each other, pair once, and hold an
encrypted session.
@@ -204,6 +210,7 @@ encrypted session.
passphrase, and exchange an encrypted ping. (= ROADMAP C1.1 across platforms.)
### Phase B — Chats + Projects replication (**bidirectional**)
+
**Goal:** create/edit a chat or project on any device; it appears everywhere.
- B1. Op-log schema + materializer (shared TS in `@offgrid/sync` or a new
@@ -220,6 +227,7 @@ passphrase, and exchange an encrypted ping. (= ROADMAP C1.1 across platforms.)
edit both offline, reconnect, both converge identically. (= ROADMAP C1.2 / C4.1.)
### Phase C — Global search + LLM offload (the "seamless offload")
+
**Goal:** search all your info from any device; run models on the best nearby
device automatically.
@@ -240,7 +248,8 @@ chat completion runs on the laptop's model automatically because it's nearby.
(= ROADMAP C5.1 + your offload goal.)
### Phase D — Cross-device intelligence ("make sense of all of it")
-**Goal:** any device reasons over the *whole* mesh's information as one corpus.
+
+**Goal:** any device reasons over the _whole_ mesh's information as one corpus.
- D1. Point each device's RAG / search / reflect at the **merged store** (the
replicated chats + projects + memory + entities), so the local model answers
@@ -250,14 +259,15 @@ chat completion runs on the laptop's model automatically because it's nearby.
- D3. Heavy reasoning auto-offloads to the most capable present peer (reuses the
Phase C tunnel) — e.g. the phone asks a question; the laptop's bigger model
answers over the unified corpus and streams back.
-- D4. (Ties to ROADMAP Phase 2B) only explicitly-shared, scoped *intelligence*
+- D4. (Ties to ROADMAP Phase 2B) only explicitly-shared, scoped _intelligence_
crosses devices — never raw frames by default.
**Checkpoint D:** ask a question on the phone that can only be answered from data
created on the laptop, and get a correct, cited answer — reasoned locally/offloaded,
never via cloud.
-### Phase E — Parity expansion *(ROADMAP Phase 5, later)*
+### Phase E — Parity expansion _(ROADMAP Phase 5, later)_
+
Mobile screen capture (ReplayKit / MediaProjection + Vision / ML Kit),
integrations, universal clipboard via the `@offgrid/clipboard` RN bridge — all
riding the same mesh. Brings mobile toward feature parity with desktop.
@@ -307,6 +317,7 @@ This is the part that has to feel magical, so it's explicit:
---
## 8. Security model
+
- **Pairing:** passphrase never leaves the device; only PBKDF2-style derived
proofs (existing `@offgrid/sync` crypto).
- **In transit:** every post-pairing frame is XSalsa20-Poly1305 (NaCl secretbox)
@@ -320,23 +331,26 @@ This is the part that has to feel magical, so it's explicit:
---
## 9. Risks & mitigations
-| Risk | Mitigation |
-|---|---|
-| iOS Local Network permission friction | Clear `NSLocalNetworkUsageDescription`; graceful prompt + fallback messaging |
-| Token streaming over framed encrypted channel | Stream deltas as small `app` frames (wire format already frames); backpressure aware |
-| Autoincrement message IDs break sync | UUID migration in Phase B before bidirectional |
-| Op-log divergence / clock skew | Lamport clock (not wall-clock) for ordering; LWW only as tiebreak |
-| Battery / radio on mobile | Pause browse in background; keepalive paused during transfers (EasyShare pattern) |
-| RN ↔ Node framing parity | Same `@offgrid/sync` wire code on both; conformance test across all 4 OSes |
-| Windows firewall blocks listen / mDNS | Installer adds an allow-rule (inbound TCP + UDP 5353); bind 5353 with `SO_REUSEADDR` to coexist with Windows' own mDNS responder |
-| Android drops multicast mDNS packets | Acquire `WifiManager.MulticastLock` while browsing; release when idle to save battery |
-| Offload to a Windows desktop | Gated on the parked Windows `llama-server` build; until then Windows is a sync/search peer, not an inference host |
-| Replicating huge capture corpus to phone | Replicate metadata/text only; frames/media fetched on demand |
+
+| Risk | Mitigation |
+| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| iOS Local Network permission friction | Clear `NSLocalNetworkUsageDescription`; graceful prompt + fallback messaging |
+| Token streaming over framed encrypted channel | Stream deltas as small `app` frames (wire format already frames); backpressure aware |
+| Autoincrement message IDs break sync | UUID migration in Phase B before bidirectional |
+| Op-log divergence / clock skew | Lamport clock (not wall-clock) for ordering; LWW only as tiebreak |
+| Battery / radio on mobile | Pause browse in background; keepalive paused during transfers (EasyShare pattern) |
+| RN ↔ Node framing parity | Same `@offgrid/sync` wire code on both; conformance test across all 4 OSes |
+| Windows firewall blocks listen / mDNS | Installer adds an allow-rule (inbound TCP + UDP 5353); bind 5353 with `SO_REUSEADDR` to coexist with Windows' own mDNS responder |
+| Android drops multicast mDNS packets | Acquire `WifiManager.MulticastLock` while browsing; release when idle to save battery |
+| Offload to a Windows desktop | Gated on the parked Windows `llama-server` build; until then Windows is a sync/search peer, not an inference host |
+| Replicating huge capture corpus to phone | Replicate metadata/text only; frames/media fetched on demand |
---
## 10. Decisions
+
**Resolved**
+
- Licensing/device cap → **Keygen only (5 machines)**; `@offgrid/sync` injects no
cap policy. Phone + laptop each activate the same key.
- **Scales per-license, not globally.** The cap is per license key (a
@@ -347,10 +361,11 @@ This is the part that has to feel magical, so it's explicit:
- Chats/projects → **bidirectional** from the start.
- Platforms → **all four: macOS + Windows (Electron) and iOS + Android (RN)**,
from the two existing codebases. Per-OS deltas are config only (plist /
- firewall / multicast lock). Caveat: offloading *to* a Windows desktop awaits
+ firewall / multicast lock). Caveat: offloading _to_ a Windows desktop awaits
the parked Windows `llama-server` binary; sync/search to/from Windows do not.
**Open (not blocking the plan; decide before Phase C)**
+
- Routing policy default: auto-offload whenever a desktop is present, or only
on Wi-Fi / when charging / above a model-size threshold?
- Search default: replicate-and-search-locally only, or always fan out live to
@@ -359,6 +374,7 @@ This is the part that has to feel magical, so it's explicit:
---
## 11. First step
+
Phase A1+A2: stand up the RN `TransportBridge` + mDNS adapters and prove a
desktop↔phone encrypted ping (Checkpoint A). Everything else builds on that
session. On your go, I'll expand Phase A into a file-by-file checklist (adapter
diff --git a/docs/WINDOWS_SUPPORT.md b/docs/WINDOWS_SUPPORT.md
index 302ac03a..0e59b6a7 100644
--- a/docs/WINDOWS_SUPPORT.md
+++ b/docs/WINDOWS_SUPPORT.md
@@ -16,48 +16,48 @@ at the bottom.
## Legend
-| Status | Meaning |
-|---|---|
-| 🟢 **Present — needs testing** | Cross-platform code path exists and any Windows-specific handling is implemented. Not yet verified on a real Windows machine. |
-| 🟡 **At risk — needs testing** | Implemented, but there's a known Windows-specific gap or fragile spot to confirm during testing (details in notes). |
-| 🔴 **Not present** | Not built / not wired for Windows yet. Work required. |
+| Status | Meaning |
+| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
+| 🟢 **Present — needs testing** | Cross-platform code path exists and any Windows-specific handling is implemented. Not yet verified on a real Windows machine. |
+| 🟡 **At risk — needs testing** | Implemented, but there's a known Windows-specific gap or fragile spot to confirm during testing (details in notes). |
+| 🔴 **Not present** | Not built / not wired for Windows yet. Work required. |
| ⚪ **N/A — Apple-only by design** | Will never run on Windows (Apple Silicon / Core ML / macOS Vision). Feature degrades gracefully; a cross-platform fallback usually covers it. |
---
## 1. Build, packaging & distribution
-| Item | Status | Notes / evidence |
-|---|---|---|
-| Windows CI build | 🟢 | `.github/workflows/windows-build.yml` — `windows-2022` runner, builds + packages on push to this branch. Uploads the installer as a **workflow artifact only** (`--publish never`); does not cut a release. Never run on a real machine yet. |
-| Native-module compile (node-gyp) | 🟢 | Pinned toolchain: `windows-2022` (VS 2022) + Python 3.12. `windows-latest`/VS 2026 + Python 3.13 break node-gyp 11 — documented in the workflow. Covers `better-sqlite3-multiple-ciphers`, `node-llama-cpp`, `sharp`. |
-| Windows runtime binaries fetch | 🟢 | `scripts/fetch-win-binaries.ps1` pulls win64 `llama-server` / `whisper-cli` / `sd-cli` / `ffmpeg` (+ DLLs) from upstream GitHub releases at build time — **versions resolved dynamically** (no longer stale). Repo LFS binaries are macOS-only and skipped (`lfs: false`). Fails loud if `llama-server.exe` is missing. |
-| NSIS installer | 🟢 | `electron-builder.yml` → `win.executableName`, `nsis` block (desktop shortcut, uninstall name). Untested end-to-end. |
-| Code signing | 🟡 | Optional via `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` secrets; **unset → unsigned build → SmartScreen will warn** on install. No cert configured yet. |
-| Auto-update | 🟡 | `electron-updater` code is cross-platform (`src/main/updater.ts`), and NSIS is a supported target. But `windows-build.yml` publishes nothing, so **no Windows update feed (`latest.yml`) is published** — auto-update won't function until a windows job is folded into `release.yml`. |
+| Item | Status | Notes / evidence |
+| -------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Windows CI build | 🟢 | `.github/workflows/windows-build.yml` — `windows-2022` runner, builds + packages on push to this branch. Uploads the installer as a **workflow artifact only** (`--publish never`); does not cut a release. Never run on a real machine yet. |
+| Native-module compile (node-gyp) | 🟢 | Pinned toolchain: `windows-2022` (VS 2022) + Python 3.12. `windows-latest`/VS 2026 + Python 3.13 break node-gyp 11 — documented in the workflow. Covers `better-sqlite3-multiple-ciphers`, `node-llama-cpp`, `sharp`. |
+| Windows runtime binaries fetch | 🟢 | `scripts/fetch-win-binaries.ps1` pulls win64 `llama-server` / `whisper-cli` / `sd-cli` / `ffmpeg` (+ DLLs) from upstream GitHub releases at build time — **versions resolved dynamically** (no longer stale). Repo LFS binaries are macOS-only and skipped (`lfs: false`). Fails loud if `llama-server.exe` is missing. |
+| NSIS installer | 🟢 | `electron-builder.yml` → `win.executableName`, `nsis` block (desktop shortcut, uninstall name). Untested end-to-end. |
+| Code signing | 🟡 | Optional via `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` secrets; **unset → unsigned build → SmartScreen will warn** on install. No cert configured yet. |
+| Auto-update | 🟡 | `electron-updater` code is cross-platform (`src/main/updater.ts`), and NSIS is a supported target. But `windows-build.yml` publishes nothing, so **no Windows update feed (`latest.yml`) is published** — auto-update won't function until a windows job is folded into `release.yml`. |
---
## 2. Core runtime features (the studio)
-| Feature | Status | Depends on | Notes / evidence |
-|---|---|---|---|
-| **The Gateway** (OpenAI-compatible API on `:7878`) | 🟢 | llama-server + Node HTTP | No platform-specific code; rides on chat. Headless `--server-only` path is pure Node. |
-| **Chat** (text + vision + reasoning, streaming) | 🟢 | `llama-server.exe` | `src/main/llm.ts` is the most Windows-hardened path: `exe()` suffix, DLL-dir prepended to `PATH`, and orphan-port cleanup via `netstat`/`tasklist`/`taskkill`. Highest-confidence runtime. |
-| **Model catalog + Hugging Face download** | 🟢 | Node fetch | `@offgrid/models` + `models-manager.ts` — pure JS, downloads into userData. Path handling is cross-platform. |
-| **Image generation — SD/SDXL/Z-Image (GGUF)** | 🟡 | `sd-cli.exe` | `src/main/imagegen.ts` uses `exe('sd-cli')` and the fetch script ships the win build + DLLs. **Gap to check:** the spawn only sets `cwd = binary dir` (`imagegen.ts:628`) and, unlike `llm.ts`, does **not** prepend the bin dir to `PATH`. Relies on Windows' default "load DLLs from the exe's own directory" behaviour — verify SD DLLs resolve. |
-| ↳ Image gen — **MLX / FLUX.2 / Z-Image-via-MLX** | ⚪ | mflux (Apple MLX) | `src/main/mflux.ts` is explicitly Apple-Silicon-only (`process.platform !== 'darwin'` gated off). On Windows, MLX models are simply not offered; **Z-Image still works via the `sd-cli` GGUF path.** |
-| ↳ Image gen — **Core ML / ANE acceleration** | ⚪ | `coreml-sd` (Swift) | macOS-only, gated off in `imagegen.ts`. Windows uses the standard `sd-cli` path. |
-| **Voice — Speech→Text** (whisper.cpp) | 🟢 | `whisper-cli.exe` + `ffmpeg.exe` | `src/main/rag/extractors.ts` uses `exe('whisper-cli')` / `exe('ffmpeg')`; both fetched by the PS1 script. ffmpeg is a self-contained static build. Untested. |
-| **Voice — Text→Speech** (Kokoro-82M) | 🟢 | onnxruntime-node worker | `src/main/tts.ts` runs the worker as Electron-as-Node (`ELECTRON_RUN_AS_NODE=1`) with a cross-platform prebuilt ORT. No platform branching. Untested. |
-| **Hands-free voice mode** | 🟢 | STT + TTS above | Renderer orchestration only; inherits STT/TTS status. |
-| **Embeddings** (`@xenova/transformers`) | 🟢 | onnxruntime-node | Prebuilt native runtime, cross-platform. Powers RAG + `/v1/embeddings`. |
-| **Projects / RAG** (docs, cited retrieval) | 🟢 | LanceDB + better-sqlite3 | Text/PDF/DOCX extraction is pure JS; vector store is `@lancedb/lancedb` (native, compiled in CI). Image docs are captioned by the **vision model** (not OCR), so they're cross-platform. Audio/video ingestion inherits whisper/ffmpeg status. |
-| **Artifacts / canvas** (HTML/React/SVG/Mermaid) | 🟢 | Renderer only | Sandboxed iframe, no platform code. Very high confidence. |
-| **Connectors (MCP)** | 🟢 | stdio / HTTP transports | HTTP/SSE connectors are platform-neutral. stdio connectors spawn via `StdioClientTransport` (`src/main/mcp.ts:114`), whose SDK uses **`cross-spawn`** — which resolves `npx` → `npx.cmd` via `cmd.exe /c` on Windows automatically. The classic gotcha is already handled by the dependency; still worth a live test. |
-| **Tools in chat** (calculator, datetime, web search) | 🟢 | Node | `src/main/tools.ts` — pure JS. Web search is the one intentionally-online feature (DuckDuckGo fetch). |
-| **Encryption at rest** | 🟢 | `better-sqlite3-multiple-ciphers` | Native module compiled in CI (node-gyp). Cross-platform SQLite cipher. |
-| **Onboarding / Settings / Command palette / theming** | 🟢 | Renderer only | Pure web UI, no platform code. |
+| Feature | Status | Depends on | Notes / evidence |
+| ----------------------------------------------------- | ------ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **The Gateway** (OpenAI-compatible API on `:7878`) | 🟢 | llama-server + Node HTTP | No platform-specific code; rides on chat. Headless `--server-only` path is pure Node. |
+| **Chat** (text + vision + reasoning, streaming) | 🟢 | `llama-server.exe` | `src/main/llm.ts` is the most Windows-hardened path: `exe()` suffix, DLL-dir prepended to `PATH`, and orphan-port cleanup via `netstat`/`tasklist`/`taskkill`. Highest-confidence runtime. |
+| **Model catalog + Hugging Face download** | 🟢 | Node fetch | `@offgrid/models` + `models-manager.ts` — pure JS, downloads into userData. Path handling is cross-platform. |
+| **Image generation — SD/SDXL/Z-Image (GGUF)** | 🟡 | `sd-cli.exe` | `src/main/imagegen.ts` uses `exe('sd-cli')` and the fetch script ships the win build + DLLs. **Gap to check:** the spawn only sets `cwd = binary dir` (`imagegen.ts:628`) and, unlike `llm.ts`, does **not** prepend the bin dir to `PATH`. Relies on Windows' default "load DLLs from the exe's own directory" behaviour — verify SD DLLs resolve. |
+| ↳ Image gen — **MLX / FLUX.2 / Z-Image-via-MLX** | ⚪ | mflux (Apple MLX) | `src/main/mflux.ts` is explicitly Apple-Silicon-only (`process.platform !== 'darwin'` gated off). On Windows, MLX models are simply not offered; **Z-Image still works via the `sd-cli` GGUF path.** |
+| ↳ Image gen — **Core ML / ANE acceleration** | ⚪ | `coreml-sd` (Swift) | macOS-only, gated off in `imagegen.ts`. Windows uses the standard `sd-cli` path. |
+| **Voice — Speech→Text** (whisper.cpp) | 🟢 | `whisper-cli.exe` + `ffmpeg.exe` | `src/main/rag/extractors.ts` uses `exe('whisper-cli')` / `exe('ffmpeg')`; both fetched by the PS1 script. ffmpeg is a self-contained static build. Untested. |
+| **Voice — Text→Speech** (Kokoro-82M) | 🟢 | onnxruntime-node worker | `src/main/tts.ts` runs the worker as Electron-as-Node (`ELECTRON_RUN_AS_NODE=1`) with a cross-platform prebuilt ORT. No platform branching. Untested. |
+| **Hands-free voice mode** | 🟢 | STT + TTS above | Renderer orchestration only; inherits STT/TTS status. |
+| **Embeddings** (`@xenova/transformers`) | 🟢 | onnxruntime-node | Prebuilt native runtime, cross-platform. Powers RAG + `/v1/embeddings`. |
+| **Projects / RAG** (docs, cited retrieval) | 🟢 | LanceDB + better-sqlite3 | Text/PDF/DOCX extraction is pure JS; vector store is `@lancedb/lancedb` (native, compiled in CI). Image docs are captioned by the **vision model** (not OCR), so they're cross-platform. Audio/video ingestion inherits whisper/ffmpeg status. |
+| **Artifacts / canvas** (HTML/React/SVG/Mermaid) | 🟢 | Renderer only | Sandboxed iframe, no platform code. Very high confidence. |
+| **Connectors (MCP)** | 🟢 | stdio / HTTP transports | HTTP/SSE connectors are platform-neutral. stdio connectors spawn via `StdioClientTransport` (`src/main/mcp.ts:114`), whose SDK uses **`cross-spawn`** — which resolves `npx` → `npx.cmd` via `cmd.exe /c` on Windows automatically. The classic gotcha is already handled by the dependency; still worth a live test. |
+| **Tools in chat** (calculator, datetime, web search) | 🟢 | Node | `src/main/tools.ts` — pure JS. Web search is the one intentionally-online feature (DuckDuckGo fetch). |
+| **Encryption at rest** | 🟢 | `better-sqlite3-multiple-ciphers` | Native module compiled in CI (node-gyp). Cross-platform SQLite cipher. |
+| **Onboarding / Settings / Command palette / theming** | 🟢 | Renderer only | Pure web UI, no platform code. |
---
@@ -65,8 +65,8 @@ at the bottom.
Ordered by likelihood of biting:
-1. **Nothing has run on Windows yet.** Every 🟢 above is "code looks right," not "verified." First real test = download the CI artifact and launch it on Windows 10/11. No *known-required* runtime code fix exists — the coding below is either shipping plumbing or contingent on what this first run breaks.
-2. **`sd-cli` DLL resolution** — `imagegen.ts:628` sets `cwd` but not `PATH` (chat's `llm.ts` does both). Windows searches the exe's own dir for DLLs by default, so this is *probably* fine; if SD fails to load its DLLs, mirror the `llm.ts` `PATH`-prepend fix (a few lines).
+1. **Nothing has run on Windows yet.** Every 🟢 above is "code looks right," not "verified." First real test = download the CI artifact and launch it on Windows 10/11. No _known-required_ runtime code fix exists — the coding below is either shipping plumbing or contingent on what this first run breaks.
+2. **`sd-cli` DLL resolution** — `imagegen.ts:628` sets `cwd` but not `PATH` (chat's `llm.ts` does both). Windows searches the exe's own dir for DLLs by default, so this is _probably_ fine; if SD fails to load its DLLs, mirror the `llm.ts` `PATH`-prepend fix (a few lines).
3. **Upstream binary compatibility** — the fetched llama/whisper/sd builds are CPU/AVX2 x64 baselines; confirm they spawn (no missing VC++ redistributable, correct AVX level) on target hardware.
4. **Unsigned installer / SmartScreen** — expected until a signing cert is added; will scare testers.
5. **Auto-update feed not published** for Windows — installs won't self-update until a windows job lands in `release.yml`.
@@ -87,7 +87,7 @@ The Pro "sees / remembers / reflects / acts" layer is **not part of core** and i
present on Windows** (🔴). Its native binaries are macOS-only:
- Screen capture watcher (Swift) and meeting recorder — macOS binaries added by the Pro build.
-- **OCR** — `src/main/ocr.ts` shells out to a bundled **macOS Vision** binary; no Windows equivalent. (Note: this is *not* used by core image-RAG, which captions via the vision model.)
+- **OCR** — `src/main/ocr.ts` shells out to a bundled **macOS Vision** binary; no Windows equivalent. (Note: this is _not_ used by core image-RAG, which captions via the vision model.)
- macOS permissions (screen recording / accessibility) — `src/main/permissions.ts` is fully `darwin`-gated and no-ops elsewhere.
Porting Pro to Windows is a separate effort and not tracked in this matrix.
diff --git a/docs/WINDOWS_TEST_MODELS.md b/docs/WINDOWS_TEST_MODELS.md
index 6b09968e..6066e52b 100644
--- a/docs/WINDOWS_TEST_MODELS.md
+++ b/docs/WINDOWS_TEST_MODELS.md
@@ -16,11 +16,11 @@ and what test inputs to use. Model names below match the **Models** screen catal
Press **Win + Pause** (or Task Manager → Performance) to see installed RAM, then use the
smallest option that fits. Bigger = better quality but slower / needs more RAM.
-| Your RAM | Use the "Light" picks below | Can also try "Standard" picks |
-|---|---|---|
-| 8 GB | ✅ required | ⚠️ only the ~4GB image model, one at a time |
-| 16 GB | ✅ | ✅ |
-| 24 GB+ | ✅ | ✅ (plus the large models if you want) |
+| Your RAM | Use the "Light" picks below | Can also try "Standard" picks |
+| -------- | --------------------------- | ------------------------------------------- |
+| 8 GB | ✅ required | ⚠️ only the ~4GB image model, one at a time |
+| 16 GB | ✅ | ✅ |
+| 24 GB+ | ✅ | ✅ (plus the large models if you want) |
**Only ONE large model loads at a time.** Chat and image generation can't both be resident —
the app swaps them automatically, so don't be alarmed if starting an image generation pauses
@@ -32,13 +32,13 @@ chat briefly.
Download these **five** models first. Total ≈ **6.9 GB**.
-| Suite | Kind | Model name (in app) | Size | Min RAM |
-|---|---|---|---|---|
-| C — Chat | text | **Qwen 3.5 0.8B** | 0.53 GB | 3 GB |
-| C — Chat (vision) | vision | **Qwen3-VL 2B** | 1.9 GB (incl. vision file) | 6 GB |
-| E — Image gen | image | **SDXL Lightning (4-step)** | 4.1 GB | 8 GB |
-| F — Voice (speak) | voice | **Kokoro TTS 82M** | ~0.1 GB | 3 GB |
-| F — Voice (dictate) | transcription | **Whisper Base** | 0.15 GB | 3 GB |
+| Suite | Kind | Model name (in app) | Size | Min RAM |
+| ------------------- | ------------- | --------------------------- | -------------------------- | ------- |
+| C — Chat | text | **Qwen 3.5 0.8B** | 0.53 GB | 3 GB |
+| C — Chat (vision) | vision | **Qwen3-VL 2B** | 1.9 GB (incl. vision file) | 6 GB |
+| E — Image gen | image | **SDXL Lightning (4-step)** | 4.1 GB | 8 GB |
+| F — Voice (speak) | voice | **Kokoro TTS 82M** | ~0.1 GB | 3 GB |
+| F — Voice (dictate) | transcription | **Whisper Base** | 0.15 GB | 3 GB |
That set lets you run every core suite. Everything below is optional depth.
@@ -47,43 +47,48 @@ That set lets you run every core suite. Everything below is optional depth.
## 2. Per-suite model picks (with alternatives)
### Suite C — Chat (text)
-| Role | Model name | HF repo | Size | Notes |
-|---|---|---|---|---|
-| **Light (start here)** | **Qwen 3.5 0.8B** | `unsloth/Qwen3.5-0.8B-GGUF` | 0.53 GB | Tiny + fast; best first smoke test — proves `llama-server.exe` runs. |
-| Standard | Qwen 3.5 4B | `unsloth/Qwen3.5-4B-GGUF` | 2.7 GB | Better answers; use if you have ≥8 GB. |
-| Reasoning check (TC-CHAT-03) | Qwen 3.5 2B or 4B | — | — | These support "thinking" mode; use one of them for the reasoning test. |
-
-### Suite C — Chat with images (vision) → also used by TC-CHAT-04 & TC-PROJ-04
-| Role | Model name | HF repo | Size | Notes |
-|---|---|---|---|---|
-| **Light (start here)** | **Qwen3-VL 2B** | `unsloth/Qwen3-VL-2B-Instruct-GGUF` | 1.9 GB | Downloads the vision add-on automatically. Lightest capable vision model. |
-| Alternative light | SmolVLM2 2.2B | `ggml-org/SmolVLM2-2.2B-Instruct-GGUF` | 2.0 GB | Equivalent; use if Qwen3-VL misbehaves. |
-| Standard | Gemma 4 E4B | `unsloth/gemma-4-E4B-it-GGUF` | 6.0 GB | Higher quality vision + thinking; needs more RAM. |
+
+| Role | Model name | HF repo | Size | Notes |
+| ---------------------------- | ----------------- | --------------------------- | ------- | ---------------------------------------------------------------------- |
+| **Light (start here)** | **Qwen 3.5 0.8B** | `unsloth/Qwen3.5-0.8B-GGUF` | 0.53 GB | Tiny + fast; best first smoke test — proves `llama-server.exe` runs. |
+| Standard | Qwen 3.5 4B | `unsloth/Qwen3.5-4B-GGUF` | 2.7 GB | Better answers; use if you have ≥8 GB. |
+| Reasoning check (TC-CHAT-03) | Qwen 3.5 2B or 4B | — | — | These support "thinking" mode; use one of them for the reasoning test. |
+
+### Suite C — Chat with images (vision) → also used by TC-CHAT-04 & TC-PROJ-04
+
+| Role | Model name | HF repo | Size | Notes |
+| ---------------------- | --------------- | -------------------------------------- | ------ | ------------------------------------------------------------------------- |
+| **Light (start here)** | **Qwen3-VL 2B** | `unsloth/Qwen3-VL-2B-Instruct-GGUF` | 1.9 GB | Downloads the vision add-on automatically. Lightest capable vision model. |
+| Alternative light | SmolVLM2 2.2B | `ggml-org/SmolVLM2-2.2B-Instruct-GGUF` | 2.0 GB | Equivalent; use if Qwen3-VL misbehaves. |
+| Standard | Gemma 4 E4B | `unsloth/gemma-4-E4B-it-GGUF` | 6.0 GB | Higher quality vision + thinking; needs more RAM. |
> A "vision" model is what lets chat **see attached images**. Without one, TC-CHAT-04 and the
> image parts of Projects can't work — that's expected, not a bug.
### Suite E — Image generation
-| Role | Model name | HF repo | Size | Notes |
-|---|---|---|---|---|
-| **Start here** | **SDXL Lightning (4-step)** | `mzwing/SDXL-Lightning-GGUF` | 4.1 GB | Single file, "Recommended" — simplest path to prove `sd-cli.exe` + its DLLs load on Windows. **Do this one first.** |
-| Fastest drafts | SDXL Turbo (fast drafts) | `OlegSkutte/sdxl-turbo-GGUF` | 4.1 GB | 1–4 step quick drafts. |
-| **Advanced (test 2nd)** | Z-Image Turbo (2026) | `leejet/Z-Image-Turbo-GGUF` | ~6.7 GB total | Flagship, but uses a **multi-file** pipeline (downloads a text-encoder + VAE too). Because it's a more complex spawn, test it **after** SDXL Lightning succeeds — if Lightning works and Z-Image doesn't, note that difference. |
-| img2img (TC-IMG-03) | SDXL Lightning or any SDXL above | — | — | The SDXL models support image-to-image; Z-Image is txt2img only. |
+
+| Role | Model name | HF repo | Size | Notes |
+| ----------------------- | -------------------------------- | ---------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **Start here** | **SDXL Lightning (4-step)** | `mzwing/SDXL-Lightning-GGUF` | 4.1 GB | Single file, "Recommended" — simplest path to prove `sd-cli.exe` + its DLLs load on Windows. **Do this one first.** |
+| Fastest drafts | SDXL Turbo (fast drafts) | `OlegSkutte/sdxl-turbo-GGUF` | 4.1 GB | 1–4 step quick drafts. |
+| **Advanced (test 2nd)** | Z-Image Turbo (2026) | `leejet/Z-Image-Turbo-GGUF` | ~6.7 GB total | Flagship, but uses a **multi-file** pipeline (downloads a text-encoder + VAE too). Because it's a more complex spawn, test it **after** SDXL Lightning succeeds — if Lightning works and Z-Image doesn't, note that difference. |
+| img2img (TC-IMG-03) | SDXL Lightning or any SDXL above | — | — | The SDXL models support image-to-image; Z-Image is txt2img only. |
> **Image gen is the #1 Windows risk area.** If generation fails, grab the console text
> (per the test plan) — a `.dll` / library error here is exactly what we're hunting for.
### Suite F — Voice
-| Role | Model name | HF repo | Size | Notes |
-|---|---|---|---|---|
-| **Text-to-speech (speak)** | **Kokoro TTS 82M** | `onnx-community/Kokoro-82M-v1.0-ONNX` | ~0.1 GB | Default voice; used by TC-VOICE-01 / 03. |
-| TTS alternative | Piper – Lessac (English) | `rhasspy/piper-voices` | 0.06 GB | Use only if Kokoro fails. |
-| **Speech-to-text (dictate)** | **Whisper Base** | `ggerganov/whisper.cpp` (base) | 0.15 GB | Default for TC-VOICE-02 / 03. Proves `whisper-cli.exe` + `ffmpeg.exe`. |
-| STT lightest | Whisper Tiny | `ggerganov/whisper.cpp` (tiny) | 0.08 GB | Fastest, lower accuracy — fine for a functional test. |
-| STT best | Whisper Large v3 Turbo | `ggerganov/whisper.cpp` (large-v3-turbo) | 1.6 GB | Only if you want to check accuracy on ≥6 GB RAM. |
+
+| Role | Model name | HF repo | Size | Notes |
+| ---------------------------- | ------------------------ | ---------------------------------------- | ------- | ---------------------------------------------------------------------- |
+| **Text-to-speech (speak)** | **Kokoro TTS 82M** | `onnx-community/Kokoro-82M-v1.0-ONNX` | ~0.1 GB | Default voice; used by TC-VOICE-01 / 03. |
+| TTS alternative | Piper – Lessac (English) | `rhasspy/piper-voices` | 0.06 GB | Use only if Kokoro fails. |
+| **Speech-to-text (dictate)** | **Whisper Base** | `ggerganov/whisper.cpp` (base) | 0.15 GB | Default for TC-VOICE-02 / 03. Proves `whisper-cli.exe` + `ffmpeg.exe`. |
+| STT lightest | Whisper Tiny | `ggerganov/whisper.cpp` (tiny) | 0.08 GB | Fastest, lower accuracy — fine for a functional test. |
+| STT best | Whisper Large v3 Turbo | `ggerganov/whisper.cpp` (large-v3-turbo) | 1.6 GB | Only if you want to check accuracy on ≥6 GB RAM. |
### Suites G/H/J/K (Projects, Artifacts, Tools, Settings)
+
No extra models needed — they reuse the **text** model from Suite C (and the **vision** model
for image documents in TC-PROJ-04). Web search (TC-TOOL-02) needs internet but no model.
@@ -93,15 +98,15 @@ for image documents in TC-PROJ-04). Web search (TC-TOOL-02) needs internet but n
Put these in a folder like `C:\OffGridTest\` so they're easy to find in file pickers.
-| File | For test | How to make it |
-|---|---|---|
-| `budget.txt` | TC-PROJ-02 | A plain text file containing exactly: `The Q3 project budget is $5,000 and the deadline is March 14.` |
-| `budget.pdf` | TC-PROJ-02 (alt) | Same text saved/printed as a PDF (to test PDF extraction too). |
-| `photo.jpg` | TC-CHAT-04 / TC-PROJ-04 | Any clear photo — e.g. a picture of a **dog on grass**, or a screenshot with visible text. |
-| `sign.png` | TC-PROJ-04 | An image containing readable text (a sign, a slide) — checks the model reads text from images. |
-| short `speech.wav`/`.mp3` | TC-PROJ-04 (audio) | Record ~10 s of you saying a sentence, or grab any short clip. |
+| File | For test | How to make it |
+| ------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------- |
+| `budget.txt` | TC-PROJ-02 | A plain text file containing exactly: `The Q3 project budget is $5,000 and the deadline is March 14.` |
+| `budget.pdf` | TC-PROJ-02 (alt) | Same text saved/printed as a PDF (to test PDF extraction too). |
+| `photo.jpg` | TC-CHAT-04 / TC-PROJ-04 | Any clear photo — e.g. a picture of a **dog on grass**, or a screenshot with visible text. |
+| `sign.png` | TC-PROJ-04 | An image containing readable text (a sign, a slide) — checks the model reads text from images. |
+| short `speech.wav`/`.mp3` | TC-PROJ-04 (audio) | Record ~10 s of you saying a sentence, or grab any short clip. |
-For the doc-grounding test (TC-PROJ-02), the expected AI answer to *"What is the budget?"* is
+For the doc-grounding test (TC-PROJ-02), the expected AI answer to _"What is the budget?"_ is
**"$5,000"** with a cited source — that specific number is why we plant it in the file.
---
@@ -113,6 +118,7 @@ server — it's public and needs no login (first launch downloads the package, s
on for this test).
**TC-INT-02 — add this stdio connector:**
+
- Sidebar → **Integrations** → add connector → choose the **command** option.
- **Name:** `Filesystem`
- **command:** `npx`
@@ -123,6 +129,7 @@ on for this test).
exactly; that's the specific Windows behavior we're verifying.
**TC-INT-03 — use it in chat:**
+
- In a chat (with a text model loaded), ask: `List the files in C:\OffGridTest using your tools.`
- **Expected:** the AI calls the filesystem tool and lists `budget.txt`, `photo.jpg`, etc.
@@ -145,4 +152,4 @@ URL fails gracefully. Don't file "couldn't connect" as a bug without a real endp
5. (Optional) **Z-Image Turbo** → advanced image test.
If a download itself fails or hangs, that's a **Suite B (Models)** bug — report it with the
-model name and console output, and move on to whatever you *can* test.
+model name and console output, and move on to whatever you _can_ test.
diff --git a/docs/WINDOWS_TEST_PLAN.md b/docs/WINDOWS_TEST_PLAN.md
index 63c5c685..23ca8ea5 100644
--- a/docs/WINDOWS_TEST_PLAN.md
+++ b/docs/WINDOWS_TEST_PLAN.md
@@ -1,7 +1,7 @@
# Off Grid AI — Windows Test Plan (Core)
**For the tester:** You don't need to know this product beforehand. This doc tells you what
-each feature is, exactly what to click, and what *should* happen. Your job is to run each
+each feature is, exactly what to click, and what _should_ happen. Your job is to run each
test case on Windows and record **Pass / Fail / Blocked**, and file any problem using the
[bug format](#how-to-report-a-bug) below.
@@ -28,12 +28,13 @@ Everything happens locally, so the **first time you use a feature you usually ha
download a model** for it. That's expected.
### The window layout
+
- A **left sidebar** with these items: **Projects, Chat, Integrations, Models, Gateway,
Settings**. (You navigate by clicking these.)
- Some sidebar items may show a **lock icon or an "Upgrade / Pro" screen** when clicked
(e.g. Day, Replay, Reflect, Meetings, Actions). **These are "Pro" features and are OUT
OF SCOPE — skip them.** Only test the items listed above.
-- The app should work **fully offline**. The *only* feature that intentionally uses the
+- The app should work **fully offline**. The _only_ feature that intentionally uses the
internet is downloading models and "web search" in chat.
---
@@ -51,6 +52,7 @@ GPU (if any) : __________
```
### How to capture logs (do this — most bugs are useless without logs)
+
The packaged app hides its internal logs by default. To see them, **launch it from a
terminal so its output prints there:**
@@ -59,16 +61,18 @@ terminal so its output prints there:**
```powershell
& "$env:LOCALAPPDATA\Programs\off-grid-ai\off-grid-ai.exe"
```
- (If it installed elsewhere, right-click the desktop shortcut → *Open file location* to
+ (If it installed elsewhere, right-click the desktop shortcut → _Open file location_ to
find the `.exe`, then run that path.)
3. Leave this PowerShell window open — **error messages and `[llama-server]` / `[OCR]` /
`[update]` lines print here.** Copy/paste relevant lines into your bug report.
**Where app data lives** (models, database, generated images) — useful to attach or clear:
+
```
%APPDATA%\Off Grid AI (try this first)
%APPDATA%\off-grid-ai (fallback)
```
+
Open by pasting that into the File Explorer address bar.
---
@@ -99,19 +103,22 @@ Notes : anything else (e.g. "worked after restarting app")
```
### Severity rubric
-| Severity | Use when… |
-|---|---|
-| **Blocker** | The app won't install/launch, or a whole feature is completely unusable and has no workaround. |
-| **High** | A core feature fails or gives wrong results, but other features work. |
-| **Medium** | Feature works but is broken in a noticeable way (bad layout, slow, confusing error, minor data issue). |
-| **Low** | Cosmetic: typo, misalignment, wrong icon, polish. |
+
+| Severity | Use when… |
+| ----------- | ------------------------------------------------------------------------------------------------------ |
+| **Blocker** | The app won't install/launch, or a whole feature is completely unusable and has no workaround. |
+| **High** | A core feature fails or gives wrong results, but other features work. |
+| **Medium** | Feature works but is broken in a noticeable way (bad layout, slow, confusing error, minor data issue). |
+| **Low** | Cosmetic: typo, misalignment, wrong icon, polish. |
### Special things to flag loudly (Windows-specific red flags)
+
If you see any of these, note it prominently — they're the failures we most expect:
+
- A popup or console error mentioning **`.dll`**, **"VCRUNTIME"**, **"MSVCP"**, **"was not
found"**, or **"is not a valid Win32 application"** → a bundled AI binary failed to load.
- **SmartScreen / "Windows protected your PC"** warning during install → expected (build is
- unsigned) — just note it happened; click *More info → Run anyway* to continue.
+ unsigned) — just note it happened; click _More info → Run anyway_ to continue.
- A feature spins forever / never responds → capture the console and say which feature.
- After closing the app, a leftover **`llama-server.exe`** in Task Manager (see TC-STAB-02).
@@ -127,110 +134,138 @@ something earlier failed) · ⏭️ Skipped.
---
-### Suite A — Install & first launch `P0`
+### Suite A — Install & first launch `P0`
**What it proves:** the installer works and the app opens on Windows.
**TC-INSTALL-01 — Install the app**
+
1. Double-click the `-setup.exe` installer.
-2. If **"Windows protected your PC" (SmartScreen)** appears → click *More info* → *Run
- anyway*. (Note in your report that it appeared — expected for now.)
+2. If **"Windows protected your PC" (SmartScreen)** appears → click _More info_ → _Run
+ anyway_. (Note in your report that it appeared — expected for now.)
3. Complete the installer.
+
- **Expected:** Installs without error; a desktop shortcut named **Off Grid AI** is created.
**TC-INSTALL-02 — First launch & onboarding**
+
1. Launch the app (ideally from PowerShell per section 2 so you capture logs).
2. Observe the first-run **onboarding** screen(s); click the **Continue / Next** button
through to the end.
+
- **Expected:** A welcome/onboarding screen appears, then you land on the main app on the
**Models** screen. No crash, no blank white window.
**TC-INSTALL-03 — Window & navigation**
+
1. Click each sidebar item that is in scope: **Projects, Chat, Integrations, Models,
Gateway, Settings.**
+
- **Expected:** Each opens its screen without crashing. (Locked/Pro tabs showing an upgrade
screen are fine — skip them.)
---
-### Suite B — Models (download a model) `P0`
+### Suite B — Models (download a model) `P0`
**What it proves:** the app can download an AI model — nothing else works without one.
**TC-MODEL-01 — Browse the catalog**
+
1. Sidebar → **Models**.
2. Look at the recommended models list (grouped by size, e.g. "Fits in …").
+
- **Expected:** A list of models renders. No blank screen or error.
**TC-MODEL-02 — Download a small text model**
+
1. In **Models**, pick a **small** recommended chat/text model (smallest available, so the
download is quick).
2. Click its **Download** button. Watch the progress bar.
+
- **Expected:** Download progresses to 100% and the model shows as installed/active. A
**Cancel** control is available during download.
- **Watch for:** stuck at 0%, network error, or the file downloads but never becomes
"ready."
**TC-MODEL-03 — Hugging Face search (uses internet)**
+
1. In **Models**, use the search to look up a model by name (e.g. type "qwen").
+
- **Expected:** Search returns results you could download.
---
-### Suite C — Chat `P0`
+### Suite C — Chat `P0`
**What it proves:** the core AI text engine (`llama-server.exe`) runs on Windows. This is
the single most important suite.
**TC-CHAT-01 — Send a text message**
+
1. Sidebar → **Chat**. Start a new chat.
2. Type `Hello, who are you?` and send.
+
- **Expected:** The AI replies with text that **streams in word-by-word**. Reply is coherent.
- **Watch for (Windows red flag):** no reply at all + a `.dll` / "llama-server" error in the
PowerShell console = the AI binary failed to load. **Report as Blocker.**
**TC-CHAT-02 — Multi-turn conversation**
+
1. After the reply, send a follow-up like `Summarize that in one sentence.`
+
- **Expected:** The AI responds in context (remembers the previous message).
**TC-CHAT-03 — Reasoning / "thinking" mode**
+
1. If there's a **reasoning / thinking** toggle for the chat, enable it and ask a
reasoning question (e.g. `If a train travels 60km in 45 minutes, what is its speed?`).
+
- **Expected:** You may see a separate "thinking" section, then a final answer. Answer is
correct (80 km/h).
-**TC-CHAT-04 — Vision (attach an image)** — *requires a vision-capable model*
+**TC-CHAT-04 — Vision (attach an image)** — _requires a vision-capable model_
+
1. Download a **vision** model from Models if prompted (one that supports images).
2. In a chat, attach an image file (e.g. a photo or screenshot) and ask
`What's in this image?`.
+
- **Expected:** The AI describes the image contents.
- **Watch for:** attach button does nothing, or the model errors on the image.
**TC-CHAT-05 — Per-chat settings**
+
1. Open the chat's settings (temperature, context window) and change the context window.
+
- **Expected:** Setting saves; chat continues to work afterward (the model restarts quietly).
---
-### Suite D — Gateway (local API) `P0`
+### Suite D — Gateway (local API) `P0`
**What it proves:** the local OpenAI-compatible web API works — a key selling point.
**TC-GW-01 — Gateway screen**
+
1. Sidebar → **Gateway**.
+
- **Expected:** Shows a **Base URL** (e.g. `http://127.0.0.1:7878/v1`) and a list of
**Endpoints**.
**TC-GW-02 — Call the API from PowerShell**
+
1. With a chat model downloaded and the app open, run in PowerShell:
```powershell
curl.exe http://127.0.0.1:7878/v1/chat/completions -H "Content-Type: application/json" -d '{\"model\":\"local\",\"messages\":[{\"role\":\"user\",\"content\":\"Hello!\"}]}'
```
+
- **Expected:** A JSON response containing the AI's reply text.
- **Watch for:** "connection refused" (server not listening) or an empty/error JSON.
**TC-GW-03 — Models endpoint**
+
1. Run: `curl.exe http://127.0.0.1:7878/v1/models`
+
- **Expected:** JSON listing your installed model(s).
---
@@ -241,21 +276,27 @@ the single most important suite.
Windows risk area** — test carefully and capture the console.
**TC-IMG-01 — Download an image model**
+
1. Sidebar → **Models**. Find an **image generation** model (e.g. an SDXL/Z-Image entry)
and download it.
+
- **Expected:** Downloads and shows as installed.
**TC-IMG-02 — Generate an image**
+
1. Open the image-generation UI, enter a prompt like `a red bicycle on a beach, sunset`.
2. Start generation.
+
- **Expected:** You see a **live step-by-step preview** as the image forms, a progress/ETA,
and a final PNG. The image roughly matches the prompt.
- **Watch for (Windows red flag):** generation fails immediately with a **`.dll` error** in
the console (the SD engine couldn't load its libraries). **Report with the exact console
text — this is a specific thing we're checking.**
-**TC-IMG-03 — Image-to-image** *(if the UI offers it)*
+**TC-IMG-03 — Image-to-image** _(if the UI offers it)_
+
1. Provide a starting image + a prompt and generate.
+
- **Expected:** Output is a variation based on the input image.
---
@@ -266,19 +307,25 @@ Windows risk area** — test carefully and capture the console.
(Kokoro) work on Windows.
**TC-VOICE-01 — Text-to-speech (speak)**
+
1. Find the **speak / play audio** control on an AI message (or the voice settings), and
trigger it. Download the voice model if prompted.
+
- **Expected:** You **hear** the text spoken aloud through your speakers.
- **Watch for:** no audio, or a console error about the TTS worker.
**TC-VOICE-02 — Speech-to-text (transcribe)**
+
1. Use the **microphone / voice input** to dictate a message (say a sentence).
+
- **Expected:** Your speech is transcribed into text in the message box.
- **Watch for:** Windows may ask for **microphone permission** — allow it. No transcript, or
an `ffmpeg`/`whisper` error in console = fail.
-**TC-VOICE-03 — Hands-free voice mode** *(if present)*
+**TC-VOICE-03 — Hands-free voice mode** _(if present)_
+
1. Enter the hands-free voice mode and have a short spoken back-and-forth.
+
- **Expected:** You speak → it transcribes → AI replies → reply is spoken aloud.
---
@@ -288,26 +335,34 @@ Windows risk area** — test carefully and capture the console.
**What it proves:** document upload + "answer using my files" works.
**TC-PROJ-01 — Create a project**
+
1. Sidebar → **Projects** → create one (name it, e.g. "Test Project").
+
- **Expected:** Project is created and opens.
**TC-PROJ-02 — Upload a document & ask about it**
+
1. In the project's **Knowledge base**, upload a **PDF or .txt/.docx** file that contains
some specific fact (e.g. a document that says "The budget is $5,000").
2. Wait for it to finish processing.
3. Start a chat in that project and ask a question only answerable from the doc
(e.g. `What is the budget?`).
+
- **Expected:** The AI answers using the document ($5,000) and shows a **cited source**.
- **Watch for:** upload fails, processing hangs, or the AI ignores the document.
**TC-PROJ-03 — Per-project instructions**
+
1. Set a project instruction (e.g. "Always answer in French").
2. Ask a question in that project.
+
- **Expected:** The AI follows the instruction.
-**TC-PROJ-04 — Audio/image document** *(depends on voice/vision models)*
+**TC-PROJ-04 — Audio/image document** _(depends on voice/vision models)_
+
1. Upload an **image** (with visible text or clear objects) and/or a short **audio** file.
2. Ask about its contents.
+
- **Expected:** Image is described / audio is transcribed and usable in answers.
---
@@ -318,12 +373,16 @@ Windows risk area** — test carefully and capture the console.
reliable on Windows).
**TC-ART-01 — Render an artifact**
+
1. In Chat, ask: `Make a simple HTML page with a button that shows an alert when clicked.`
+
- **Expected:** A rendered **Preview** appears in a canvas, with a **Code / Preview** toggle
and a **Download** option. Clicking the button in the preview shows the alert.
**TC-ART-02 — Mermaid diagram**
+
1. Ask: `Draw a flowchart of making tea, as a mermaid diagram.`
+
- **Expected:** A diagram renders in the canvas.
---
@@ -334,21 +393,27 @@ reliable on Windows).
launch `npx` are a Windows-sensitive area** — test TC-INT-02.
**TC-INT-01 — Add an HTTP connector**
+
1. Sidebar → **Integrations** → add a connector using the **URL** option
(`https://mcp.example.com/endpoint` field). Use any test MCP endpoint you were given, or
- just verify the *form* opens and accepts input if you have no endpoint.
+ just verify the _form_ opens and accepts input if you have no endpoint.
+
- **Expected:** Connector saves; **Connect** attempts a connection.
-**TC-INT-02 — Add a stdio connector (`npx`)** — *Windows red flag area*
+**TC-INT-02 — Add a stdio connector (`npx`)** — _Windows red flag area_
+
1. Add a connector using the **command** option: command = `npx`, args = an MCP server
package you were given (or a known one).
2. Enable / connect it.
+
- **Expected:** The connector shows as **Connected** (the app launched `npx` under the
- hood).
+ hood).
- **Watch for:** "command not found" / spawn errors in the console — capture them exactly.
**TC-INT-03 — Use a connector in chat**
+
1. With a connector connected, ask the AI something that would use its tool.
+
- **Expected:** The AI calls the tool and uses the result in its answer.
---
@@ -356,11 +421,15 @@ launch `npx` are a Windows-sensitive area** — test TC-INT-02.
### Suite J — Tools in chat
**TC-TOOL-01 — Calculator / datetime**
+
1. Ask: `What is 1234 * 5678?` and `What is today's date and time?`
+
- **Expected:** Correct math (7,006,652) and a correct current date/time.
-**TC-TOOL-02 — Web search** *(uses internet)*
+**TC-TOOL-02 — Web search** _(uses internet)_
+
1. Ask something requiring fresh info, e.g. `Search the web for the latest news about NASA.`
+
- **Expected:** The AI performs a search and summarizes results.
---
@@ -368,13 +437,17 @@ launch `npx` are a Windows-sensitive area** — test TC-INT-02.
### Suite K — Settings, persistence & theming
**TC-SET-01 — Theme toggle**
+
1. Sidebar → **Settings**. Toggle between light and dark theme.
+
- **Expected:** The whole app switches theme cleanly (no unreadable text, no broken layout —
check the starry background is visible in both).
-**TC-SET-02 — Data persists across restart** *(also tests encryption-at-rest)*
+**TC-SET-02 — Data persists across restart** _(also tests encryption-at-rest)_
+
1. Have at least one chat with history.
2. **Fully quit** the app, then reopen it.
+
- **Expected:** Your previous chats/projects are **still there**. Downloaded models are still
installed.
- **Watch for:** database errors on startup in the console, or everything wiped.
@@ -384,50 +457,58 @@ launch `npx` are a Windows-sensitive area** — test TC-INT-02.
### Suite L — Stability & cleanup
**TC-STAB-01 — Long session**
+
1. Use chat + image gen + voice over ~15–20 minutes.
+
- **Expected:** No crash, no runaway memory. Note if the app becomes sluggish.
-**TC-STAB-02 — No orphaned processes after quit** — *Windows-specific check*
+**TC-STAB-02 — No orphaned processes after quit** — _Windows-specific check_
+
1. Quit the app fully.
2. Open **Task Manager → Details** and search for **`llama-server.exe`** (and
`sd-cli.exe`, `whisper-cli.exe`).
+
- **Expected:** **None** of these are still running after the app is closed.
- **Watch for:** a leftover `llama-server.exe` — report it (it would block the next launch).
**TC-STAB-03 — Relaunch after quit**
+
1. Reopen the app and send a chat message.
+
- **Expected:** Works first try (no "port in use" / model won't load error from a leftover
process).
---
-### Suite M — Auto-update *(likely N/A right now — confirm and note)*
+### Suite M — Auto-update _(likely N/A right now — confirm and note)_
**TC-UPD-01 — Update check**
+
1. Watch the console at startup for `[update]` lines.
+
- **Expected for this branch:** it's fine if updates **don't** work yet — the Windows update
feed isn't published. **Just record what you see** (e.g. `[update] check failed` or
- nothing). Don't file this as a bug unless the app *crashes* over it.
+ nothing). Don't file this as a bug unless the app _crashes_ over it.
---
## 5. Quick summary sheet (fill and return)
-| Suite | Result (✅/❌/⛔/⏭️) | Bug IDs filed |
-|---|---|---|
-| A — Install & launch | | |
-| B — Models | | |
-| C — Chat | | |
-| D — Gateway | | |
-| E — Image generation | | |
-| F — Voice | | |
-| G — Projects / RAG | | |
-| H — Artifacts | | |
-| I — Integrations (MCP) | | |
-| J — Tools in chat | | |
-| K — Settings & persistence | | |
-| L — Stability & cleanup | | |
-| M — Auto-update | | |
+| Suite | Result (✅/❌/⛔/⏭️) | Bug IDs filed |
+| -------------------------- | -------------------- | ------------- |
+| A — Install & launch | | |
+| B — Models | | |
+| C — Chat | | |
+| D — Gateway | | |
+| E — Image generation | | |
+| F — Voice | | |
+| G — Projects / RAG | | |
+| H — Artifacts | | |
+| I — Integrations (MCP) | | |
+| J — Tools in chat | | |
+| K — Settings & persistence | | |
+| L — Stability & cleanup | | |
+| M — Auto-update | | |
**Overall verdict:** Core app is ☐ usable / ☐ usable with issues / ☐ blocked on Windows.
diff --git a/docs/features/gateway.md b/docs/features/gateway.md
index d04a421e..7973907e 100644
--- a/docs/features/gateway.md
+++ b/docs/features/gateway.md
@@ -7,16 +7,16 @@ you've downloaded. Point any OpenAI SDK at `…/v1` with any (ignored) key.

-| Capability | Method · Endpoint | Notes |
-|---|---|---|
-| Chat (text) | `POST /v1/chat/completions` | streaming via `stream:true` |
-| Vision | `POST /v1/chat/completions` | `image_url` content parts (data URL or http) |
-| Text → Image | `POST /v1/images` · `/v1/images/generations` | `{prompt, aspect_ratio?, resolution?, seed?}` |
-| Image → Image | `POST /v1/images` · `/v1/images/edits` | `input_references:[{image_url:{url}}]` |
-| Speech → Text | `POST /v1/audio/transcriptions` | multipart `file` (whisper) |
-| Text → Speech | `POST /v1/audio/speech` | `{input, voice?}` → `audio/wav` (Kokoro) |
-| Embeddings | `POST /v1/embeddings` | local `all-MiniLM-L6-v2`, 384-dim |
-| Models | `GET /v1/models` | the active model per modality |
+| Capability | Method · Endpoint | Notes |
+| ------------- | -------------------------------------------- | --------------------------------------------- |
+| Chat (text) | `POST /v1/chat/completions` | streaming via `stream:true` |
+| Vision | `POST /v1/chat/completions` | `image_url` content parts (data URL or http) |
+| Text → Image | `POST /v1/images` · `/v1/images/generations` | `{prompt, aspect_ratio?, resolution?, seed?}` |
+| Image → Image | `POST /v1/images` · `/v1/images/edits` | `input_references:[{image_url:{url}}]` |
+| Speech → Text | `POST /v1/audio/transcriptions` | multipart `file` (whisper) |
+| Text → Speech | `POST /v1/audio/speech` | `{input, voice?}` → `audio/wav` (Kokoro) |
+| Embeddings | `POST /v1/embeddings` | local `all-MiniLM-L6-v2`, 384-dim |
+| Models | `GET /v1/models` | the active model per modality |
- **Interactive docs**: `GET /docs` (Scalar) · **spec**: `GET /openapi.json`.
- **Load-on-demand**: models load when a request needs them and offload after — long
diff --git a/e2e/chat-memory.spec.ts b/e2e/chat-memory.spec.ts
index e91afd45..b94e014d 100644
--- a/e2e/chat-memory.spec.ts
+++ b/e2e/chat-memory.spec.ts
@@ -6,173 +6,213 @@
* Runs against the built app with OFFGRID_PRO=1 so the memory dropdown is visible.
* No LLM model is expected — we only assert UI state and IPC plumbing, not model output.
*/
-import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
-import os from 'os';
-import path from 'path';
-import fs from 'fs';
-
-let app: ElectronApplication;
-let page: Page;
-let userDataDir: string;
+import {
+ test,
+ expect,
+ _electron as electron,
+ type ElectronApplication,
+ type Page
+} from '@playwright/test'
+import os from 'os'
+import path from 'path'
+import fs from 'fs'
+
+let app: ElectronApplication
+let page: Page
+let userDataDir: string
test.beforeAll(async () => {
- userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-chat-e2e-'));
+ userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-chat-e2e-'))
app = await electron.launch({
args: ['.'],
env: {
...process.env,
OFFGRID_USER_DATA: userDataDir,
OFFGRID_PRO: '1',
- NODE_ENV: 'production',
- },
- });
- page = await app.firstWindow();
- await page.waitForLoadState('domcontentloaded');
+ NODE_ENV: 'production'
+ }
+ })
+ page = await app.firstWindow()
+ await page.waitForLoadState('domcontentloaded')
// Skip onboarding so we land in the app shell.
for (let i = 0; i < 8; i++) {
- const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i });
- if (!(await btn.isVisible().catch(() => false))) break;
- await btn.click();
- await page.waitForTimeout(300);
+ const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i })
+ if (!(await btn.isVisible().catch(() => false))) break
+ await btn.click()
+ await page.waitForTimeout(300)
}
-});
+})
test.afterAll(async () => {
- await app?.close();
- try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
-});
+ await app?.close()
+ try {
+ fs.rmSync(userDataDir, { recursive: true, force: true })
+ } catch {
+ /* ignore */
+ }
+})
test('navigates to the chat screen', async () => {
// Find the chat/mind-share nav item and click it.
- const chatNav = page.getByRole('button', { name: /chat|mind|ask/i }).first();
+ const chatNav = page.getByRole('button', { name: /chat|mind|ask/i }).first()
if (await chatNav.isVisible().catch(() => false)) {
- await chatNav.click();
- await page.waitForTimeout(500);
+ await chatNav.click()
+ await page.waitForTimeout(500)
}
- await page.screenshot({ path: 'e2e/screenshots/chat-screen.png', fullPage: false });
-});
+ await page.screenshot({ path: 'e2e/screenshots/chat-screen.png', fullPage: false })
+})
test('memory toggle: No memory sticks after selection', async () => {
// Dismiss the "Set up your local AI" banner (and any other overlays) that block clicks.
- const dismissBtns = page.locator('button').filter({ hasText: '' }).filter({ has: page.locator('svg') });
- const banner = page.locator('div').filter({ hasText: /set up your local ai/i }).first();
+ const dismissBtns = page
+ .locator('button')
+ .filter({ hasText: '' })
+ .filter({ has: page.locator('svg') })
+ const banner = page
+ .locator('div')
+ .filter({ hasText: /set up your local ai/i })
+ .first()
if (await banner.isVisible().catch(() => false)) {
- const closeBtn = banner.locator('button').last();
- await closeBtn.click().catch(() => {});
- await page.waitForTimeout(300);
+ const closeBtn = banner.locator('button').last()
+ await closeBtn.click().catch(() => {})
+ await page.waitForTimeout(300)
}
- void dismissBtns; // suppress unused warning
- await page.keyboard.press('Escape');
- await page.waitForTimeout(200);
+ void dismissBtns // suppress unused warning
+ await page.keyboard.press('Escape')
+ await page.waitForTimeout(200)
// Open the memory/scope dropdown.
- const memoryBtn = page.locator('button').filter({ hasText: /memory|no memory/i }).first();
+ const memoryBtn = page
+ .locator('button')
+ .filter({ hasText: /memory|no memory/i })
+ .first()
if (!(await memoryBtn.isVisible().catch(() => false))) {
- test.skip(true, 'Memory toggle not visible — may be on a different screen');
- return;
+ test.skip(true, 'Memory toggle not visible — may be on a different screen')
+ return
}
- await memoryBtn.click({ force: true });
- await page.waitForTimeout(500);
- await page.screenshot({ path: 'e2e/screenshots/memory-dropdown-open.png' });
+ await memoryBtn.click({ force: true })
+ await page.waitForTimeout(500)
+ await page.screenshot({ path: 'e2e/screenshots/memory-dropdown-open.png' })
// Click "No memory" — Radix DropdownMenuItem renders as role="menuitem".
// Fall back to text match if the role selector doesn't resolve (portal timing).
- const noMemoryItem = page.getByRole('menuitem', { name: /no memory/i })
- .or(page.locator('[data-radix-dropdown-menu-content] *').filter({ hasText: /^No memory/i }));
- await expect(noMemoryItem.first()).toBeVisible({ timeout: 5000 });
- await noMemoryItem.first().click();
- await page.waitForTimeout(300);
+ const noMemoryItem = page
+ .getByRole('menuitem', { name: /no memory/i })
+ .or(page.locator('[data-radix-dropdown-menu-content] *').filter({ hasText: /^No memory/i }))
+ await expect(noMemoryItem.first()).toBeVisible({ timeout: 5000 })
+ await noMemoryItem.first().click()
+ await page.waitForTimeout(300)
- await page.screenshot({ path: 'e2e/screenshots/memory-no-memory-selected.png' });
+ await page.screenshot({ path: 'e2e/screenshots/memory-no-memory-selected.png' })
// The trigger button should now say "No memory", confirming the state stuck.
- const triggerAfter = page.locator('button').filter({ hasText: /no memory/i }).first();
- await expect(triggerAfter).toBeVisible();
-});
+ const triggerAfter = page
+ .locator('button')
+ .filter({ hasText: /no memory/i })
+ .first()
+ await expect(triggerAfter).toBeVisible()
+})
test('memory toggle: All memory sticks after selection', async () => {
// Open dropdown and switch to All memory to verify the round-trip.
- const memoryBtn = page.locator('button').filter({ hasText: /no memory|memory/i }).first();
+ const memoryBtn = page
+ .locator('button')
+ .filter({ hasText: /no memory|memory/i })
+ .first()
if (!(await memoryBtn.isVisible().catch(() => false))) {
- test.skip(true, 'Memory toggle not visible');
- return;
+ test.skip(true, 'Memory toggle not visible')
+ return
}
- await memoryBtn.click({ force: true });
- await page.waitForTimeout(200);
+ await memoryBtn.click({ force: true })
+ await page.waitForTimeout(200)
- const allMemoryItem = page.getByRole('menuitem', { name: /all memory/i });
+ const allMemoryItem = page.getByRole('menuitem', { name: /all memory/i })
if (!(await allMemoryItem.isVisible().catch(() => false))) {
- test.skip(true, 'All memory item not in dropdown (non-pro build?)');
- return;
+ test.skip(true, 'All memory item not in dropdown (non-pro build?)')
+ return
}
- await allMemoryItem.click();
- await page.waitForTimeout(300);
+ await allMemoryItem.click()
+ await page.waitForTimeout(300)
- await page.screenshot({ path: 'e2e/screenshots/memory-all-memory-selected.png' });
+ await page.screenshot({ path: 'e2e/screenshots/memory-all-memory-selected.png' })
// Button should now reflect "All memory".
- const triggerAfter = page.locator('button').filter({ hasText: /all memory/i }).first();
- await expect(triggerAfter).toBeVisible();
-});
+ const triggerAfter = page
+ .locator('button')
+ .filter({ hasText: /all memory/i })
+ .first()
+ await expect(triggerAfter).toBeVisible()
+})
test('chat composer renders and accepts input', async () => {
- const composer = page.getByPlaceholder(/ask anything/i);
+ const composer = page.getByPlaceholder(/ask anything/i)
if (!(await composer.isVisible().catch(() => false))) {
- test.skip(true, 'Chat composer not visible');
- return;
+ test.skip(true, 'Chat composer not visible')
+ return
}
- await composer.fill('Hello, test message');
- await page.screenshot({ path: 'e2e/screenshots/chat-composer-filled.png' });
+ await composer.fill('Hello, test message')
+ await page.screenshot({ path: 'e2e/screenshots/chat-composer-filled.png' })
// Verify the send button is present.
- const sendBtn = page.locator('button[type="submit"], button').filter({ has: page.locator('svg') }).last();
- await expect(sendBtn).toBeVisible();
+ const sendBtn = page
+ .locator('button[type="submit"], button')
+ .filter({ has: page.locator('svg') })
+ .last()
+ await expect(sendBtn).toBeVisible()
// Clear without sending — we don't have a model running.
- await composer.fill('');
-});
+ await composer.fill('')
+})
test('streaming placeholder appears immediately after send', async () => {
// This test captures the streaming UI state: the user bubble + the assistant
// placeholder bubble that appears instantly (before any model response).
// It validates the fix — previously nothing appeared until the full response
// resolved because stream tokens routed to the wrong conversation bucket.
- const composer = page.getByPlaceholder(/ask anything/i);
+ const composer = page.getByPlaceholder(/ask anything/i)
if (!(await composer.isVisible().catch(() => false))) {
- test.skip(true, 'Chat composer not visible');
- return;
+ test.skip(true, 'Chat composer not visible')
+ return
}
- await composer.fill('What did I work on today?');
- await page.keyboard.press('Enter');
+ await composer.fill('What did I work on today?')
+ await page.keyboard.press('Enter')
// The user bubble + assistant streaming placeholder should appear within ~500 ms
// regardless of whether a model is running — the placeholder is added synchronously
// before ragChat is even called. Screenshot right after send to capture it.
- await page.waitForTimeout(600);
- await page.screenshot({ path: 'e2e/screenshots/streaming-placeholder.png' });
+ await page.waitForTimeout(600)
+ await page.screenshot({ path: 'e2e/screenshots/streaming-placeholder.png' })
// Assert the user message rendered immediately.
- const userBubble = page.locator('text=What did I work on today?').first();
- await expect(userBubble).toBeVisible({ timeout: 3000 });
+ const userBubble = page.locator('text=What did I work on today?').first()
+ await expect(userBubble).toBeVisible({ timeout: 3000 })
// Assert exactly one assistant bubble appeared — the streaming placeholder
// must transition to the final state (error when no model), never duplicate into two.
- const assistantBubble = page.locator('div').filter({
- hasText: /searching|working|sorry|error|off grid/i,
- }).first();
- await expect(assistantBubble).toBeVisible({ timeout: 5000 }).catch(() => {
- // No model running is acceptable — the user bubble alone proves routing.
- });
+ const assistantBubble = page
+ .locator('div')
+ .filter({
+ hasText: /searching|working|sorry|error|off grid/i
+ })
+ .first()
+ await expect(assistantBubble)
+ .toBeVisible({ timeout: 5000 })
+ .catch(() => {
+ // No model running is acceptable — the user bubble alone proves routing.
+ })
// Confirm there is NOT a second stale streaming bubble (the animated dots)
// alongside the error — before the fix there would be two assistant bubbles.
// Target the innermost text nodes to avoid counting wrapper divs.
- const errorBubbleCount = await page.locator('p, span').filter({ hasText: /^Sorry, something went wrong/i }).count();
- expect(errorBubbleCount).toBeLessThanOrEqual(1);
-
- await page.screenshot({ path: 'e2e/screenshots/streaming-after-send.png' });
-});
+ const errorBubbleCount = await page
+ .locator('p, span')
+ .filter({ hasText: /^Sorry, something went wrong/i })
+ .count()
+ expect(errorBubbleCount).toBeLessThanOrEqual(1)
+
+ await page.screenshot({ path: 'e2e/screenshots/streaming-after-send.png' })
+})
diff --git a/e2e/pro.spec.ts b/e2e/pro.spec.ts
index 2da751b8..570646a4 100644
--- a/e2e/pro.spec.ts
+++ b/e2e/pro.spec.ts
@@ -17,27 +17,33 @@
* macOS only (the app is macOS; fix 2 uses NSPasteboard via osascript). Requires the pro
* package to be present — skipped in a core-only checkout, exactly as the build gates pro.
*/
-import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
-import os from 'os';
-import path from 'path';
-import fs from 'fs';
+import {
+ test,
+ expect,
+ _electron as electron,
+ type ElectronApplication,
+ type Page
+} from '@playwright/test'
+import os from 'os'
+import path from 'path'
+import fs from 'fs'
// Mirrors electron.vite.config's proExists gate: without the pro package, OFFGRID_PRO=1
// can't activate pro features, so these surfaces would render the free upgrade screen.
-const PRO_PRESENT = fs.existsSync(path.resolve('pro/package.json'));
+const PRO_PRESENT = fs.existsSync(path.resolve('pro/package.json'))
-let app: ElectronApplication;
-let page: Page;
-let userDataDir: string;
+let app: ElectronApplication
+let page: Page
+let userDataDir: string
const nav = async (label: string): Promise => {
- await page.getByRole('button', { name: label, exact: true }).first().click();
- await page.waitForTimeout(500);
-};
+ await page.getByRole('button', { name: label, exact: true }).first().click()
+ await page.waitForTimeout(500)
+}
test.beforeAll(async () => {
- test.skip(!PRO_PRESENT, 'pro package not present — pro features cannot activate');
- userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-pro-'));
+ test.skip(!PRO_PRESENT, 'pro package not present — pro features cannot activate')
+ userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-pro-'))
app = await electron.launch({
args: ['.'],
env: {
@@ -45,178 +51,216 @@ test.beforeAll(async () => {
OFFGRID_USER_DATA: userDataDir,
OFFGRID_PRO: '1', // force pro on without a license (pro code is bundled in this checkout)
OFFGRID_SEED_PRO: 'force', // deterministic observations + entities + replay frames (TEMP profile only)
- NODE_ENV: 'production',
- },
- });
- page = await app.firstWindow();
- await page.waitForLoadState('domcontentloaded');
+ NODE_ENV: 'production'
+ }
+ })
+ page = await app.firstWindow()
+ await page.waitForLoadState('domcontentloaded')
// Click through onboarding into the app shell.
for (let i = 0; i < 8; i++) {
- const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i });
- if (!(await btn.isVisible().catch(() => false))) break;
- await btn.click();
- await page.waitForTimeout(400);
+ const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i })
+ if (!(await btn.isVisible().catch(() => false))) break
+ await btn.click()
+ await page.waitForTimeout(400)
+ }
+ try {
+ await page.getByRole('button', { name: 'Expand sidebar' }).click({ timeout: 4000 })
+ } catch {
+ /* already open */
}
- try { await page.getByRole('button', { name: 'Expand sidebar' }).click({ timeout: 4000 }); } catch { /* already open */ }
- await page.waitForTimeout(500);
+ await page.waitForTimeout(500)
// Seeding runs async on the main process after IPC setup — give it a beat to land.
- await page.waitForTimeout(1500);
-});
+ await page.waitForTimeout(1500)
+})
test.afterAll(async () => {
- await app?.close();
- try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
-});
+ await app?.close()
+ try {
+ fs.rmSync(userDataDir, { recursive: true, force: true })
+ } catch {
+ /* ignore */
+ }
+})
test('Replay is unlocked in the pro build (renders the manager, not the upgrade screen)', async () => {
- await nav('Replay');
- await expect(page.getByText('Off Grid Pro · Available now')).toHaveCount(0);
+ await nav('Replay')
+ await expect(page.getByText('Off Grid Pro · Available now')).toHaveCount(0)
// The seeded day has frames, so the film + scrubber render.
- await expect(page.getByText(/frames?$/).first()).toBeVisible();
-});
+ await expect(page.getByText(/frames?$/).first()).toBeVisible()
+})
test('Replay moments are backed by a captured screen — connector-only moments are dropped', async () => {
// Exercise the live crm:replay-* IPC exactly as the screen does (same day window).
const result = await page.evaluate(async () => {
- const api = (window as unknown as { api: Record Promise> }).api;
- const sec = (await api.crmReplayDefaultDay()) as number;
- const d = new Date(sec * 1000);
- const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
- const s = Math.floor(start / 1000);
- const e = Math.floor((start + 86400000) / 1000);
- const frames = (await api.crmReplayFrames(s, e)) as { ts: number }[];
- const threads = (await api.crmReplayThreads(s, e)) as { entityId?: number }[];
- const seg = threads.find((t) => typeof t.entityId === 'number');
- if (!seg || seg.entityId == null) return { ok: false, reason: 'no entity thread', frames: frames.length };
- const day = (await api.crmReplayEntityDay(seg.entityId, s, e)) as { scenes: { ts: number; surface: string | null }[] };
- return { ok: true, entityId: seg.entityId, frameTs: frames.map((f) => f.ts), scenes: day.scenes };
- });
-
- expect(result.ok, `setup: ${(result as { reason?: string }).reason ?? ''}`).toBe(true);
- const r = result as { frameTs: number[]; scenes: { ts: number; surface: string | null }[] };
+ const api = (
+ window as unknown as { api: Record Promise> }
+ ).api
+ const sec = (await api.crmReplayDefaultDay()) as number
+ const d = new Date(sec * 1000)
+ const start = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
+ const s = Math.floor(start / 1000)
+ const e = Math.floor((start + 86400000) / 1000)
+ const frames = (await api.crmReplayFrames(s, e)) as { ts: number }[]
+ const threads = (await api.crmReplayThreads(s, e)) as { entityId?: number }[]
+ const seg = threads.find((t) => typeof t.entityId === 'number')
+ if (!seg || seg.entityId == null)
+ return { ok: false, reason: 'no entity thread', frames: frames.length }
+ const day = (await api.crmReplayEntityDay(seg.entityId, s, e)) as {
+ scenes: { ts: number; surface: string | null }[]
+ }
+ return {
+ ok: true,
+ entityId: seg.entityId,
+ frameTs: frames.map((f) => f.ts),
+ scenes: day.scenes
+ }
+ })
+
+ expect(result.ok, `setup: ${(result as { reason?: string }).reason ?? ''}`).toBe(true)
+ const r = result as { frameTs: number[]; scenes: { ts: number; surface: string | null }[] }
// The entity (drawn from a work thread) has on-screen activity this day.
- expect(r.scenes.length).toBeGreaterThan(0);
+ expect(r.scenes.length).toBeGreaterThan(0)
// The invariant the fix enforces: every moment shown lines up with a captured
// screen frame. A connector-only observation (no screenshot) would have a ts with
// no matching frame — before the fix it leaked through here.
- const frameTs = new Set(r.frameTs);
+ const frameTs = new Set(r.frameTs)
for (const sc of r.scenes) {
- const hasFrame = r.frameTs.some((t) => Math.abs(t - sc.ts) <= 5) || frameTs.has(sc.ts);
- expect(hasFrame, `moment at ${sc.ts} (${sc.surface}) has no captured screen`).toBe(true);
+ const hasFrame = r.frameTs.some((t) => Math.abs(t - sc.ts) <= 5) || frameTs.has(sc.ts)
+ expect(hasFrame, `moment at ${sc.ts} (${sc.surface}) has no captured screen`).toBe(true)
}
-});
+})
test('Clipboard is unlocked in the pro build', async () => {
- await nav('Clipboard');
- await expect(page.getByText('Off Grid Pro · Available now')).toHaveCount(0);
- await expect(page.getByPlaceholder('Search content or tags…')).toBeVisible();
-});
+ await nav('Clipboard')
+ await expect(page.getByText('Off Grid Pro · Available now')).toHaveCount(0)
+ await expect(page.getByPlaceholder('Search content or tags…')).toBeVisible()
+})
test('Voice is unlocked in the pro build (renders the dictation library)', async () => {
- await nav('Voice');
- await expect(page.getByText('Off Grid Pro · Available now')).toHaveCount(0);
+ await nav('Voice')
+ await expect(page.getByText('Off Grid Pro · Available now')).toHaveCount(0)
// The real screen: a search box, the dictation CTA, and the file-transcribe entry.
- await expect(page.getByPlaceholder('Search transcripts')).toBeVisible();
- await expect(page.getByRole('button', { name: 'Start dictation' })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Transcribe file' })).toBeVisible();
-});
+ await expect(page.getByPlaceholder('Search transcripts')).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Start dictation' })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Transcribe file' })).toBeVisible()
+})
test('Restoring a copied file puts BOTH the path text and the file-url on the clipboard', async () => {
// 1. A real file on disk (written from the test process — Playwright's evaluate
// sandbox has no `require`), then simulate a Finder "copy file" by putting its
// file-url on the OS clipboard. The 500ms poller captures it into history.
- const { pathToFileURL } = await import('url');
- const fp = path.join(userDataDir, 'e2e-clip-sample.txt');
- fs.writeFileSync(fp, 'off-grid clipboard e2e payload');
- const copied = { fp, basename: path.basename(fp), fileUrl: pathToFileURL(fp).href };
+ const { pathToFileURL } = await import('url')
+ const fp = path.join(userDataDir, 'e2e-clip-sample.txt')
+ fs.writeFileSync(fp, 'off-grid clipboard e2e payload')
+ const copied = { fp, basename: path.basename(fp), fileUrl: pathToFileURL(fp).href }
await app.evaluate(async ({ clipboard }, fileUrl) => {
- clipboard.clear();
- clipboard.writeBuffer('public.file-url', Buffer.from(fileUrl, 'utf8'));
- }, copied.fileUrl);
+ clipboard.clear()
+ clipboard.writeBuffer('public.file-url', Buffer.from(fileUrl, 'utf8'))
+ }, copied.fileUrl)
// 2. Wait for capture, then restore that item via the real IPC.
const restoredId = await page.evaluate(async (basename) => {
- const api = (window as unknown as { api: { proInvoke: (c: string, ...a: unknown[]) => Promise } }).api;
- const deadline = Date.now() + 12000;
+ const api = (
+ window as unknown as { api: { proInvoke: (c: string, ...a: unknown[]) => Promise } }
+ ).api
+ const deadline = Date.now() + 12000
while (Date.now() < deadline) {
- const items = (await api.proInvoke('clipboard:list', 50)) as { id: string; contentType: string; textContent: string | null }[];
- const hit = items.find((it) => it.contentType === 'file' && it.textContent === basename);
- if (hit) { await api.proInvoke('clipboard:restore', hit.id); return hit.id; }
- await new Promise((res) => setTimeout(res, 300));
+ const items = (await api.proInvoke('clipboard:list', 50)) as {
+ id: string
+ contentType: string
+ textContent: string | null
+ }[]
+ const hit = items.find((it) => it.contentType === 'file' && it.textContent === basename)
+ if (hit) {
+ await api.proInvoke('clipboard:restore', hit.id)
+ return hit.id
+ }
+ await new Promise((res) => setTimeout(res, 300))
}
- return null;
- }, copied.basename);
+ return null
+ }, copied.basename)
- expect(restoredId, 'file clip was captured and restored').not.toBeNull();
+ expect(restoredId, 'file clip was captured and restored').not.toBeNull()
// 3. The multi-flavor write runs through osascript (async); poll the pasteboard.
const pb = await app.evaluate(async ({ clipboard }) => {
- const deadline = Date.now() + 6000;
- let formats: string[] = [];
- let text = '';
+ const deadline = Date.now() + 6000
+ let formats: string[] = []
+ let text = ''
while (Date.now() < deadline) {
- formats = clipboard.availableFormats();
- text = clipboard.readText();
- if (formats.includes('text/plain') && formats.includes('text/uri-list')) break;
- await new Promise((res) => setTimeout(res, 200));
+ formats = clipboard.availableFormats()
+ text = clipboard.readText()
+ if (formats.includes('text/plain') && formats.includes('text/uri-list')) break
+ await new Promise((res) => setTimeout(res, 200))
}
- return { formats, text };
- });
+ return { formats, text }
+ })
// Finder flavor (file-url) AND terminal flavor (plain-text path) both present.
- expect(pb.formats).toContain('text/uri-list');
- expect(pb.formats).toContain('text/plain');
+ expect(pb.formats).toContain('text/uri-list')
+ expect(pb.formats).toContain('text/plain')
// The plain text is the file's path (so a terminal paste yields the path).
- expect(pb.text).toContain(copied.basename);
-});
+ expect(pb.text).toContain(copied.basename)
+})
test('Restoring a copied IMAGE file still gives the terminal a path (plus pixels)', async () => {
// An image file is still a file: pasting it into a terminal must yield the path, not
// nothing. Regression for the image-only branch that wrote pixels and no text.
- const { pathToFileURL } = await import('url');
+ const { pathToFileURL } = await import('url')
// A real 48x48 PNG (a 1x1 is too small for readImage to register as a bitmap).
- const sharp = (await import('sharp')).default;
- const png = await sharp({ create: { width: 48, height: 48, channels: 3, background: { r: 200, g: 30, b: 90 } } })
+ const sharp = (await import('sharp')).default
+ const png = await sharp({
+ create: { width: 48, height: 48, channels: 3, background: { r: 200, g: 30, b: 90 } }
+ })
.png()
- .toBuffer();
- const fp = path.join(userDataDir, 'e2e-clip-image.png');
- fs.writeFileSync(fp, png);
- const copied = { basename: path.basename(fp), fileUrl: pathToFileURL(fp).href };
+ .toBuffer()
+ const fp = path.join(userDataDir, 'e2e-clip-image.png')
+ fs.writeFileSync(fp, png)
+ const copied = { basename: path.basename(fp), fileUrl: pathToFileURL(fp).href }
await app.evaluate(async ({ clipboard }, fileUrl) => {
- clipboard.clear();
- clipboard.writeBuffer('public.file-url', Buffer.from(fileUrl, 'utf8'));
- }, copied.fileUrl);
+ clipboard.clear()
+ clipboard.writeBuffer('public.file-url', Buffer.from(fileUrl, 'utf8'))
+ }, copied.fileUrl)
const restoredId = await page.evaluate(async (basename) => {
- const api = (window as unknown as { api: { proInvoke: (c: string, ...a: unknown[]) => Promise } }).api;
- const deadline = Date.now() + 12000;
+ const api = (
+ window as unknown as { api: { proInvoke: (c: string, ...a: unknown[]) => Promise } }
+ ).api
+ const deadline = Date.now() + 12000
while (Date.now() < deadline) {
- const items = (await api.proInvoke('clipboard:list', 50)) as { id: string; contentType: string; textContent: string | null }[];
- const hit = items.find((it) => it.contentType === 'file' && it.textContent === basename);
- if (hit) { await api.proInvoke('clipboard:restore', hit.id); return hit.id; }
- await new Promise((res) => setTimeout(res, 300));
+ const items = (await api.proInvoke('clipboard:list', 50)) as {
+ id: string
+ contentType: string
+ textContent: string | null
+ }[]
+ const hit = items.find((it) => it.contentType === 'file' && it.textContent === basename)
+ if (hit) {
+ await api.proInvoke('clipboard:restore', hit.id)
+ return hit.id
+ }
+ await new Promise((res) => setTimeout(res, 300))
}
- return null;
- }, copied.basename);
- expect(restoredId, 'image clip was captured and restored').not.toBeNull();
+ return null
+ }, copied.basename)
+ expect(restoredId, 'image clip was captured and restored').not.toBeNull()
const pb = await app.evaluate(async ({ clipboard }) => {
- const deadline = Date.now() + 6000;
- let formats: string[] = [];
- let text = '';
- let imageEmpty = true;
+ const deadline = Date.now() + 6000
+ let formats: string[] = []
+ let text = ''
+ let imageEmpty = true
while (Date.now() < deadline) {
- formats = clipboard.availableFormats();
- text = clipboard.readText();
- imageEmpty = clipboard.readImage().isEmpty();
- if (formats.includes('text/plain') && !imageEmpty) break;
- await new Promise((res) => setTimeout(res, 200));
+ formats = clipboard.availableFormats()
+ text = clipboard.readText()
+ imageEmpty = clipboard.readImage().isEmpty()
+ if (formats.includes('text/plain') && !imageEmpty) break
+ await new Promise((res) => setTimeout(res, 200))
}
- return { formats, text, imageEmpty };
- });
+ return { formats, text, imageEmpty }
+ })
// Terminal path AND the file-url AND the image pixels — all from one copied image.
- expect(pb.formats).toContain('text/plain');
- expect(pb.text).toContain(copied.basename);
- expect(pb.imageEmpty, 'image pixels still on the clipboard for image editors').toBe(false);
-});
+ expect(pb.formats).toContain('text/plain')
+ expect(pb.text).toContain(copied.basename)
+ expect(pb.imageEmpty, 'image pixels still on the clipboard for image editors').toBe(false)
+})
diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts
index 3b30ed2d..53c1551c 100644
--- a/e2e/smoke.spec.ts
+++ b/e2e/smoke.spec.ts
@@ -10,86 +10,102 @@
*
* Requires a build first: `npm run build` (the test:e2e script does this).
*/
-import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
-import os from 'os';
-import path from 'path';
-import fs from 'fs';
+import {
+ test,
+ expect,
+ _electron as electron,
+ type ElectronApplication,
+ type Page
+} from '@playwright/test'
+import os from 'os'
+import path from 'path'
+import fs from 'fs'
-let app: ElectronApplication;
-let page: Page;
-let userDataDir: string;
+let app: ElectronApplication
+let page: Page
+let userDataDir: string
test.beforeAll(async () => {
- userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-e2e-'));
+ userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-e2e-'))
app = await electron.launch({
args: ['.'],
env: {
...process.env,
OFFGRID_USER_DATA: userDataDir, // pristine first-run
OFFGRID_PRO: '0', // deterministic free tier (no permission gate)
- NODE_ENV: 'production',
- },
- });
- page = await app.firstWindow();
- await page.waitForLoadState('domcontentloaded');
-});
+ NODE_ENV: 'production'
+ }
+ })
+ page = await app.firstWindow()
+ await page.waitForLoadState('domcontentloaded')
+})
test.afterAll(async () => {
- await app?.close();
- try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
-});
+ await app?.close()
+ try {
+ fs.rmSync(userDataDir, { recursive: true, force: true })
+ } catch {
+ /* ignore */
+ }
+})
test('boots fresh without a white screen and exposes the preload bridge', async () => {
// Renderer mounted with real content (not a blank/crashed page).
- await expect(page.locator('#root')).not.toBeEmpty();
+ await expect(page.locator('#root')).not.toBeEmpty()
// Preload contextBridge is wired.
- const hasApi = await page.evaluate(() => typeof (window as { api?: unknown }).api === 'object');
- expect(hasApi).toBe(true);
-});
+ const hasApi = await page.evaluate(() => typeof (window as { api?: unknown }).api === 'object')
+ expect(hasApi).toBe(true)
+})
test('shows onboarding on a fresh install', async () => {
- await expect(page.getByText(/Off Grid/i).first()).toBeVisible();
- await expect(page.getByRole('button', { name: /Continue|Start using Off Grid/i })).toBeVisible();
-});
+ await expect(page.getByText(/Off Grid/i).first()).toBeVisible()
+ await expect(page.getByRole('button', { name: /Continue|Start using Off Grid/i })).toBeVisible()
+})
test('onboarding surfaces the Pro capability grid', async () => {
// Advance until the Pro step renders its capability cards, then assert a few
// capabilities are shown by name (Replay, Meetings, Vault). Regression guard
// for the onboarding redesign that showcases the Pro layer.
- const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i });
+ const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i })
for (let i = 0; i < 6; i++) {
- if (await page.getByText('Meetings').isVisible().catch(() => false)) break;
- if (!(await btn.isVisible().catch(() => false))) break;
- await btn.click();
- await page.waitForTimeout(400);
+ if (
+ await page
+ .getByText('Meetings')
+ .isVisible()
+ .catch(() => false)
+ )
+ break
+ if (!(await btn.isVisible().catch(() => false))) break
+ await btn.click()
+ await page.waitForTimeout(400)
}
- await expect(page.getByText('Replay')).toBeVisible();
- await expect(page.getByText('Meetings')).toBeVisible();
- await expect(page.getByText('Vault')).toBeVisible();
- await page.screenshot({ path: 'e2e/screenshots/onboarding-pro-grid.png' });
-});
+ await expect(page.getByText('Replay')).toBeVisible()
+ await expect(page.getByText('Meetings')).toBeVisible()
+ await expect(page.getByText('Vault')).toBeVisible()
+ await page.screenshot({ path: 'e2e/screenshots/onboarding-pro-grid.png' })
+})
test('completes onboarding and lands in the app shell', async () => {
// Click through every onboarding step (Continue × N, then "Start using Off Grid").
for (let i = 0; i < 6; i++) {
- const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i });
- if (!(await btn.isVisible().catch(() => false))) break;
- await btn.click();
- await page.waitForTimeout(400);
+ const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i })
+ if (!(await btn.isVisible().catch(() => false))) break
+ await btn.click()
+ await page.waitForTimeout(400)
}
// Free tier defaults to the Models screen — assert the app shell rendered.
- await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible();
-});
+ await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible()
+})
test('system:health IPC returns the component list', async () => {
const health = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- return (window as any).api?.systemHealth?.();
- });
- expect(health).toBeTruthy();
- expect(Array.isArray(health.components)).toBe(true);
+ return (window as any).api?.systemHealth?.()
+ })
+ expect(health).toBeTruthy()
+ expect(Array.isArray(health.components)).toBe(true)
// The chat + gateway components are always reported.
- const ids = health.components.map((c: { id: string }) => c.id);
- expect(ids).toContain('chat');
- expect(ids).toContain('gateway');
-});
+ const ids = health.components.map((c: { id: string }) => c.id)
+ expect(ids).toContain('chat')
+ expect(ids).toContain('gateway')
+})
diff --git a/e2e/tour.spec.ts b/e2e/tour.spec.ts
index e72de525..53ddb534 100644
--- a/e2e/tour.spec.ts
+++ b/e2e/tour.spec.ts
@@ -5,102 +5,124 @@
* real one) and only navigates / reads — no destructive clicks — so it's safe.
* OFFGRID_PRO=0 forces deterministic free-tier UI.
*/
-import { test, expect, _electron as electron, type ElectronApplication, type Page } from '@playwright/test';
-import os from 'os';
-import path from 'path';
-import fs from 'fs';
+import {
+ test,
+ expect,
+ _electron as electron,
+ type ElectronApplication,
+ type Page
+} from '@playwright/test'
+import os from 'os'
+import path from 'path'
+import fs from 'fs'
-let app: ElectronApplication;
-let page: Page;
-let userDataDir: string;
+let app: ElectronApplication
+let page: Page
+let userDataDir: string
const nav = async (label: string): Promise => {
- await page.getByRole('button', { name: label, exact: true }).first().click();
- await page.waitForTimeout(500);
-};
+ await page.getByRole('button', { name: label, exact: true }).first().click()
+ await page.waitForTimeout(500)
+}
test.beforeAll(async () => {
- userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-tour-'));
+ userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'offgrid-tour-'))
app = await electron.launch({
args: ['.'],
- env: { ...process.env, OFFGRID_USER_DATA: userDataDir, OFFGRID_PRO: '0', NODE_ENV: 'production' },
- });
- page = await app.firstWindow();
- await page.waitForLoadState('domcontentloaded');
+ env: {
+ ...process.env,
+ OFFGRID_USER_DATA: userDataDir,
+ OFFGRID_PRO: '0',
+ NODE_ENV: 'production'
+ }
+ })
+ page = await app.firstWindow()
+ await page.waitForLoadState('domcontentloaded')
// Click through onboarding into the app shell.
for (let i = 0; i < 6; i++) {
- const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i });
- if (!(await btn.isVisible().catch(() => false))) break;
- await btn.click();
- await page.waitForTimeout(400);
+ const btn = page.getByRole('button', { name: /Continue|Start using Off Grid/i })
+ if (!(await btn.isVisible().catch(() => false))) break
+ await btn.click()
+ await page.waitForTimeout(400)
}
// Expand the sidebar so nav items have visible labels.
- try { await page.getByRole('button', { name: 'Expand sidebar' }).click({ timeout: 4000 }); } catch { /* already open */ }
- await page.waitForTimeout(500);
-});
+ try {
+ await page.getByRole('button', { name: 'Expand sidebar' }).click({ timeout: 4000 })
+ } catch {
+ /* already open */
+ }
+ await page.waitForTimeout(500)
+})
test.afterAll(async () => {
- await app?.close();
- try { fs.rmSync(userDataDir, { recursive: true, force: true }); } catch { /* ignore */ }
-});
+ await app?.close()
+ try {
+ fs.rmSync(userDataDir, { recursive: true, force: true })
+ } catch {
+ /* ignore */
+ }
+})
test('Models: merged tab + use-cases + import', async () => {
- await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible();
- await expect(page.getByRole('button', { name: /Import \.gguf/i })).toBeVisible();
- await expect(page.getByRole('button', { name: 'Coding', exact: true })).toBeVisible(); // use-case chip
-});
+ await expect(page.getByRole('heading', { name: 'Models' })).toBeVisible()
+ await expect(page.getByRole('button', { name: /Import \.gguf/i })).toBeVisible()
+ await expect(page.getByRole('button', { name: 'Coding', exact: true })).toBeVisible() // use-case chip
+})
test('Settings: setup, resource modes, storage, data & privacy all render', async () => {
- await nav('Settings');
+ await nav('Settings')
// Sections are collapsed accordions — the titles (headings) are always visible;
// expand a card to reveal its body before asserting the body content.
- await expect(page.getByRole('heading', { name: 'Setup & health' })).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Data & privacy' })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Setup & health' })).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Data & privacy' })).toBeVisible()
- await page.getByRole('button', { name: /Setup & health/ }).click(); // expand
- await expect(page.getByText('Configure it for me')).toBeVisible();
+ await page.getByRole('button', { name: /Setup & health/ }).click() // expand
+ await expect(page.getByText('Configure it for me')).toBeVisible()
// The resource-mode selector now lives inside the Configure card.
for (const m of ['Conservative', 'Balanced', 'Extreme']) {
// The mode button's accessible name includes its description, so match by substring.
- await expect(page.getByRole('button', { name: m }).first()).toBeVisible();
+ await expect(page.getByRole('button', { name: m }).first()).toBeVisible()
}
- await page.getByRole('button', { name: /Data & privacy/ }).click(); // expand
- await expect(page.getByText('Screen captures')).toBeVisible();
- await expect(page.getByText('Your data on this device')).toBeVisible();
-});
+ await page.getByRole('button', { name: /Data & privacy/ }).click() // expand
+ await expect(page.getByText('Screen captures')).toBeVisible()
+ await expect(page.getByText('Your data on this device')).toBeVisible()
+})
test('Resource mode is selectable (Conservative)', async () => {
// Ensure the Setup & health accordion is expanded (the modes live in its body).
- const cons = page.getByRole('button', { name: 'Conservative' }).first();
+ const cons = page.getByRole('button', { name: 'Conservative' }).first()
if (!(await cons.isVisible().catch(() => false))) {
- await page.getByRole('button', { name: /Setup & health/ }).click();
+ await page.getByRole('button', { name: /Setup & health/ }).click()
}
- await cons.click();
- await expect(cons).toHaveAttribute('aria-pressed', 'true');
-});
+ await cons.click()
+ await expect(cons).toHaveAttribute('aria-pressed', 'true')
+})
test('Clipboard is a Pro tab in the core build (shows upgrade)', async () => {
// Clipboard moved to Pro: in the core/free tour (OFFGRID_PRO=0) the tab is
// locked and renders the upgrade screen, not the clipboard manager/settings.
// (Locked items carry a "Pro" lock label, so match by prefix, not exact.)
- await page.getByRole('button', { name: /Clipboard/ }).first().click();
- await page.waitForTimeout(500);
- await expect(page.getByText('Off Grid Pro · Available now')).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Clipboard' })).toBeVisible();
-});
+ await page
+ .getByRole('button', { name: /Clipboard/ })
+ .first()
+ .click()
+ await page.waitForTimeout(500)
+ await expect(page.getByText('Off Grid Pro · Available now')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Clipboard' })).toBeVisible()
+})
test('Voice is a Pro tab in the core build (shows upgrade)', async () => {
// Voice/dictation is Pro: in the free tour (OFFGRID_PRO=0) the tab is locked and
// renders the upgrade screen, not the dictation library.
- await page.getByRole('button', { name: /Voice/ }).first().click();
- await page.waitForTimeout(500);
- await expect(page.getByText('Off Grid Pro · Available now')).toBeVisible();
- await expect(page.getByRole('heading', { name: 'Voice' })).toBeVisible();
-});
+ await page.getByRole('button', { name: /Voice/ }).first().click()
+ await page.waitForTimeout(500)
+ await expect(page.getByText('Off Grid Pro · Available now')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Voice' })).toBeVisible()
+})
test('Gateway screen renders', async () => {
- await nav('Settings'); // leave the upgrade screen first
- await nav('Gateway');
- await expect(page.getByText(/OpenAI-compatible/i).first()).toBeVisible();
-});
+ await nav('Settings') // leave the upgrade screen first
+ await nav('Gateway')
+ await expect(page.getByText(/OpenAI-compatible/i).first()).toBeVisible()
+})
diff --git a/electron-builder.yml b/electron-builder.yml
index 7c0b5e49..eb2c420c 100644
--- a/electron-builder.yml
+++ b/electron-builder.yml
@@ -25,8 +25,8 @@ extraResources:
- from: resources
to: .
filter:
- - "**/*"
- - "!models/**"
+ - '**/*'
+ - '!models/**'
win:
executableName: off-grid-ai
nsis:
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 56d38e2e..e7e863aa 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -50,7 +50,9 @@ const sonarProWarn = {
files: ['pro/**/*.{ts,tsx}'],
ignores: ['pro/**/*.{test,spec}.{ts,tsx}', 'pro/**/__tests__/**'],
rules: {
- ...Object.fromEntries(Object.keys(sonarjs.configs.recommended.rules ?? {}).map((rule) => [rule, 'warn'])),
+ ...Object.fromEntries(
+ Object.keys(sonarjs.configs.recommended.rules ?? {}).map((rule) => [rule, 'warn'])
+ ),
// Pure style — not a defect:
'sonarjs/arrow-function-convention': 'off',
'sonarjs/file-header': 'off',
diff --git a/knip.json b/knip.json
index dc922e87..b2034f3f 100644
--- a/knip.json
+++ b/knip.json
@@ -17,10 +17,17 @@
"ignore": ["**/*.d.ts", "component-library-animations/**"],
"tags": ["-public"],
"ignoreDependencies": [
- "tailwindcss", "@tailwindcss/vite", "postcss", "autoprefixer",
- "@vitejs/plugin-react", "@electron-toolkit/preload", "@offgrid/design",
- "better-sqlite3", "@types/better-sqlite3",
- "kokoro-js", "ollama"
+ "tailwindcss",
+ "@tailwindcss/vite",
+ "postcss",
+ "autoprefixer",
+ "@vitejs/plugin-react",
+ "@electron-toolkit/preload",
+ "@offgrid/design",
+ "better-sqlite3",
+ "@types/better-sqlite3",
+ "kokoro-js",
+ "ollama"
],
"ignoreBinaries": ["swift", "osascript", "pkill", "xattr", "lsof", "ps", "playwright"]
}
diff --git a/packages/clipboard/src/adapters/electron.ts b/packages/clipboard/src/adapters/electron.ts
index 94fed68b..1c99637d 100644
--- a/packages/clipboard/src/adapters/electron.ts
+++ b/packages/clipboard/src/adapters/electron.ts
@@ -3,36 +3,36 @@
// ClipboardBridge interface. Electron is injected by the host so this package
// never imports 'electron' directly and stays installable on mobile.
-import * as fs from 'fs';
-import * as path from 'path';
-import { execSync } from 'child_process';
-import type { ClipboardBridge, ClipboardItem, ClipboardRead, ContentType } from '../types';
+import * as fs from 'fs'
+import * as path from 'path'
+import { execSync } from 'child_process'
+import type { ClipboardBridge, ClipboardItem, ClipboardRead, ContentType } from '../types'
/** Minimal shape of Electron's nativeImage instances we use. */
interface ElectronImage {
- isEmpty(): boolean;
- toPNG(): Buffer;
+ isEmpty(): boolean
+ toPNG(): Buffer
}
/** Minimal shape of Electron's clipboard module we use. */
export interface ElectronClipboard {
- availableFormats(): string[];
- readImage(): ElectronImage;
- readRTF(): string;
- readText(): string;
- readBuffer(format: string): Buffer;
- read(format: string): string;
- writeText(text: string): void;
- writeRTF(text: string): void;
- writeImage(image: ElectronImage): void;
+ availableFormats(): string[]
+ readImage(): ElectronImage
+ readRTF(): string
+ readText(): string
+ readBuffer(format: string): Buffer
+ read(format: string): string
+ writeText(text: string): void
+ writeRTF(text: string): void
+ writeImage(image: ElectronImage): void
}
/** Minimal shape of Electron's nativeImage module we use. */
export interface ElectronNativeImage {
- createFromBuffer(buffer: Buffer): ElectronImage;
+ createFromBuffer(buffer: Buffer): ElectronImage
}
-const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
+const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
export class ElectronClipboardBridge implements ClipboardBridge {
constructor(
@@ -41,29 +41,29 @@ export class ElectronClipboardBridge implements ClipboardBridge {
) {}
read(): ClipboardRead | null {
- const formats = this.clipboard.availableFormats();
- if (formats.length === 0) return null;
- const extracted = this.extract(formats);
- if (!extracted.rawData || extracted.rawData.length === 0) return null;
- return extracted;
+ const formats = this.clipboard.availableFormats()
+ if (formats.length === 0) return null
+ const extracted = this.extract(formats)
+ if (!extracted.rawData || extracted.rawData.length === 0) return null
+ return extracted
}
write(item: ClipboardItem): void {
switch (item.contentType) {
case 'image': {
- const img = this.nativeImage.createFromBuffer(Buffer.from(item.rawData));
- this.clipboard.writeImage(img);
- return;
+ const img = this.nativeImage.createFromBuffer(Buffer.from(item.rawData))
+ this.clipboard.writeImage(img)
+ return
}
case 'rtf': {
- const rtf = Buffer.from(item.rawData).toString('utf-8');
- this.clipboard.writeRTF(rtf);
- if (item.textContent) this.clipboard.writeText(item.textContent);
- return;
+ const rtf = Buffer.from(item.rawData).toString('utf-8')
+ this.clipboard.writeRTF(rtf)
+ if (item.textContent) this.clipboard.writeText(item.textContent)
+ return
}
default: {
- const text = item.textContent ?? Buffer.from(item.rawData).toString('utf-8');
- this.clipboard.writeText(text);
+ const text = item.textContent ?? Buffer.from(item.rawData).toString('utf-8')
+ this.clipboard.writeText(text)
}
}
}
@@ -71,34 +71,34 @@ export class ElectronClipboardBridge implements ClipboardBridge {
private extract(formats: string[]): ClipboardRead {
// Image first.
if (formats.some((f) => f.includes('image'))) {
- const image = this.clipboard.readImage();
+ const image = this.clipboard.readImage()
if (!image.isEmpty()) {
- return { contentType: 'image', rawData: image.toPNG(), textContent: null };
+ return { contentType: 'image', rawData: image.toPNG(), textContent: null }
}
}
// RTF.
if (formats.includes('text/rtf')) {
- const rtf = this.clipboard.readRTF();
- const text = this.clipboard.readText();
+ const rtf = this.clipboard.readRTF()
+ const text = this.clipboard.readText()
if (rtf) {
- return { contentType: 'rtf', rawData: Buffer.from(rtf, 'utf-8'), textContent: text || null };
+ return { contentType: 'rtf', rawData: Buffer.from(rtf, 'utf-8'), textContent: text || null }
}
}
// File paths (macOS Finder).
if (formats.includes('public.file-url') || formats.includes('text/uri-list')) {
- const fileRead = this.extractFile();
- if (fileRead) return fileRead;
+ const fileRead = this.extractFile()
+ if (fileRead) return fileRead
}
// Plain text.
- const text = this.clipboard.readText();
+ const text = this.clipboard.readText()
if (text) {
- return { contentType: 'text', rawData: Buffer.from(text, 'utf-8'), textContent: text };
+ return { contentType: 'text', rawData: Buffer.from(text, 'utf-8'), textContent: text }
}
- return { contentType: 'text', rawData: Buffer.from(''), textContent: null };
+ return { contentType: 'text', rawData: Buffer.from(''), textContent: null }
}
private extractFile(): ClipboardRead | null {
@@ -107,22 +107,22 @@ export class ElectronClipboardBridge implements ClipboardBridge {
'NSFilenamesPboardType',
'com.apple.nspasteboard.promised-file-url',
'dyn.ah62d4rv4gu8y',
- 'text/uri-list',
- ];
+ 'text/uri-list'
+ ]
- let fileUrl: string | null = null;
+ let fileUrl: string | null = null
for (const formatType of macOSFileTypes) {
- if (fileUrl) break;
+ if (fileUrl) break
try {
- const buffer = this.clipboard.readBuffer(formatType);
+ const buffer = this.clipboard.readBuffer(formatType)
if (buffer && buffer.length > 0) {
- let parsed = buffer.toString('utf-8').replace(/\0/g, '').trim();
+ let parsed = buffer.toString('utf-8').replace(/\0/g, '').trim()
if (formatType === 'NSFilenamesPboardType' && parsed.includes('([^<]+)<\/string>/);
- if (m) parsed = m[1];
+ const m = parsed.match(/([^<]+)<\/string>/)
+ if (m) parsed = m[1]
}
- if (parsed.includes('\n')) parsed = parsed.split('\n')[0].trim();
- if (parsed && (parsed.startsWith('/') || parsed.startsWith('file://'))) fileUrl = parsed;
+ if (parsed.includes('\n')) parsed = parsed.split('\n')[0].trim()
+ if (parsed && (parsed.startsWith('/') || parsed.startsWith('file://'))) fileUrl = parsed
}
} catch {
// not all formats are readable
@@ -130,40 +130,40 @@ export class ElectronClipboardBridge implements ClipboardBridge {
}
if (!fileUrl) {
- const text = this.clipboard.readText();
- if (text && (text.startsWith('/') || text.startsWith('file://'))) fileUrl = text;
+ const text = this.clipboard.readText()
+ if (text && (text.startsWith('/') || text.startsWith('file://'))) fileUrl = text
}
- if (!fileUrl || !(fileUrl.startsWith('/') || fileUrl.startsWith('file://'))) return null;
+ if (!fileUrl || !(fileUrl.startsWith('/') || fileUrl.startsWith('file://'))) return null
- const resolved = resolveFileReferenceUrl(fileUrl);
+ const resolved = resolveFileReferenceUrl(fileUrl)
const filePath = resolved
? resolved
: fileUrl.startsWith('file://')
? decodeURIComponent(fileUrl.replace('file://', ''))
- : fileUrl;
+ : fileUrl
try {
- const stats = fs.statSync(filePath);
+ const stats = fs.statSync(filePath)
if (stats.isFile() && stats.size <= MAX_FILE_SIZE) {
return {
contentType: 'file',
rawData: fs.readFileSync(filePath),
- textContent: path.basename(filePath),
- };
+ textContent: path.basename(filePath)
+ }
}
if (stats.isFile() && stats.size > MAX_FILE_SIZE) {
return {
contentType: 'text',
rawData: Buffer.from(fileUrl, 'utf-8'),
- textContent: `[File too large: ${path.basename(filePath)}]`,
- };
+ textContent: `[File too large: ${path.basename(filePath)}]`
+ }
}
} catch {
// fall through to storing the path as text
}
- return { contentType: 'text', rawData: Buffer.from(fileUrl, 'utf-8'), textContent: fileUrl };
+ return { contentType: 'text', rawData: Buffer.from(fileUrl, 'utf-8'), textContent: fileUrl }
}
}
@@ -172,7 +172,7 @@ export class ElectronClipboardBridge implements ClipboardBridge {
* via AppleScript / NSURL. Returns null for normal URLs. Ported from copyclip.
*/
function resolveFileReferenceUrl(fileUrl: string): string | null {
- if (!fileUrl.includes('/.file/id=')) return null;
+ if (!fileUrl.includes('/.file/id=')) return null
try {
const script = `
use framework "Foundation"
@@ -183,14 +183,14 @@ function resolveFileReferenceUrl(fileUrl: string): string | null {
else
return ""
end if
- `;
+ `
const result = execSync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, {
encoding: 'utf-8',
- timeout: 5000,
- }).trim();
- if (result && result.startsWith('/')) return result;
+ timeout: 5000
+ }).trim()
+ if (result && result.startsWith('/')) return result
} catch {
// ignore
}
- return null;
+ return null
}
diff --git a/packages/clipboard/src/engine.ts b/packages/clipboard/src/engine.ts
index 63761efd..5cecc1a7 100644
--- a/packages/clipboard/src/engine.ts
+++ b/packages/clipboard/src/engine.ts
@@ -6,71 +6,71 @@
// Adapted from copyclip's clipboard-monitor (MIT); the OS-specific reading now
// lives behind ClipboardBridge instead of being baked in.
-import type { ClipboardBridge, ClipboardItem, ClipboardStore } from './types';
+import type { ClipboardBridge, ClipboardItem, ClipboardStore } from './types'
export interface ClipboardEngineOptions {
- bridge: ClipboardBridge;
- store: ClipboardStore;
+ bridge: ClipboardBridge
+ store: ClipboardStore
/** Content hash (sha256 hex). Injected so the package stays dependency-free
* (host passes node crypto on desktop, a JS impl on mobile). */
- hash: (data: Uint8Array) => string;
+ hash: (data: Uint8Array) => string
/** Poll interval in ms. copyclip used 500ms. */
- pollIntervalMs?: number;
+ pollIntervalMs?: number
/** Schedule a repeating timer. Defaults to setInterval; injectable for tests
* or platforms with a different timer API. */
- setInterval?: (cb: () => void, ms: number) => unknown;
- clearInterval?: (handle: unknown) => void;
+ setInterval?: (cb: () => void, ms: number) => unknown
+ clearInterval?: (handle: unknown) => void
}
-type Listener = (item: ClipboardItem) => void;
+type Listener = (item: ClipboardItem) => void
export class ClipboardEngine {
private readonly opts: Required> &
- ClipboardEngineOptions;
- private handle: unknown = null;
- private lastHash = '';
- private listeners: Listener[] = [];
+ ClipboardEngineOptions
+ private handle: unknown = null
+ private lastHash = ''
+ private listeners: Listener[] = []
constructor(options: ClipboardEngineOptions) {
this.opts = {
pollIntervalMs: 500,
- ...options,
- };
+ ...options
+ }
}
/** Subscribe to new clipboard items. Returns an unsubscribe function. */
onItem(listener: Listener): () => void {
- this.listeners.push(listener);
+ this.listeners.push(listener)
return () => {
- this.listeners = this.listeners.filter((l) => l !== listener);
- };
+ this.listeners = this.listeners.filter((l) => l !== listener)
+ }
}
start(): void {
- if (this.handle != null) return;
+ if (this.handle != null) return
// Seed lastHash with the current clipboard so we do not re-capture what is
// already there on startup.
- const current = this.safeRead();
- this.lastHash = current ? this.opts.hash(current.rawData) : '';
+ const current = this.safeRead()
+ this.lastHash = current ? this.opts.hash(current.rawData) : ''
- const schedule = this.opts.setInterval ?? ((cb, ms) => setInterval(cb, ms));
- this.handle = schedule(() => this.tick(), this.opts.pollIntervalMs);
+ const schedule = this.opts.setInterval ?? ((cb, ms) => setInterval(cb, ms))
+ this.handle = schedule(() => this.tick(), this.opts.pollIntervalMs)
}
stop(): void {
- if (this.handle == null) return;
- const clear = this.opts.clearInterval ?? ((h) => clearInterval(h as never));
- clear(this.handle);
- this.handle = null;
+ if (this.handle == null) return
+ const clear = this.opts.clearInterval ?? ((h) => clearInterval(h as never))
+ clear(this.handle)
+ this.handle = null
}
/** Read the clipboard once and persist if it is new. Exposed for tests. */
tick(): ClipboardItem | null {
- const read = this.safeRead();
- if (!read || read.rawData.length === 0) return null;
+ const read = this.safeRead()
+ if (!read || read.rawData.length === 0) return null
- const hash = this.opts.hash(read.rawData);
- if (hash === this.lastHash) return null;
+ const hash = this.opts.hash(read.rawData)
+ if (hash === this.lastHash) return null
const inserted = this.opts.store.insert({
timestamp: Date.now(),
@@ -78,28 +78,32 @@ export class ClipboardEngine {
textContent: read.textContent,
rawData: read.rawData,
sourceApp: read.sourceApp ?? null,
- hash,
- });
+ hash
+ })
// Only mark this content as "seen" AFTER a successful store write — if insert
// throws, leave lastHash so the payload is retried on the next tick instead of
// being silently dropped.
- this.lastHash = hash;
+ this.lastHash = hash
if (inserted) {
// Isolate subscribers: a throwing listener must not escape the poll timer
// (that can take down the Electron main process) or block other listeners.
for (const l of this.listeners) {
- try { l(inserted); } catch (e) { console.error('[clipboard] onItem listener threw', e); }
+ try {
+ l(inserted)
+ } catch (e) {
+ console.error('[clipboard] onItem listener threw', e)
+ }
}
}
- return inserted;
+ return inserted
}
private safeRead() {
try {
- return this.opts.bridge.read();
+ return this.opts.bridge.read()
} catch {
- return null;
+ return null
}
}
}
diff --git a/packages/clipboard/src/fuzzy-search.ts b/packages/clipboard/src/fuzzy-search.ts
index 773ab0a3..e57c7dcc 100644
--- a/packages/clipboard/src/fuzzy-search.ts
+++ b/packages/clipboard/src/fuzzy-search.ts
@@ -1,7 +1,7 @@
// Fuzzy search, ported from copyclip (https://github.com/alichherawalla/copyclip, MIT).
// Pure TS, reused unchanged except the types import path.
-import type { ClipboardItemDisplay, SearchResult } from './types';
+import type { ClipboardItemDisplay, SearchResult } from './types'
/**
* Fuzzy search implementation ported from Swift version.
@@ -12,183 +12,189 @@ import type { ClipboardItemDisplay, SearchResult } from './types';
*/
interface FuzzyMatchResult {
- score: number;
- matches: Array<[number, number]>;
+ score: number
+ matches: Array<[number, number]>
}
export function fuzzyMatch(pattern: string, text: string): FuzzyMatchResult | null {
if (!pattern || !text) {
- return null;
+ return null
}
- const patternLower = pattern.toLowerCase();
- const textLower = text.toLowerCase();
+ const patternLower = pattern.toLowerCase()
+ const textLower = text.toLowerCase()
// Quick check: all pattern characters must exist in text
- let patternIndex = 0;
+ let patternIndex = 0
for (let i = 0; i < textLower.length && patternIndex < patternLower.length; i++) {
if (textLower[i] === patternLower[patternIndex]) {
- patternIndex++;
+ patternIndex++
}
}
if (patternIndex !== patternLower.length) {
- return null; // Not all characters found
+ return null // Not all characters found
}
// Find best match with scoring
- const result = findBestMatch(patternLower, textLower);
+ const result = findBestMatch(patternLower, textLower)
if (!result) {
- return null;
+ return null
}
- return result;
+ return result
}
function findBestMatch(pattern: string, text: string): FuzzyMatchResult | null {
- const matches: number[] = [];
- let score = 0;
- let patternIdx = 0;
- let consecutiveBonus = 0;
+ const matches: number[] = []
+ let score = 0
+ let patternIdx = 0
+ let consecutiveBonus = 0
for (let textIdx = 0; textIdx < text.length && patternIdx < pattern.length; textIdx++) {
if (text[textIdx] === pattern[patternIdx]) {
- matches.push(textIdx);
+ matches.push(textIdx)
// Base score for match
- let matchScore = 1;
+ let matchScore = 1
// Bonus for consecutive matches
if (matches.length > 1 && matches[matches.length - 1] === matches[matches.length - 2] + 1) {
- consecutiveBonus++;
- matchScore += consecutiveBonus * 2;
+ consecutiveBonus++
+ matchScore += consecutiveBonus * 2
} else {
- consecutiveBonus = 0;
+ consecutiveBonus = 0
}
// Bonus for word boundary (start of word)
if (textIdx === 0 || isWordBoundary(text[textIdx - 1])) {
- matchScore += 5;
+ matchScore += 5
}
// Bonus for camelCase boundary
if (textIdx > 0 && isUpperCase(text[textIdx]) && isLowerCase(text[textIdx - 1])) {
- matchScore += 3;
+ matchScore += 3
}
// Bonus for matching at start
if (textIdx === 0) {
- matchScore += 10;
+ matchScore += 10
}
- score += matchScore;
- patternIdx++;
+ score += matchScore
+ patternIdx++
}
}
if (patternIdx !== pattern.length) {
- return null;
+ return null
}
// Penalty for unmatched characters (to prefer shorter matches)
- const unmatchedPenalty = (text.length - matches.length) * 0.1;
- score = Math.max(0, score - unmatchedPenalty);
+ const unmatchedPenalty = (text.length - matches.length) * 0.1
+ score = Math.max(0, score - unmatchedPenalty)
// Normalize score
- score = score / pattern.length;
+ score = score / pattern.length
// Convert match indices to ranges
- const ranges = indicesToRanges(matches);
+ const ranges = indicesToRanges(matches)
- return { score, matches: ranges };
+ return { score, matches: ranges }
}
function isWordBoundary(char: string): boolean {
- return /[\s\-_.,;:!?()[\]{}'"\/\\]/.test(char);
+ return /[\s\-_.,;:!?()[\]{}'"\/\\]/.test(char)
}
function isUpperCase(char: string): boolean {
- return char >= 'A' && char <= 'Z';
+ return char >= 'A' && char <= 'Z'
}
function isLowerCase(char: string): boolean {
- return char >= 'a' && char <= 'z';
+ return char >= 'a' && char <= 'z'
}
function indicesToRanges(indices: number[]): Array<[number, number]> {
if (indices.length === 0) {
- return [];
+ return []
}
- const ranges: Array<[number, number]> = [];
- let start = indices[0];
- let end = indices[0];
+ const ranges: Array<[number, number]> = []
+ let start = indices[0]
+ let end = indices[0]
for (let i = 1; i < indices.length; i++) {
if (indices[i] === end + 1) {
- end = indices[i];
+ end = indices[i]
} else {
- ranges.push([start, end + 1]);
- start = indices[i];
- end = indices[i];
+ ranges.push([start, end + 1])
+ start = indices[i]
+ end = indices[i]
}
}
- ranges.push([start, end + 1]);
- return ranges;
+ ranges.push([start, end + 1])
+ return ranges
}
function wordMatchBonus(query: string, text: string): number {
- const queryWords = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
- if (queryWords.length === 0) return 0;
+ const queryWords = query
+ .toLowerCase()
+ .split(/\s+/)
+ .filter((w) => w.length > 0)
+ if (queryWords.length === 0) return 0
- const textLower = text.toLowerCase();
- let bonus = 0;
+ const textLower = text.toLowerCase()
+ let bonus = 0
for (const word of queryWords) {
// Exact word match (bounded by word boundaries or string edges)
- const wordRegex = new RegExp(`(?:^|[\\s\\-_.,;:!?()\\[\\]{}'"\\/\\\\])${escapeRegex(word)}(?:$|[\\s\\-_.,;:!?()\\[\\]{}'"\\/\\\\])`, 'i');
+ const wordRegex = new RegExp(
+ `(?:^|[\\s\\-_.,;:!?()\\[\\]{}'"\\/\\\\])${escapeRegex(word)}(?:$|[\\s\\-_.,;:!?()\\[\\]{}'"\\/\\\\])`,
+ 'i'
+ )
if (wordRegex.test(text)) {
- bonus += 50;
+ bonus += 50
} else if (textLower.includes(word)) {
// Substring match (e.g. "kubectl" inside "run_kubectl_apply")
- bonus += 30;
+ bonus += 30
}
}
// Extra bonus when all query words are found as words
- const allWordsFound = queryWords.every(word => textLower.includes(word));
+ const allWordsFound = queryWords.every((word) => textLower.includes(word))
if (allWordsFound) {
- bonus += 20;
+ bonus += 20
}
- return bonus;
+ return bonus
}
function escapeRegex(str: string): string {
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export function fuzzySearch(items: ClipboardItemDisplay[], query: string): SearchResult[] {
- const results: SearchResult[] = [];
+ const results: SearchResult[] = []
for (const item of items) {
- const text = item.textContent || item.preview;
- const matchResult = fuzzyMatch(query, text);
+ const text = item.textContent || item.preview
+ const matchResult = fuzzyMatch(query, text)
if (matchResult) {
- const bonus = wordMatchBonus(query, text);
+ const bonus = wordMatchBonus(query, text)
results.push({
item,
score: matchResult.score + bonus,
- matches: matchResult.matches,
- });
+ matches: matchResult.matches
+ })
}
}
// Sort by match score (best first), then recency as tiebreaker
- results.sort((a, b) => b.score - a.score || b.item.timestamp - a.item.timestamp);
+ results.sort((a, b) => b.score - a.score || b.item.timestamp - a.item.timestamp)
// Return top 100 results
- return results.slice(0, 100);
+ return results.slice(0, 100)
}
diff --git a/packages/clipboard/src/index.ts b/packages/clipboard/src/index.ts
index 8744ecb0..374e820c 100644
--- a/packages/clipboard/src/index.ts
+++ b/packages/clipboard/src/index.ts
@@ -7,7 +7,7 @@
// ClipboardStore (a local SQLite store today, an @offgrid/memory op-log later so
// the clipboard syncs across devices).
-export * from './types';
-export { ClipboardEngine } from './engine';
-export type { ClipboardEngineOptions } from './engine';
-export { fuzzyMatch, fuzzySearch } from './fuzzy-search';
+export * from './types'
+export { ClipboardEngine } from './engine'
+export type { ClipboardEngineOptions } from './engine'
+export { fuzzyMatch, fuzzySearch } from './fuzzy-search'
diff --git a/packages/clipboard/src/types.ts b/packages/clipboard/src/types.ts
index ca6cf665..b5ff0c78 100644
--- a/packages/clipboard/src/types.ts
+++ b/packages/clipboard/src/types.ts
@@ -2,43 +2,43 @@
// Adapted from copyclip (https://github.com/alichherawalla/copyclip, MIT);
// rawData uses Uint8Array (not Node Buffer) so the types work on mobile too.
-export type ContentType = 'text' | 'rtf' | 'image' | 'file';
+export type ContentType = 'text' | 'rtf' | 'image' | 'file'
/** A captured clipboard entry, including its raw bytes. */
export interface ClipboardItem {
- id: string;
- timestamp: number;
- contentType: ContentType;
- textContent: string | null;
- rawData: Uint8Array;
+ id: string
+ timestamp: number
+ contentType: ContentType
+ textContent: string | null
+ rawData: Uint8Array
/** App the content was copied from, when the platform can report it. */
- sourceApp: string | null;
+ sourceApp: string | null
/** sha256 of rawData, used for dedup and as the sync key. */
- hash: string;
+ hash: string
}
/** A clipboard entry without the heavy raw bytes, for lists/UI. */
export interface ClipboardItemDisplay {
- id: string;
- timestamp: number;
- contentType: ContentType;
- textContent: string | null;
- sourceApp: string | null;
- preview: string;
+ id: string
+ timestamp: number
+ contentType: ContentType
+ textContent: string | null
+ sourceApp: string | null
+ preview: string
}
export interface SearchResult {
- item: ClipboardItemDisplay;
- score: number;
- matches: Array<[number, number]>;
+ item: ClipboardItemDisplay
+ score: number
+ matches: Array<[number, number]>
}
/** What a platform bridge reads from the OS clipboard at a point in time. */
export interface ClipboardRead {
- contentType: ContentType;
- rawData: Uint8Array;
- textContent: string | null;
- sourceApp?: string | null;
+ contentType: ContentType
+ rawData: Uint8Array
+ textContent: string | null
+ sourceApp?: string | null
}
/**
@@ -48,9 +48,9 @@ export interface ClipboardRead {
*/
export interface ClipboardBridge {
/** Read the current clipboard contents, or null if empty/unsupported. */
- read(): ClipboardRead | null;
+ read(): ClipboardRead | null
/** Put an item back on the OS clipboard (used when the user picks one). */
- write(item: ClipboardItem): void;
+ write(item: ClipboardItem): void
}
/**
@@ -60,10 +60,10 @@ export interface ClipboardBridge {
export interface ClipboardStore {
/** Insert a new item. Returns null if a row with the same hash exists
* (the store should bump that row's timestamp instead). */
- insert(item: Omit): ClipboardItem | null;
- list(limit?: number): ClipboardItemDisplay[];
- get(id: string): ClipboardItem | null;
- remove(id: string): void;
- clear(): void;
- count(): number;
+ insert(item: Omit): ClipboardItem | null
+ list(limit?: number): ClipboardItemDisplay[]
+ get(id: string): ClipboardItem | null
+ remove(id: string): void
+ clear(): void
+ count(): number
}
diff --git a/packages/design/src/index.ts b/packages/design/src/index.ts
index 298dc828..0d592b21 100644
--- a/packages/design/src/index.ts
+++ b/packages/design/src/index.ts
@@ -33,8 +33,8 @@ export const COLORS_LIGHT = {
info: '#525252',
overlay: 'rgba(0, 0, 0, 0.4)',
- divider: '#EBEBEB',
-} as const;
+ divider: '#EBEBEB'
+} as const
export const COLORS_DARK = {
primary: '#34D399',
@@ -63,20 +63,20 @@ export const COLORS_DARK = {
info: '#B0B0B0',
overlay: 'rgba(0, 0, 0, 0.7)',
- divider: '#1A1A1A',
-} as const;
+ divider: '#1A1A1A'
+} as const
-export type ThemeColors = typeof COLORS_LIGHT;
-export type ColorToken = keyof ThemeColors;
+export type ThemeColors = typeof COLORS_LIGHT
+export type ColorToken = keyof ThemeColors
// Monospace font stack. Menlo is the canonical Off Grid face; the rest are
// graceful fallbacks for non-macOS web environments.
export const FONT_MONO =
- "'Menlo', ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', monospace";
+ "'Menlo', ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', monospace"
export const FONTS = {
- mono: 'Menlo',
-} as const;
+ mono: 'Menlo'
+} as const
// Spacing scale in pixels (mobile/src/constants/index.ts SPACING).
export const SPACING = {
@@ -85,17 +85,17 @@ export const SPACING = {
md: 12,
lg: 16,
xl: 24,
- xxl: 32,
-} as const;
+ xxl: 32
+} as const
-export type SpacingToken = keyof typeof SPACING;
+export type SpacingToken = keyof typeof SPACING
// Single accent radius. The design guide favours crisp, sharp edges; 8px is
// the standard control radius, 2px for inline code and chips.
export const RADIUS = {
sm: 2,
- md: 8,
-} as const;
+ md: 8
+} as const
// Typography scale (mobile/src/constants/index.ts TYPOGRAPHY). Sizes in px,
// weights as numeric strings to match the RN tokens. Weights stay <= 400.
@@ -109,17 +109,17 @@ export const TYPOGRAPHY = {
label: { fontSize: 10, fontFamily: FONTS.mono, fontWeight: '400', letterSpacing: 0.3 },
labelSmall: { fontSize: 9, fontFamily: FONTS.mono, fontWeight: '400', letterSpacing: 0.3 },
meta: { fontSize: 10, fontFamily: FONTS.mono, fontWeight: '300', letterSpacing: 0 },
- metaSmall: { fontSize: 9, fontFamily: FONTS.mono, fontWeight: '300', letterSpacing: 0 },
-} as const;
+ metaSmall: { fontSize: 9, fontFamily: FONTS.mono, fontWeight: '300', letterSpacing: 0 }
+} as const
-export type TypographyToken = keyof typeof TYPOGRAPHY;
+export type TypographyToken = keyof typeof TYPOGRAPHY
// Build a CSS variable reference from a color token, e.g.
// cssVar('primary') -> 'var(--og-primary)'.
export function cssVar(token: ColorToken): string {
- return `var(--og-${kebab(token)})`;
+ return `var(--og-${kebab(token)})`
}
function kebab(s: string): string {
- return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
+ return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
}
diff --git a/packages/design/src/tokens.css b/packages/design/src/tokens.css
index 932c3472..4a1a8005 100644
--- a/packages/design/src/tokens.css
+++ b/packages/design/src/tokens.css
@@ -122,8 +122,8 @@
/* Theme-independent tokens */
:root {
- --og-font-mono: 'Menlo', ui-monospace, SFMono-Regular, 'SF Mono', Consolas,
- 'Liberation Mono', monospace;
+ --og-font-mono:
+ 'Menlo', ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', monospace;
--og-radius-sm: 2px;
--og-radius-md: 8px;
diff --git a/packages/design/tailwind-preset.js b/packages/design/tailwind-preset.js
index 54c7bc87..f90436f3 100644
--- a/packages/design/tailwind-preset.js
+++ b/packages/design/tailwind-preset.js
@@ -13,30 +13,30 @@
* `og()` is exported so apps can remap legacy color names (neutral, green, ...)
* onto the same channel variables for a zero-edit migration.
*/
-const og = (name) => `rgb(var(--og-rgb-${name}) / )`;
+const og = (name) => `rgb(var(--og-rgb-${name}) / )`
const colors = {
primary: {
DEFAULT: og('primary'),
dark: og('primary-dark'),
- light: og('primary-light'),
+ light: og('primary-light')
},
background: og('background'),
surface: {
DEFAULT: og('surface'),
light: og('surface-light'),
- hover: og('surface-hover'),
+ hover: og('surface-hover')
},
text: {
DEFAULT: og('text'),
secondary: og('text-secondary'),
muted: og('text-muted'),
- disabled: og('text-disabled'),
+ disabled: og('text-disabled')
},
border: {
DEFAULT: og('border'),
light: og('border-light'),
- focus: og('border-focus'),
+ focus: og('border-focus')
},
success: og('success'),
warning: og('warning'),
@@ -44,10 +44,18 @@ const colors = {
trending: og('trending'),
info: og('info'),
overlay: 'var(--og-overlay)',
- divider: 'var(--og-divider)',
-};
+ divider: 'var(--og-divider)'
+}
-const fontMono = ['Menlo', 'ui-monospace', 'SFMono-Regular', 'SF Mono', 'Consolas', 'Liberation Mono', 'monospace'];
+const fontMono = [
+ 'Menlo',
+ 'ui-monospace',
+ 'SFMono-Regular',
+ 'SF Mono',
+ 'Consolas',
+ 'Liberation Mono',
+ 'monospace'
+]
const preset = {
theme: {
@@ -55,7 +63,7 @@ const preset = {
colors,
fontFamily: {
mono: fontMono,
- sans: fontMono,
+ sans: fontMono
},
fontSize: {
display: ['22px', { lineHeight: '1.2', letterSpacing: '-0.5px', fontWeight: '200' }],
@@ -67,7 +75,7 @@ const preset = {
label: ['10px', { lineHeight: '1.4', letterSpacing: '0.3px', fontWeight: '400' }],
labelSmall: ['9px', { lineHeight: '1.4', letterSpacing: '0.3px', fontWeight: '400' }],
meta: ['10px', { lineHeight: '1.4', fontWeight: '300' }],
- metaSmall: ['9px', { lineHeight: '1.4', fontWeight: '300' }],
+ metaSmall: ['9px', { lineHeight: '1.4', fontWeight: '300' }]
},
spacing: {
xs: '4px',
@@ -75,17 +83,17 @@ const preset = {
md: '12px',
lg: '16px',
xl: '24px',
- xxl: '32px',
+ xxl: '32px'
},
borderRadius: {
sm: '2px',
DEFAULT: '8px',
- md: '8px',
- },
- },
- },
-};
+ md: '8px'
+ }
+ }
+ }
+}
-module.exports = preset;
-module.exports.og = og;
-module.exports.colors = colors;
+module.exports = preset
+module.exports.og = og
+module.exports.colors = colors
diff --git a/packages/models/src/adapters/node.ts b/packages/models/src/adapters/node.ts
index c4b21227..957e6ee6 100644
--- a/packages/models/src/adapters/node.ts
+++ b/packages/models/src/adapters/node.ts
@@ -2,25 +2,25 @@
// (HTTP Range) and progress. Uses global fetch (Node 18+/Electron). RN supplies
// its own bridge (background downloader); this lives at @offgrid/models/node.
-import fs from 'fs';
-import path from 'path';
-import type { DownloadBridge } from '../types';
+import fs from 'fs'
+import path from 'path'
+import type { DownloadBridge } from '../types'
export class NodeDownloadBridge implements DownloadBridge {
constructor(private readonly modelsDir: string) {
- fs.mkdirSync(modelsDir, { recursive: true });
+ fs.mkdirSync(modelsDir, { recursive: true })
}
pathFor(fileName: string): string {
- return path.join(this.modelsDir, fileName);
+ return path.join(this.modelsDir, fileName)
}
async exists(destPath: string, expectedBytes?: number): Promise {
try {
- const st = fs.statSync(destPath);
- return expectedBytes ? st.size === expectedBytes : st.size > 0;
+ const st = fs.statSync(destPath)
+ return expectedBytes ? st.size === expectedBytes : st.size > 0
} catch {
- return false;
+ return false
}
}
@@ -29,44 +29,44 @@ export class NodeDownloadBridge implements DownloadBridge {
destPath: string,
opts: { onProgress?: (written: number, total: number) => void; signal?: AbortSignal }
): Promise {
- const tmp = `${destPath}.part`;
- let start = 0;
+ const tmp = `${destPath}.part`
+ let start = 0
try {
- start = fs.statSync(tmp).size;
+ start = fs.statSync(tmp).size
} catch {
- start = 0;
+ start = 0
}
- const headers: Record = {};
- if (start > 0) headers.Range = `bytes=${start}-`;
+ const headers: Record = {}
+ if (start > 0) headers.Range = `bytes=${start}-`
- const res = await fetch(url, { headers, signal: opts.signal });
+ const res = await fetch(url, { headers, signal: opts.signal })
if (!res.ok && res.status !== 206) {
- throw new Error(`download failed: HTTP ${res.status} for ${url}`);
+ throw new Error(`download failed: HTTP ${res.status} for ${url}`)
}
- if (!res.body) throw new Error('download failed: empty body');
+ if (!res.body) throw new Error('download failed: empty body')
- const contentLength = Number(res.headers.get('content-length') ?? 0);
- const total = contentLength + (res.status === 206 ? start : 0);
+ const contentLength = Number(res.headers.get('content-length') ?? 0)
+ const total = contentLength + (res.status === 206 ? start : 0)
- const out = fs.createWriteStream(tmp, { flags: start > 0 && res.status === 206 ? 'a' : 'w' });
- let written = res.status === 206 ? start : 0;
+ const out = fs.createWriteStream(tmp, { flags: start > 0 && res.status === 206 ? 'a' : 'w' })
+ let written = res.status === 206 ? start : 0
- const reader = res.body.getReader();
+ const reader = res.body.getReader()
try {
for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- out.write(Buffer.from(value));
- written += value.length;
- opts.onProgress?.(written, total || written);
+ const { done, value } = await reader.read()
+ if (done) break
+ out.write(Buffer.from(value))
+ written += value.length
+ opts.onProgress?.(written, total || written)
}
} finally {
- out.end();
- await new Promise((resolve) => out.on('finish', () => resolve()));
+ out.end()
+ await new Promise((resolve) => out.on('finish', () => resolve()))
}
- fs.renameSync(tmp, destPath);
- return written;
+ fs.renameSync(tmp, destPath)
+ return written
}
}
diff --git a/packages/models/src/catalog.ts b/packages/models/src/catalog.ts
index 69a5c5e9..8086904a 100644
--- a/packages/models/src/catalog.ts
+++ b/packages/models/src/catalog.ts
@@ -3,10 +3,10 @@
// come. Entries point at Hugging Face resolve URLs. This is editorial/default;
// the HF browser (hf.ts) lets users find anything else.
-import type { ModelEntry, ModelKind, ModelRecommendationTier } from './types';
+import type { ModelEntry, ModelKind, ModelRecommendationTier } from './types'
-const HF = 'https://huggingface.co';
-const resolve = (repo: string, file: string): string => `${HF}/${repo}/resolve/main/${file}`;
+const HF = 'https://huggingface.co'
+const resolve = (repo: string, file: string): string => `${HF}/${repo}/resolve/main/${file}`
// RAM tier -> max LLM size + quant (ported from mobile MODEL_RECOMMENDATIONS).
export const RECOMMENDATION_TIERS: ModelRecommendationTier[] = [
@@ -15,14 +15,14 @@ export const RECOMMENDATION_TIERS: ModelRecommendationTier[] = [
{ minRamGb: 6, maxRamGb: 8, maxParams: 4, quantization: 'Q4_K_M' },
{ minRamGb: 8, maxRamGb: 12, maxParams: 8, quantization: 'Q4_K_M' },
{ minRamGb: 12, maxRamGb: 16, maxParams: 13, quantization: 'Q4_K_M' },
- { minRamGb: 16, maxRamGb: Infinity, maxParams: 30, quantization: 'Q4_K_M' },
-];
+ { minRamGb: 16, maxRamGb: Infinity, maxParams: 30, quantization: 'Q4_K_M' }
+]
export function recommendForRam(ramGb: number): ModelRecommendationTier {
return (
RECOMMENDATION_TIERS.find((t) => ramGb >= t.minRamGb && ramGb < t.maxRamGb) ??
RECOMMENDATION_TIERS[RECOMMENDATION_TIERS.length - 1]
- );
+ )
}
export const CATALOG: ModelEntry[] = [
@@ -38,7 +38,14 @@ export const CATALOG: ModelEntry[] = [
minRamGb: 3,
quant: 'Q4_K_M',
releaseDate: '2026-03-01',
- files: [{ name: 'Qwen3.5-0.8B-Q4_K_M.gguf', url: resolve('unsloth/Qwen3.5-0.8B-GGUF', 'Qwen3.5-0.8B-Q4_K_M.gguf'), sizeBytes: 530000000, role: 'primary' }],
+ files: [
+ {
+ name: 'Qwen3.5-0.8B-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3.5-0.8B-GGUF', 'Qwen3.5-0.8B-Q4_K_M.gguf'),
+ sizeBytes: 530000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/Qwen3.5-2B-GGUF',
@@ -50,7 +57,14 @@ export const CATALOG: ModelEntry[] = [
minRamGb: 4,
quant: 'Q4_K_M',
releaseDate: '2026-02-28',
- files: [{ name: 'Qwen3.5-2B-Q4_K_M.gguf', url: resolve('unsloth/Qwen3.5-2B-GGUF', 'Qwen3.5-2B-Q4_K_M.gguf'), sizeBytes: 1280000000, role: 'primary' }],
+ files: [
+ {
+ name: 'Qwen3.5-2B-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3.5-2B-GGUF', 'Qwen3.5-2B-Q4_K_M.gguf'),
+ sizeBytes: 1280000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/Qwen3.5-4B-GGUF',
@@ -63,7 +77,14 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
tags: ['Challenger'],
releaseDate: '2026-03-02',
- files: [{ name: 'Qwen3.5-4B-Q4_K_M.gguf', url: resolve('unsloth/Qwen3.5-4B-GGUF', 'Qwen3.5-4B-Q4_K_M.gguf'), sizeBytes: 2740000000, role: 'primary' }],
+ files: [
+ {
+ name: 'Qwen3.5-4B-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3.5-4B-GGUF', 'Qwen3.5-4B-Q4_K_M.gguf'),
+ sizeBytes: 2740000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/Qwen3.5-9B-GGUF',
@@ -76,7 +97,14 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
tags: ['Challenger'],
releaseDate: '2026-02-28',
- files: [{ name: 'Qwen3.5-9B-Q4_K_M.gguf', url: resolve('unsloth/Qwen3.5-9B-GGUF', 'Qwen3.5-9B-Q4_K_M.gguf'), sizeBytes: 5680000000, role: 'primary' }],
+ files: [
+ {
+ name: 'Qwen3.5-9B-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3.5-9B-GGUF', 'Qwen3.5-9B-Q4_K_M.gguf'),
+ sizeBytes: 5680000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/Qwen3.5-27B-GGUF',
@@ -88,7 +116,14 @@ export const CATALOG: ModelEntry[] = [
minRamGb: 24,
quant: 'Q4_K_M',
releaseDate: '2026-02-24',
- files: [{ name: 'Qwen3.5-27B-Q4_K_M.gguf', url: resolve('unsloth/Qwen3.5-27B-GGUF', 'Qwen3.5-27B-Q4_K_M.gguf'), sizeBytes: 16740000000, role: 'primary' }],
+ files: [
+ {
+ name: 'Qwen3.5-27B-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3.5-27B-GGUF', 'Qwen3.5-27B-Q4_K_M.gguf'),
+ sizeBytes: 16740000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/gemma-4-E2B-it-GGUF',
@@ -100,7 +135,14 @@ export const CATALOG: ModelEntry[] = [
minRamGb: 5,
quant: 'Q4_K_M',
releaseDate: '2026-04-01',
- files: [{ name: 'gemma-4-E2B-it-Q4_K_M.gguf', url: resolve('unsloth/gemma-4-E2B-it-GGUF', 'gemma-4-E2B-it-Q4_K_M.gguf'), sizeBytes: 3110000000, role: 'primary' }],
+ files: [
+ {
+ name: 'gemma-4-E2B-it-Q4_K_M.gguf',
+ url: resolve('unsloth/gemma-4-E2B-it-GGUF', 'gemma-4-E2B-it-Q4_K_M.gguf'),
+ sizeBytes: 3110000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/gemma-4-12b-it-GGUF',
@@ -113,7 +155,14 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
tags: ['Challenger'],
releaseDate: '2026-05-29',
- files: [{ name: 'gemma-4-12b-it-Q4_K_M.gguf', url: resolve('unsloth/gemma-4-12b-it-GGUF', 'gemma-4-12b-it-Q4_K_M.gguf'), sizeBytes: 7120000000, role: 'primary' }],
+ files: [
+ {
+ name: 'gemma-4-12b-it-Q4_K_M.gguf',
+ url: resolve('unsloth/gemma-4-12b-it-GGUF', 'gemma-4-12b-it-Q4_K_M.gguf'),
+ sizeBytes: 7120000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/gemma-4-26B-A4B-it-GGUF',
@@ -126,7 +175,14 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
tags: ['Challenger'],
releaseDate: '2026-04-01',
- files: [{ name: 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf', url: resolve('unsloth/gemma-4-26B-A4B-it-GGUF', 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf'), sizeBytes: 16950000000, role: 'primary' }],
+ files: [
+ {
+ name: 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf',
+ url: resolve('unsloth/gemma-4-26B-A4B-it-GGUF', 'gemma-4-26B-A4B-it-UD-Q4_K_M.gguf'),
+ sizeBytes: 16950000000,
+ role: 'primary'
+ }
+ ]
},
{
id: 'unsloth/gemma-4-31B-it-GGUF',
@@ -138,7 +194,14 @@ export const CATALOG: ModelEntry[] = [
minRamGb: 24,
quant: 'Q4_K_M',
releaseDate: '2026-04-01',
- files: [{ name: 'gemma-4-31B-it-Q4_K_M.gguf', url: resolve('unsloth/gemma-4-31B-it-GGUF', 'gemma-4-31B-it-Q4_K_M.gguf'), sizeBytes: 18320000000, role: 'primary' }],
+ files: [
+ {
+ name: 'gemma-4-31B-it-Q4_K_M.gguf',
+ url: resolve('unsloth/gemma-4-31B-it-GGUF', 'gemma-4-31B-it-Q4_K_M.gguf'),
+ sizeBytes: 18320000000,
+ role: 'primary'
+ }
+ ]
},
// --- vision (multimodal LLM) ---
{
@@ -153,9 +216,19 @@ export const CATALOG: ModelEntry[] = [
tags: ['Challenger'],
releaseDate: '2026-04-01',
files: [
- { name: 'gemma-4-E4B-it-Q4_K_M.gguf', url: resolve('unsloth/gemma-4-E4B-it-GGUF', 'gemma-4-E4B-it-Q4_K_M.gguf'), sizeBytes: 4980000000, role: 'primary' },
- { name: 'mmproj-gemma-4-E4B-it-F16.gguf', url: resolve('unsloth/gemma-4-E4B-it-GGUF', 'mmproj-F16.gguf'), sizeBytes: 990000000, role: 'mmproj' },
- ],
+ {
+ name: 'gemma-4-E4B-it-Q4_K_M.gguf',
+ url: resolve('unsloth/gemma-4-E4B-it-GGUF', 'gemma-4-E4B-it-Q4_K_M.gguf'),
+ sizeBytes: 4980000000,
+ role: 'primary'
+ },
+ {
+ name: 'mmproj-gemma-4-E4B-it-F16.gguf',
+ url: resolve('unsloth/gemma-4-E4B-it-GGUF', 'mmproj-F16.gguf'),
+ sizeBytes: 990000000,
+ role: 'mmproj'
+ }
+ ]
},
{
id: 'ggml-org/SmolVLM2-2.2B-Instruct-GGUF',
@@ -168,9 +241,22 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
releaseDate: '2025-04-21',
files: [
- { name: 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf', url: resolve('ggml-org/SmolVLM2-2.2B-Instruct-GGUF', 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf'), sizeBytes: 1110000000, role: 'primary' },
- { name: 'mmproj-SmolVLM2-2.2B-Instruct-f16.gguf', url: resolve('ggml-org/SmolVLM2-2.2B-Instruct-GGUF', 'mmproj-SmolVLM2-2.2B-Instruct-f16.gguf'), sizeBytes: 870000000, role: 'mmproj' },
- ],
+ {
+ name: 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf',
+ url: resolve('ggml-org/SmolVLM2-2.2B-Instruct-GGUF', 'SmolVLM2-2.2B-Instruct-Q4_K_M.gguf'),
+ sizeBytes: 1110000000,
+ role: 'primary'
+ },
+ {
+ name: 'mmproj-SmolVLM2-2.2B-Instruct-f16.gguf',
+ url: resolve(
+ 'ggml-org/SmolVLM2-2.2B-Instruct-GGUF',
+ 'mmproj-SmolVLM2-2.2B-Instruct-f16.gguf'
+ ),
+ sizeBytes: 870000000,
+ role: 'mmproj'
+ }
+ ]
},
{
id: 'unsloth/Qwen3-VL-2B-Instruct-GGUF',
@@ -183,9 +269,19 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
releaseDate: '2025-10-30',
files: [
- { name: 'Qwen3-VL-2B-Instruct-Q4_K_M.gguf', url: resolve('unsloth/Qwen3-VL-2B-Instruct-GGUF', 'Qwen3-VL-2B-Instruct-Q4_K_M.gguf'), sizeBytes: 1110000000, role: 'primary' },
- { name: 'mmproj-Qwen3-VL-2B-Instruct-F16.gguf', url: resolve('unsloth/Qwen3-VL-2B-Instruct-GGUF', 'mmproj-BF16.gguf'), sizeBytes: 820000000, role: 'mmproj' },
- ],
+ {
+ name: 'Qwen3-VL-2B-Instruct-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3-VL-2B-Instruct-GGUF', 'Qwen3-VL-2B-Instruct-Q4_K_M.gguf'),
+ sizeBytes: 1110000000,
+ role: 'primary'
+ },
+ {
+ name: 'mmproj-Qwen3-VL-2B-Instruct-F16.gguf',
+ url: resolve('unsloth/Qwen3-VL-2B-Instruct-GGUF', 'mmproj-BF16.gguf'),
+ sizeBytes: 820000000,
+ role: 'mmproj'
+ }
+ ]
},
{
id: 'unsloth/Qwen3-VL-8B-Instruct-GGUF',
@@ -198,9 +294,19 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q4_K_M',
releaseDate: '2025-10-30',
files: [
- { name: 'Qwen3-VL-8B-Instruct-Q4_K_M.gguf', url: resolve('unsloth/Qwen3-VL-8B-Instruct-GGUF', 'Qwen3-VL-8B-Instruct-Q4_K_M.gguf'), sizeBytes: 5030000000, role: 'primary' },
- { name: 'mmproj-Qwen3-VL-8B-Instruct-F16.gguf', url: resolve('unsloth/Qwen3-VL-8B-Instruct-GGUF', 'mmproj-BF16.gguf'), sizeBytes: 1160000000, role: 'mmproj' },
- ],
+ {
+ name: 'Qwen3-VL-8B-Instruct-Q4_K_M.gguf',
+ url: resolve('unsloth/Qwen3-VL-8B-Instruct-GGUF', 'Qwen3-VL-8B-Instruct-Q4_K_M.gguf'),
+ sizeBytes: 5030000000,
+ role: 'primary'
+ },
+ {
+ name: 'mmproj-Qwen3-VL-8B-Instruct-F16.gguf',
+ url: resolve('unsloth/Qwen3-VL-8B-Instruct-GGUF', 'mmproj-BF16.gguf'),
+ sizeBytes: 1160000000,
+ role: 'mmproj'
+ }
+ ]
},
// --- image generation — 2026 / fast few-step models only (open weight) ---
{
@@ -213,7 +319,8 @@ export const CATALOG: ModelEntry[] = [
// models verified fast on-device (dreamshaper-turbo, realvis-lightning).
tags: ['Recommended', '2026', 'Top quality'],
org: 'Alibaba Tongyi',
- description: 'Flagship 2026 model — 1024px in ~8 steps, top quality-per-byte, strong bilingual text. Apache-2.0. (diffusion + Qwen3 encoder + VAE)',
+ description:
+ 'Flagship 2026 model — 1024px in ~8 steps, top quality-per-byte, strong bilingual text. Apache-2.0. (diffusion + Qwen3 encoder + VAE)',
minRamGb: 12,
imageModes: ['txt2img'],
files: [
@@ -221,21 +328,21 @@ export const CATALOG: ModelEntry[] = [
name: 'z_image_turbo-Q4_K.gguf',
url: resolve('leejet/Z-Image-Turbo-GGUF', 'z_image_turbo-Q4_K.gguf'),
role: 'primary',
- sizeBytes: 3860000000,
+ sizeBytes: 3860000000
},
{
name: 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf',
url: resolve('unsloth/Qwen3-4B-Instruct-2507-GGUF', 'Qwen3-4B-Instruct-2507-Q4_K_M.gguf'),
role: 'aux',
- sizeBytes: 2500000000,
+ sizeBytes: 2500000000
},
{
name: 'ae.safetensors',
url: resolve('second-state/FLUX.1-schnell-GGUF', 'ae.safetensors'),
role: 'aux',
- sizeBytes: 340000000,
- },
- ],
+ sizeBytes: 340000000
+ }
+ ]
},
// NOTE: MLX/mflux image models are PARKED (2026-06-23) — the only non-gated
// on-device MLX LoRA options are too large to ship (Z-Image ~13GB 8-bit / ~33GB
@@ -248,7 +355,8 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Recommended', 'Fast'],
org: 'ByteDance',
- description: 'Near-SDXL quality at 1024px in 4 steps (~7× faster). ~4GB model. Best balance — recommended.',
+ description:
+ 'Near-SDXL quality at 1024px in 4 steps (~7× faster). ~4GB model. Best balance — recommended.',
minRamGb: 8,
imageModes: ['txt2img', 'img2img'],
files: [
@@ -256,9 +364,9 @@ export const CATALOG: ModelEntry[] = [
name: 'sdxl_lightning_4step.q8_0.gguf',
url: resolve('mzwing/SDXL-Lightning-GGUF', 'sdxl_lightning_4step.q8_0.gguf'),
role: 'primary',
- sizeBytes: 4099000000,
- },
- ],
+ sizeBytes: 4099000000
+ }
+ ]
},
{
id: 'OlegSkutte/sdxl-turbo-GGUF',
@@ -266,12 +374,18 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Fastest', 'Drafts'],
org: 'Stability AI',
- description: 'Distilled SDXL — 1-4 steps, ~10s drafts at 512px. Fastest option; lower fidelity.',
+ description:
+ 'Distilled SDXL — 1-4 steps, ~10s drafts at 512px. Fastest option; lower fidelity.',
minRamGb: 8,
imageModes: ['txt2img', 'img2img'],
files: [
- { name: 'sd_xl_turbo_1.0.q8_0.gguf', url: resolve('OlegSkutte/sdxl-turbo-GGUF', 'sd_xl_turbo_1.0.q8_0.gguf'), role: 'primary', sizeBytes: 4100000000 },
- ],
+ {
+ name: 'sd_xl_turbo_1.0.q8_0.gguf',
+ url: resolve('OlegSkutte/sdxl-turbo-GGUF', 'sd_xl_turbo_1.0.q8_0.gguf'),
+ role: 'primary',
+ sizeBytes: 4100000000
+ }
+ ]
},
// SDXL finetunes — Off Grid GGUF builds (q8). The community GGUF quants of these
// are mis-exported and won't load in sd.cpp, so we converted the official
@@ -287,7 +401,14 @@ export const CATALOG: ModelEntry[] = [
quant: 'Q8_0',
releaseDate: '2024-08-05',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'realvisxl-v5.0-Q8_0.gguf', url: resolve('offgrid-ai/realvisxl-v5.0-GGUF', 'realvisxl-v5.0-Q8_0.gguf'), role: 'primary', sizeBytes: 4180000000 }],
+ files: [
+ {
+ name: 'realvisxl-v5.0-Q8_0.gguf',
+ url: resolve('offgrid-ai/realvisxl-v5.0-GGUF', 'realvisxl-v5.0-Q8_0.gguf'),
+ role: 'primary',
+ sizeBytes: 4180000000
+ }
+ ]
},
{
// Light (Q4_K) sibling — ~35% less memory, runs on a 16GB Mac. Tagged 'Light'
@@ -297,12 +418,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Photoreal', 'Light'],
org: 'RealVis',
- description: 'Top photorealism SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0.',
+ description:
+ 'Top photorealism SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2024-08-05',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'realvisxl-v5.0-Q4_K.gguf', url: resolve('offgrid-ai/realvisxl-v5.0-GGUF', 'realvisxl-v5.0-Q4_K.gguf'), role: 'primary', sizeBytes: 2800000000 }],
+ files: [
+ {
+ name: 'realvisxl-v5.0-Q4_K.gguf',
+ url: resolve('offgrid-ai/realvisxl-v5.0-GGUF', 'realvisxl-v5.0-Q4_K.gguf'),
+ role: 'primary',
+ sizeBytes: 2800000000
+ }
+ ]
},
{
id: 'offgrid-ai/realvisxl-v5.0-lightning-GGUF',
@@ -312,12 +441,23 @@ export const CATALOG: ModelEntry[] = [
// Light (Q4) sibling that's both few-step AND memory-safe.
tags: ['Photoreal'],
org: 'RealVis',
- description: 'Photoreal SDXL, few-step (fast) — Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.',
+ description:
+ 'Photoreal SDXL, few-step (fast) — Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.',
minRamGb: 8,
quant: 'Q8_0',
releaseDate: '2024-09-02',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'realvisxl-v5.0-lightning-Q8_0.gguf', url: resolve('offgrid-ai/realvisxl-v5.0-lightning-GGUF', 'realvisxl-v5.0-lightning-Q8_0.gguf'), role: 'primary', sizeBytes: 4180000000 }],
+ files: [
+ {
+ name: 'realvisxl-v5.0-lightning-Q8_0.gguf',
+ url: resolve(
+ 'offgrid-ai/realvisxl-v5.0-lightning-GGUF',
+ 'realvisxl-v5.0-lightning-Q8_0.gguf'
+ ),
+ role: 'primary',
+ sizeBytes: 4180000000
+ }
+ ]
},
{
// Light (Q4_K) sibling — few-step photoreal, ~35% less memory, 16GB-friendly.
@@ -326,12 +466,23 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Fast', 'Photoreal', 'Light'],
org: 'RealVis',
- description: 'Photoreal SDXL, few-step (fast). Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.',
+ description:
+ 'Photoreal SDXL, few-step (fast). Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of SG161222/RealVisXL_V5.0_Lightning.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2024-09-02',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'realvisxl-v5.0-lightning-Q4_K.gguf', url: resolve('offgrid-ai/realvisxl-v5.0-lightning-GGUF', 'realvisxl-v5.0-lightning-Q4_K.gguf'), role: 'primary', sizeBytes: 2800000000 }],
+ files: [
+ {
+ name: 'realvisxl-v5.0-lightning-Q4_K.gguf',
+ url: resolve(
+ 'offgrid-ai/realvisxl-v5.0-lightning-GGUF',
+ 'realvisxl-v5.0-lightning-Q4_K.gguf'
+ ),
+ role: 'primary',
+ sizeBytes: 2800000000
+ }
+ ]
},
{
id: 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF',
@@ -341,12 +492,23 @@ export const CATALOG: ModelEntry[] = [
// Light (Q4) sibling that's both few-step AND memory-safe.
tags: ['Versatile'],
org: 'Lykon',
- description: 'The all-rounder — photoreal, art, fantasy, 3D. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo. Full Q8 quant (best quality); best on 24GB+ RAM.',
+ description:
+ 'The all-rounder — photoreal, art, fantasy, 3D. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo. Full Q8 quant (best quality); best on 24GB+ RAM.',
minRamGb: 8,
quant: 'Q8_0',
releaseDate: '2024-02-07',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'dreamshaper-xl-v2-turbo-Q8_0.gguf', url: resolve('offgrid-ai/dreamshaper-xl-v2-turbo-GGUF', 'dreamshaper-xl-v2-turbo-Q8_0.gguf'), role: 'primary', sizeBytes: 4180000000 }],
+ files: [
+ {
+ name: 'dreamshaper-xl-v2-turbo-Q8_0.gguf',
+ url: resolve(
+ 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF',
+ 'dreamshaper-xl-v2-turbo-Q8_0.gguf'
+ ),
+ role: 'primary',
+ sizeBytes: 4180000000
+ }
+ ]
},
{
// Lighter Q4_K quant of the same distilled turbo model — ~35% less memory
@@ -359,12 +521,23 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Versatile', 'Fast', 'Light'],
org: 'Lykon',
- description: 'The all-rounder — photoreal, art, fantasy, 3D. Q4 quant: ~35% less memory than the full model, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo.',
+ description:
+ 'The all-rounder — photoreal, art, fantasy, 3D. Q4 quant: ~35% less memory than the full model, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Lykon/dreamshaper-xl-v2-turbo.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2024-02-07',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'dreamshaper-xl-v2-turbo-Q4_K.gguf', url: resolve('offgrid-ai/dreamshaper-xl-v2-turbo-GGUF', 'dreamshaper-xl-v2-turbo-Q4_K.gguf'), role: 'primary', sizeBytes: 2800000000 }],
+ files: [
+ {
+ name: 'dreamshaper-xl-v2-turbo-Q4_K.gguf',
+ url: resolve(
+ 'offgrid-ai/dreamshaper-xl-v2-turbo-GGUF',
+ 'dreamshaper-xl-v2-turbo-Q4_K.gguf'
+ ),
+ role: 'primary',
+ sizeBytes: 2800000000
+ }
+ ]
},
{
id: 'offgrid-ai/juggernaut-xl-v9-GGUF',
@@ -372,12 +545,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['High quality', 'Photoreal'],
org: 'RunDiffusion',
- description: 'Versatile photoreal SDXL — cinematic, portraits, landscapes. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.',
+ description:
+ 'Versatile photoreal SDXL — cinematic, portraits, landscapes. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.',
minRamGb: 8,
quant: 'Q8_0',
releaseDate: '2024-02-18',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'juggernaut-xl-v9-Q8_0.gguf', url: resolve('offgrid-ai/juggernaut-xl-v9-GGUF', 'juggernaut-xl-v9-Q8_0.gguf'), role: 'primary', sizeBytes: 4350000000 }],
+ files: [
+ {
+ name: 'juggernaut-xl-v9-Q8_0.gguf',
+ url: resolve('offgrid-ai/juggernaut-xl-v9-GGUF', 'juggernaut-xl-v9-Q8_0.gguf'),
+ role: 'primary',
+ sizeBytes: 4350000000
+ }
+ ]
},
{
// Light (Q4_K) sibling — ~35% less memory, 16GB-friendly.
@@ -386,12 +567,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Photoreal', 'Light'],
org: 'RunDiffusion',
- description: 'Versatile photoreal SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.',
+ description:
+ 'Versatile photoreal SDXL. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of RunDiffusion/Juggernaut-XL-v9.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2024-02-18',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'juggernaut-xl-v9-Q4_K.gguf', url: resolve('offgrid-ai/juggernaut-xl-v9-GGUF', 'juggernaut-xl-v9-Q4_K.gguf'), role: 'primary', sizeBytes: 2900000000 }],
+ files: [
+ {
+ name: 'juggernaut-xl-v9-Q4_K.gguf',
+ url: resolve('offgrid-ai/juggernaut-xl-v9-GGUF', 'juggernaut-xl-v9-Q4_K.gguf'),
+ role: 'primary',
+ sizeBytes: 2900000000
+ }
+ ]
},
{
id: 'offgrid-ai/animagine-xl-4.0-GGUF',
@@ -399,12 +588,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['High quality', 'Anime'],
org: 'Cagliostro',
- description: 'Leading anime SDXL — strong character knowledge. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.',
+ description:
+ 'Leading anime SDXL — strong character knowledge. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.',
minRamGb: 8,
quant: 'Q8_0',
releaseDate: '2025-01-10',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'animagine-xl-4.0-Q8_0.gguf', url: resolve('offgrid-ai/animagine-xl-4.0-GGUF', 'animagine-xl-4.0-Q8_0.gguf'), role: 'primary', sizeBytes: 4180000000 }],
+ files: [
+ {
+ name: 'animagine-xl-4.0-Q8_0.gguf',
+ url: resolve('offgrid-ai/animagine-xl-4.0-GGUF', 'animagine-xl-4.0-Q8_0.gguf'),
+ role: 'primary',
+ sizeBytes: 4180000000
+ }
+ ]
},
{
// Light (Q4_K) sibling — ~35% less memory, 16GB-friendly.
@@ -413,12 +610,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Anime', 'Light'],
org: 'Cagliostro',
- description: 'Leading anime SDXL — strong character knowledge. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.',
+ description:
+ 'Leading anime SDXL — strong character knowledge. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of cagliostrolab/animagine-xl-4.0.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2025-01-10',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'animagine-xl-4.0-Q4_K.gguf', url: resolve('offgrid-ai/animagine-xl-4.0-GGUF', 'animagine-xl-4.0-Q4_K.gguf'), role: 'primary', sizeBytes: 2800000000 }],
+ files: [
+ {
+ name: 'animagine-xl-4.0-Q4_K.gguf',
+ url: resolve('offgrid-ai/animagine-xl-4.0-GGUF', 'animagine-xl-4.0-Q4_K.gguf'),
+ role: 'primary',
+ sizeBytes: 2800000000
+ }
+ ]
},
{
id: 'offgrid-ai/illustrious-xl-v2.0-GGUF',
@@ -426,12 +631,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['High quality', 'Anime'],
org: 'OnomaAI',
- description: 'Top anime / illustration SDXL base. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.',
+ description:
+ 'Top anime / illustration SDXL base. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.',
minRamGb: 8,
quant: 'Q8_0',
releaseDate: '2025-04-18',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'illustrious-xl-v2.0-Q8_0.gguf', url: resolve('offgrid-ai/illustrious-xl-v2.0-GGUF', 'illustrious-xl-v2.0-Q8_0.gguf'), role: 'primary', sizeBytes: 4180000000 }],
+ files: [
+ {
+ name: 'illustrious-xl-v2.0-Q8_0.gguf',
+ url: resolve('offgrid-ai/illustrious-xl-v2.0-GGUF', 'illustrious-xl-v2.0-Q8_0.gguf'),
+ role: 'primary',
+ sizeBytes: 4180000000
+ }
+ ]
},
{
// Light (Q4_K) sibling — ~35% less memory, 16GB-friendly.
@@ -440,12 +653,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Anime', 'Light'],
org: 'OnomaAI',
- description: 'Top anime / illustration SDXL base. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.',
+ description:
+ 'Top anime / illustration SDXL base. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of OnomaAIResearch/Illustrious-XL-v2.0.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2025-04-18',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'illustrious-xl-v2.0-Q4_K.gguf', url: resolve('offgrid-ai/illustrious-xl-v2.0-GGUF', 'illustrious-xl-v2.0-Q4_K.gguf'), role: 'primary', sizeBytes: 2800000000 }],
+ files: [
+ {
+ name: 'illustrious-xl-v2.0-Q4_K.gguf',
+ url: resolve('offgrid-ai/illustrious-xl-v2.0-GGUF', 'illustrious-xl-v2.0-Q4_K.gguf'),
+ role: 'primary',
+ sizeBytes: 2800000000
+ }
+ ]
},
{
id: 'offgrid-ai/pony-diffusion-v6-xl-GGUF',
@@ -453,12 +674,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['High quality', 'Stylized'],
org: 'PurpleSmartAI',
- description: 'Dominant SDXL for stylized characters & illustration; highly promptable. Off Grid GGUF build of Pony Diffusion V6 XL.',
+ description:
+ 'Dominant SDXL for stylized characters & illustration; highly promptable. Off Grid GGUF build of Pony Diffusion V6 XL.',
minRamGb: 8,
quant: 'Q8_0',
releaseDate: '2024-05-25',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'pony-diffusion-v6-xl-Q8_0.gguf', url: resolve('offgrid-ai/pony-diffusion-v6-xl-GGUF', 'pony-diffusion-v6-xl-Q8_0.gguf'), role: 'primary', sizeBytes: 4180000000 }],
+ files: [
+ {
+ name: 'pony-diffusion-v6-xl-Q8_0.gguf',
+ url: resolve('offgrid-ai/pony-diffusion-v6-xl-GGUF', 'pony-diffusion-v6-xl-Q8_0.gguf'),
+ role: 'primary',
+ sizeBytes: 4180000000
+ }
+ ]
},
{
// Light (Q4_K) sibling — ~35% less memory, 16GB-friendly.
@@ -467,12 +696,20 @@ export const CATALOG: ModelEntry[] = [
kind: 'image',
tags: ['Stylized', 'Light'],
org: 'PurpleSmartAI',
- description: 'Dominant SDXL for stylized characters & illustration. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Pony Diffusion V6 XL.',
+ description:
+ 'Dominant SDXL for stylized characters & illustration. Q4 quant: ~35% less memory, small quality trade-off. Runs on a 16GB Mac. Off Grid GGUF build of Pony Diffusion V6 XL.',
minRamGb: 8,
quant: 'Q4_K',
releaseDate: '2024-05-25',
imageModes: ['txt2img', 'img2img'],
- files: [{ name: 'pony-diffusion-v6-xl-Q4_K.gguf', url: resolve('offgrid-ai/pony-diffusion-v6-xl-GGUF', 'pony-diffusion-v6-xl-Q4_K.gguf'), role: 'primary', sizeBytes: 2800000000 }],
+ files: [
+ {
+ name: 'pony-diffusion-v6-xl-Q4_K.gguf',
+ url: resolve('offgrid-ai/pony-diffusion-v6-xl-GGUF', 'pony-diffusion-v6-xl-Q4_K.gguf'),
+ role: 'primary',
+ sizeBytes: 2800000000
+ }
+ ]
},
// --- voice (TTS); open models, ONNX runtime (no Python) ---
{
@@ -487,9 +724,9 @@ export const CATALOG: ModelEntry[] = [
name: 'kokoro-82m-v1.0.onnx',
url: resolve('onnx-community/Kokoro-82M-v1.0-ONNX', 'onnx/model_quantized.onnx'),
role: 'primary',
- sizeBytes: 92361116,
- },
- ],
+ sizeBytes: 92361116
+ }
+ ]
},
{
id: 'rhasspy/piper-voices/en_US-lessac-medium',
@@ -503,14 +740,17 @@ export const CATALOG: ModelEntry[] = [
name: 'en_US-lessac-medium.onnx',
url: resolve('rhasspy/piper-voices', 'en/en_US/lessac/medium/en_US-lessac-medium.onnx'),
role: 'primary',
- sizeBytes: 63201294,
+ sizeBytes: 63201294
},
{
name: 'en_US-lessac-medium.onnx.json',
- url: resolve('rhasspy/piper-voices', 'en/en_US/lessac/medium/en_US-lessac-medium.onnx.json'),
- role: 'aux',
- },
- ],
+ url: resolve(
+ 'rhasspy/piper-voices',
+ 'en/en_US/lessac/medium/en_US-lessac-medium.onnx.json'
+ ),
+ role: 'aux'
+ }
+ ]
},
// --- transcription (STT / whisper); all from ggerganov/whisper.cpp (ggml .bin) ---
{
@@ -520,7 +760,14 @@ export const CATALOG: ModelEntry[] = [
org: 'ggerganov',
description: 'Fastest, smallest — lowest accuracy',
minRamGb: 2,
- files: [{ name: 'ggml-tiny.bin', url: resolve('ggerganov/whisper.cpp', 'ggml-tiny.bin'), role: 'primary', sizeBytes: 77700000 }],
+ files: [
+ {
+ name: 'ggml-tiny.bin',
+ url: resolve('ggerganov/whisper.cpp', 'ggml-tiny.bin'),
+ role: 'primary',
+ sizeBytes: 77700000
+ }
+ ]
},
{
id: 'ggerganov/whisper.cpp/base',
@@ -529,7 +776,14 @@ export const CATALOG: ModelEntry[] = [
org: 'ggerganov',
description: 'Offline speech-to-text (base) — good speed/quality default',
minRamGb: 3,
- files: [{ name: 'ggml-base.bin', url: resolve('ggerganov/whisper.cpp', 'ggml-base.bin'), role: 'primary', sizeBytes: 147951000 }],
+ files: [
+ {
+ name: 'ggml-base.bin',
+ url: resolve('ggerganov/whisper.cpp', 'ggml-base.bin'),
+ role: 'primary',
+ sizeBytes: 147951000
+ }
+ ]
},
{
id: 'ggerganov/whisper.cpp/small',
@@ -538,7 +792,14 @@ export const CATALOG: ModelEntry[] = [
org: 'ggerganov',
description: 'Offline speech-to-text (higher accuracy)',
minRamGb: 4,
- files: [{ name: 'ggml-small.bin', url: resolve('ggerganov/whisper.cpp', 'ggml-small.bin'), role: 'primary', sizeBytes: 487601000 }],
+ files: [
+ {
+ name: 'ggml-small.bin',
+ url: resolve('ggerganov/whisper.cpp', 'ggml-small.bin'),
+ role: 'primary',
+ sizeBytes: 487601000
+ }
+ ]
},
{
id: 'ggerganov/whisper.cpp/medium',
@@ -547,7 +808,14 @@ export const CATALOG: ModelEntry[] = [
org: 'ggerganov',
description: 'High accuracy; slower',
minRamGb: 6,
- files: [{ name: 'ggml-medium.bin', url: resolve('ggerganov/whisper.cpp', 'ggml-medium.bin'), role: 'primary', sizeBytes: 1533000000 }],
+ files: [
+ {
+ name: 'ggml-medium.bin',
+ url: resolve('ggerganov/whisper.cpp', 'ggml-medium.bin'),
+ role: 'primary',
+ sizeBytes: 1533000000
+ }
+ ]
},
{
id: 'ggerganov/whisper.cpp/large-v3-turbo',
@@ -556,7 +824,14 @@ export const CATALOG: ModelEntry[] = [
org: 'ggerganov',
description: 'Near-large accuracy, much faster — recommended',
minRamGb: 6,
- files: [{ name: 'ggml-large-v3-turbo.bin', url: resolve('ggerganov/whisper.cpp', 'ggml-large-v3-turbo.bin'), role: 'primary', sizeBytes: 1624000000 }],
+ files: [
+ {
+ name: 'ggml-large-v3-turbo.bin',
+ url: resolve('ggerganov/whisper.cpp', 'ggml-large-v3-turbo.bin'),
+ role: 'primary',
+ sizeBytes: 1624000000
+ }
+ ]
},
{
id: 'ggerganov/whisper.cpp/large-v3',
@@ -565,7 +840,14 @@ export const CATALOG: ModelEntry[] = [
org: 'ggerganov',
description: 'Highest accuracy (large); needs more RAM',
minRamGb: 8,
- files: [{ name: 'ggml-large-v3.bin', url: resolve('ggerganov/whisper.cpp', 'ggml-large-v3.bin'), role: 'primary', sizeBytes: 3095000000 }],
+ files: [
+ {
+ name: 'ggml-large-v3.bin',
+ url: resolve('ggerganov/whisper.cpp', 'ggml-large-v3.bin'),
+ role: 'primary',
+ sizeBytes: 3095000000
+ }
+ ]
},
// --- transcription (Parakeet, NVIDIA NeMo) — sherpa-onnx offline transducer (ONNX).
// A model is 4 files (encoder/decoder/joiner/tokens); on-disk names are slug-prefixed
@@ -581,11 +863,31 @@ export const CATALOG: ModelEntry[] = [
minRamGb: 4,
tags: ['Accurate', 'English'],
files: [
- { name: 'parakeet-v2.encoder.int8.onnx', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'encoder.int8.onnx'), role: 'primary', sizeBytes: 652000000 },
- { name: 'parakeet-v2.decoder.int8.onnx', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'decoder.int8.onnx'), role: 'aux', sizeBytes: 7260000 },
- { name: 'parakeet-v2.joiner.int8.onnx', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'joiner.int8.onnx'), role: 'aux', sizeBytes: 1740000 },
- { name: 'parakeet-v2.tokens.txt', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'tokens.txt'), role: 'tokenizer', sizeBytes: 9600 },
- ],
+ {
+ name: 'parakeet-v2.encoder.int8.onnx',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'encoder.int8.onnx'),
+ role: 'primary',
+ sizeBytes: 652000000
+ },
+ {
+ name: 'parakeet-v2.decoder.int8.onnx',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'decoder.int8.onnx'),
+ role: 'aux',
+ sizeBytes: 7260000
+ },
+ {
+ name: 'parakeet-v2.joiner.int8.onnx',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'joiner.int8.onnx'),
+ role: 'aux',
+ sizeBytes: 1740000
+ },
+ {
+ name: 'parakeet-v2.tokens.txt',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8', 'tokens.txt'),
+ role: 'tokenizer',
+ sizeBytes: 9600
+ }
+ ]
},
{
id: 'csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8',
@@ -598,16 +900,36 @@ export const CATALOG: ModelEntry[] = [
isNew: true,
tags: ['Accurate', 'Multilingual'],
files: [
- { name: 'parakeet-v3.encoder.int8.onnx', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'encoder.int8.onnx'), role: 'primary', sizeBytes: 652000000 },
- { name: 'parakeet-v3.decoder.int8.onnx', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'decoder.int8.onnx'), role: 'aux', sizeBytes: 7260000 },
- { name: 'parakeet-v3.joiner.int8.onnx', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'joiner.int8.onnx'), role: 'aux', sizeBytes: 1740000 },
- { name: 'parakeet-v3.tokens.txt', url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'tokens.txt'), role: 'tokenizer', sizeBytes: 9600 },
- ],
- },
-];
+ {
+ name: 'parakeet-v3.encoder.int8.onnx',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'encoder.int8.onnx'),
+ role: 'primary',
+ sizeBytes: 652000000
+ },
+ {
+ name: 'parakeet-v3.decoder.int8.onnx',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'decoder.int8.onnx'),
+ role: 'aux',
+ sizeBytes: 7260000
+ },
+ {
+ name: 'parakeet-v3.joiner.int8.onnx',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'joiner.int8.onnx'),
+ role: 'aux',
+ sizeBytes: 1740000
+ },
+ {
+ name: 'parakeet-v3.tokens.txt',
+ url: resolve('csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8', 'tokens.txt'),
+ role: 'tokenizer',
+ sizeBytes: 9600
+ }
+ ]
+ }
+]
export function modelsByKind(kind: ModelKind): ModelEntry[] {
- return CATALOG.filter((m) => m.kind === kind);
+ return CATALOG.filter((m) => m.kind === kind)
}
-export const MODEL_KINDS: ModelKind[] = ['text', 'vision', 'image', 'voice', 'transcription'];
+export const MODEL_KINDS: ModelKind[] = ['text', 'vision', 'image', 'voice', 'transcription']
diff --git a/packages/models/src/credibility.ts b/packages/models/src/credibility.ts
index 36ec894d..abfdd757 100644
--- a/packages/models/src/credibility.ts
+++ b/packages/models/src/credibility.ts
@@ -2,10 +2,10 @@
// Verified quantizer / Community, with labels + colors for badges. Shared so
// desktop and mobile rank sources identically.
-export type Credibility = 'offgrid' | 'official' | 'verified-quantizer' | 'community';
+export type Credibility = 'offgrid' | 'official' | 'verified-quantizer' | 'community'
// HF authors published/curated by Off Grid (our own converted + verified models).
-export const OFFGRID_AUTHORS = ['offgrid-ai', 'offgrid'];
+export const OFFGRID_AUTHORS = ['offgrid-ai', 'offgrid']
export const OFFICIAL_MODEL_AUTHORS: Record = {
'meta-llama': 'Meta',
@@ -28,8 +28,8 @@ export const OFFICIAL_MODEL_AUTHORS: Record = {
CohereForAI: 'Cohere',
allenai: 'Allen AI',
nvidia: 'NVIDIA',
- apple: 'Apple',
-};
+ apple: 'Apple'
+}
export const VERIFIED_QUANTIZERS: Record = {
TheBloke: 'TheBloke',
@@ -44,20 +44,31 @@ export const VERIFIED_QUANTIZERS: Record = {
ggerganov: 'Georgi Gerganov',
// Strong community quantizers (formerly badged separately) — trusted GGUFs.
'lmstudio-community': 'Community GGUF',
- 'lmstudio-ai': 'Community GGUF',
-};
+ 'lmstudio-ai': 'Community GGUF'
+}
-export const CREDIBILITY_LABELS: Record = {
- offgrid: { label: 'Off Grid', description: 'Curated & converted by Off Grid — verified to run on-device', color: '#34D399' },
+export const CREDIBILITY_LABELS: Record<
+ Credibility,
+ { label: string; description: string; color: string }
+> = {
+ offgrid: {
+ label: 'Off Grid',
+ description: 'Curated & converted by Off Grid — verified to run on-device',
+ color: '#34D399'
+ },
official: { label: 'Official', description: 'From the original model creator', color: '#22C55E' },
- 'verified-quantizer': { label: 'Verified', description: 'From a trusted quantization provider', color: '#A78BFA' },
- community: { label: 'Community', description: 'Community contributed model', color: '#64748B' },
-};
+ 'verified-quantizer': {
+ label: 'Verified',
+ description: 'From a trusted quantization provider',
+ color: '#A78BFA'
+ },
+ community: { label: 'Community', description: 'Community contributed model', color: '#64748B' }
+}
/** Classify a HF author into a credibility tier. */
export function determineCredibility(author: string): Credibility {
- if (OFFGRID_AUTHORS.includes(author)) return 'offgrid';
- if (author in OFFICIAL_MODEL_AUTHORS) return 'official';
- if (author in VERIFIED_QUANTIZERS) return 'verified-quantizer';
- return 'community';
+ if (OFFGRID_AUTHORS.includes(author)) return 'offgrid'
+ if (author in OFFICIAL_MODEL_AUTHORS) return 'official'
+ if (author in VERIFIED_QUANTIZERS) return 'verified-quantizer'
+ return 'community'
}
diff --git a/packages/models/src/download.ts b/packages/models/src/download.ts
index 9e3e5f92..f7b10fc6 100644
--- a/packages/models/src/download.ts
+++ b/packages/models/src/download.ts
@@ -2,11 +2,11 @@
// DownloadBridge, with aggregate progress, cancel, and install tracking via a
// ModelStore. Platform-agnostic; the bridge does the actual file IO.
-import type { DownloadBridge, DownloadProgress, ModelEntry, ModelStore } from './types';
+import type { DownloadBridge, DownloadProgress, ModelEntry, ModelStore } from './types'
export class ModelDownloader {
- private aborts = new Map();
- private listeners = new Set<(p: DownloadProgress) => void>();
+ private aborts = new Map()
+ private listeners = new Set<(p: DownloadProgress) => void>()
constructor(
private readonly bridge: DownloadBridge,
@@ -14,75 +14,75 @@ export class ModelDownloader {
) {}
onProgress(cb: (p: DownloadProgress) => void): () => void {
- this.listeners.add(cb);
- return () => this.listeners.delete(cb);
+ this.listeners.add(cb)
+ return () => this.listeners.delete(cb)
}
isInstalled(modelId: string): boolean {
- return this.store.isInstalled(modelId);
+ return this.store.isInstalled(modelId)
}
cancel(modelId: string): void {
- this.aborts.get(modelId)?.abort();
+ this.aborts.get(modelId)?.abort()
}
private emit(p: DownloadProgress): void {
- for (const l of this.listeners) l(p);
+ for (const l of this.listeners) l(p)
}
async download(entry: ModelEntry): Promise {
- const controller = new AbortController();
- this.aborts.set(entry.id, controller);
- const totalKnown = entry.files.reduce((n, f) => n + (f.sizeBytes ?? 0), 0);
- let basePrev = 0;
+ const controller = new AbortController()
+ this.aborts.set(entry.id, controller)
+ const totalKnown = entry.files.reduce((n, f) => n + (f.sizeBytes ?? 0), 0)
+ let basePrev = 0
try {
for (const file of entry.files) {
- const dest = this.bridge.pathFor(file.name);
+ const dest = this.bridge.pathFor(file.name)
if (await this.bridge.exists(dest, file.sizeBytes)) {
- basePrev += file.sizeBytes ?? 0;
- continue;
+ basePrev += file.sizeBytes ?? 0
+ continue
}
await this.bridge.download(file.url, dest, {
signal: controller.signal,
onProgress: (written, total) => {
- const totalBytes = totalKnown || basePrev + total;
- const bytesDownloaded = basePrev + written;
+ const totalBytes = totalKnown || basePrev + total
+ const bytesDownloaded = basePrev + written
this.emit({
modelId: entry.id,
status: 'downloading',
bytesDownloaded,
totalBytes,
progress: totalBytes ? Math.min(1, bytesDownloaded / totalBytes) : 0,
- currentFile: file.name,
- });
- },
- });
- basePrev += file.sizeBytes ?? 0;
+ currentFile: file.name
+ })
+ }
+ })
+ basePrev += file.sizeBytes ?? 0
}
- this.store.markInstalled(entry);
+ this.store.markInstalled(entry)
this.emit({
modelId: entry.id,
status: 'completed',
progress: 1,
bytesDownloaded: totalKnown,
- totalBytes: totalKnown,
- });
- return true;
+ totalBytes: totalKnown
+ })
+ return true
} catch (err) {
- const aborted = controller.signal.aborted;
+ const aborted = controller.signal.aborted
this.emit({
modelId: entry.id,
status: aborted ? 'paused' : 'failed',
progress: 0,
bytesDownloaded: 0,
totalBytes: totalKnown,
- error: aborted ? undefined : err instanceof Error ? err.message : String(err),
- });
- return false;
+ error: aborted ? undefined : err instanceof Error ? err.message : String(err)
+ })
+ return false
} finally {
- this.aborts.delete(entry.id);
+ this.aborts.delete(entry.id)
}
}
}
diff --git a/packages/models/src/filters.ts b/packages/models/src/filters.ts
index 35f51047..0810a5d6 100644
--- a/packages/models/src/filters.ts
+++ b/packages/models/src/filters.ts
@@ -3,20 +3,20 @@
// / downloads / size / recency). Pure logic over a normalized FilterableModel;
// the UI renders the option lists and feeds the FilterState.
-import type { Credibility } from './credibility';
+import type { Credibility } from './credibility'
-export type ModelTypeFilter = 'all' | 'text' | 'vision' | 'code' | 'image-gen';
-export type CredibilityFilter = 'all' | Credibility;
-export type SizeFilter = 'all' | 'tiny' | 'small' | 'medium' | 'large';
-export type SortOption = 'recommended' | 'bestfit' | 'size' | 'downloads' | 'recency';
+export type ModelTypeFilter = 'all' | 'text' | 'vision' | 'code' | 'image-gen'
+export type CredibilityFilter = 'all' | Credibility
+export type SizeFilter = 'all' | 'tiny' | 'small' | 'medium' | 'large'
+export type SortOption = 'recommended' | 'bestfit' | 'size' | 'downloads' | 'recency'
export interface FilterState {
- orgs: string[];
- type: ModelTypeFilter;
- source: CredibilityFilter;
- size: SizeFilter;
- quant: string; // 'all' or a quant label
- sort: SortOption;
+ orgs: string[]
+ type: ModelTypeFilter
+ source: CredibilityFilter
+ size: SizeFilter
+ quant: string // 'all' or a quant label
+ sort: SortOption
}
export const initialFilterState: FilterState = {
@@ -25,135 +25,161 @@ export const initialFilterState: FilterState = {
source: 'all',
size: 'all',
quant: 'all',
- sort: 'recommended',
-};
+ sort: 'recommended'
+}
/** Normalized model the filters/sorts operate on (map HF results into this). */
export interface FilterableModel {
- id: string;
- name: string;
- org: string;
- credibility?: Credibility;
- params?: number | null;
- tags?: string[];
- downloads?: number;
- likes?: number;
- lastModified?: string;
- minRamGb?: number;
- files?: { sizeBytes?: number; quant?: string }[];
+ id: string
+ name: string
+ org: string
+ credibility?: Credibility
+ params?: number | null
+ tags?: string[]
+ downloads?: number
+ likes?: number
+ lastModified?: string
+ minRamGb?: number
+ files?: { sizeBytes?: number; quant?: string }[]
}
export const SIZE_OPTIONS = [
{ key: 'tiny', label: 'Tiny (<2B)', min: 0, max: 2 },
{ key: 'small', label: 'Small (2-5B)', min: 2, max: 5 },
{ key: 'medium', label: 'Medium (5-15B)', min: 5, max: 15 },
- { key: 'large', label: 'Large (15B+)', min: 15, max: Infinity },
-] as const;
+ { key: 'large', label: 'Large (15B+)', min: 15, max: Infinity }
+] as const
export const MODEL_TYPE_OPTIONS = [
{ key: 'text', label: 'Text' },
{ key: 'vision', label: 'Vision' },
{ key: 'code', label: 'Code' },
- { key: 'image-gen', label: 'Image' },
-] as const;
+ { key: 'image-gen', label: 'Image' }
+] as const
export const CREDIBILITY_OPTIONS = [
{ key: 'offgrid', label: 'Off Grid' },
{ key: 'official', label: 'Official' },
{ key: 'verified-quantizer', label: 'Verified' },
- { key: 'community', label: 'Community' },
-] as const;
+ { key: 'community', label: 'Community' }
+] as const
export const SORT_OPTIONS = [
{ key: 'recommended', label: 'Recommended' },
{ key: 'bestfit', label: 'Best fit' },
{ key: 'downloads', label: 'Downloads' },
{ key: 'size', label: 'Size' },
- { key: 'recency', label: 'Recent' },
-] as const;
+ { key: 'recency', label: 'Recent' }
+] as const
// Matches a parameter count with a B (billions) or M (millions) unit, e.g.
// "2.2B", "500M", "256m". M is normalized to billions (500M -> 0.5).
-const PARAM_RE = /\b(\d+(?:\.\d+)?)\s?([BbMm])\b/;
+const PARAM_RE = /\b(\d+(?:\.\d+)?)\s?([BbMm])\b/
/** Parse a billions-of-parameters count from a model name/id, in billions
* ("Qwen3.5-2B" -> 2, "SmolVLM2-500M" -> 0.5). Returns null if none found. */
export function parseParamCount(nameOrId: string): number | null {
- const m = PARAM_RE.exec(nameOrId);
- if (!m) return null;
- const n = Number.parseFloat(m[1]);
- return /[Mm]/.test(m[2]) ? n / 1000 : n;
+ const m = PARAM_RE.exec(nameOrId)
+ if (!m) return null
+ const n = Number.parseFloat(m[1])
+ return /[Mm]/.test(m[2]) ? n / 1000 : n
}
/** Detect a model's type from its name + tags. */
export function getModelType(name: string, tags: string[] = []): ModelTypeFilter {
- const n = name.toLowerCase();
- const t = tags.map((x) => x.toLowerCase());
+ const n = name.toLowerCase()
+ const t = tags.map((x) => x.toLowerCase())
if (
- t.some((x) => x.includes('diffusion') || x.includes('text-to-image') || x.includes('image-generation') || x.includes('diffusers')) ||
- n.includes('stable-diffusion') || n.includes('sd-') || n.includes('sdxl') || n.includes('flux')
+ t.some(
+ (x) =>
+ x.includes('diffusion') ||
+ x.includes('text-to-image') ||
+ x.includes('image-generation') ||
+ x.includes('diffusers')
+ ) ||
+ n.includes('stable-diffusion') ||
+ n.includes('sd-') ||
+ n.includes('sdxl') ||
+ n.includes('flux')
)
- return 'image-gen';
+ return 'image-gen'
if (
t.some((x) => x.includes('vision') || x.includes('multimodal') || x.includes('image-text')) ||
- n.includes('vision') || n.includes('vlm') || n.includes('-vl') || n.includes('llava')
+ n.includes('vision') ||
+ n.includes('vlm') ||
+ n.includes('-vl') ||
+ n.includes('llava')
)
- return 'vision';
- if (t.some((x) => x.includes('code')) || n.includes('code') || n.includes('coder')) return 'code';
- return 'text';
+ return 'vision'
+ if (t.some((x) => x.includes('code')) || n.includes('code') || n.includes('coder')) return 'code'
+ return 'text'
}
/** Lower is better. Ideal model uses ~40% of RAM; penalize >75% (too slow). */
export function bestFitScore(m: FilterableModel, ramGb: number): number {
- const params = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id) ?? 0;
- const minRam = m.minRamGb ?? params * 0.75;
- const ratio = ramGb ? minRam / ramGb : 0;
- const penalty = ratio > 0.75 ? (ratio - 0.75) * 4 : 0;
- return Math.abs(ratio - 0.4) + penalty;
+ const params = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id) ?? 0
+ const minRam = m.minRamGb ?? params * 0.75
+ const ratio = ramGb ? minRam / ramGb : 0
+ const penalty = ratio > 0.75 ? (ratio - 0.75) * 4 : 0
+ return Math.abs(ratio - 0.4) + penalty
}
export function hasActiveFilters(state: FilterState): boolean {
- return state.orgs.length > 0 || state.type !== 'all' || state.source !== 'all' || state.size !== 'all' || state.quant !== 'all';
+ return (
+ state.orgs.length > 0 ||
+ state.type !== 'all' ||
+ state.source !== 'all' ||
+ state.size !== 'all' ||
+ state.quant !== 'all'
+ )
}
export function applyFilters(models: T[], state: FilterState): T[] {
return models.filter((m) => {
- if (state.source !== 'all' && m.credibility !== state.source) return false;
- if (state.type !== 'all' && getModelType(m.name, m.tags) !== state.type) return false;
- if (state.orgs.length > 0 && !state.orgs.includes(m.org)) return false;
+ if (state.source !== 'all' && m.credibility !== state.source) return false
+ if (state.type !== 'all' && getModelType(m.name, m.tags) !== state.type) return false
+ if (state.orgs.length > 0 && !state.orgs.includes(m.org)) return false
if (state.size !== 'all') {
- const p = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id);
- const opt = SIZE_OPTIONS.find((s) => s.key === state.size);
+ const p = m.params ?? parseParamCount(m.name) ?? parseParamCount(m.id)
+ const opt = SIZE_OPTIONS.find((s) => s.key === state.size)
// Exclude when out of range — and when the size is unknowable, since the
// user explicitly asked for a size band (don't leak unsized models in).
- if (opt && (p == null || p < opt.min || p >= opt.max)) return false;
+ if (opt && (p == null || p < opt.min || p >= opt.max)) return false
}
if (state.quant !== 'all' && m.files && m.files.length > 0) {
- if (!m.files.some((f) => f.quant === state.quant)) return false;
+ if (!m.files.some((f) => f.quant === state.quant)) return false
}
- return true;
- });
+ return true
+ })
}
-export function applySort(models: T[], sort: SortOption, ramGb = 0): T[] {
- if (sort === 'recommended') return models;
- const arr = [...models];
- const p = (m: FilterableModel) => m.params ?? parseParamCount(m.name) ?? 0;
+export function applySort(
+ models: T[],
+ sort: SortOption,
+ ramGb = 0
+): T[] {
+ if (sort === 'recommended') return models
+ const arr = [...models]
+ const p = (m: FilterableModel) => m.params ?? parseParamCount(m.name) ?? 0
switch (sort) {
case 'bestfit':
- return arr.sort((a, b) => bestFitScore(a, ramGb) - bestFitScore(b, ramGb));
+ return arr.sort((a, b) => bestFitScore(a, ramGb) - bestFitScore(b, ramGb))
case 'size':
- return arr.sort((a, b) => p(a) - p(b));
+ return arr.sort((a, b) => p(a) - p(b))
case 'downloads':
- return arr.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0));
+ return arr.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
case 'recency':
- return arr.sort((a, b) => (b.lastModified ?? '').localeCompare(a.lastModified ?? ''));
+ return arr.sort((a, b) => (b.lastModified ?? '').localeCompare(a.lastModified ?? ''))
default:
- return arr;
+ return arr
}
}
/** Apply filters then sort in one pass. */
-export function filterAndSort(models: T[], state: FilterState, ramGb = 0): T[] {
- return applySort(applyFilters(models, state), state.sort, ramGb);
+export function filterAndSort(
+ models: T[],
+ state: FilterState,
+ ramGb = 0
+): T[] {
+ return applySort(applyFilters(models, state), state.sort, ramGb)
}
diff --git a/packages/models/src/hf.ts b/packages/models/src/hf.ts
index 36a642d3..8be73655 100644
--- a/packages/models/src/hf.ts
+++ b/packages/models/src/hf.ts
@@ -2,10 +2,10 @@
// downloadable ModelEntry (pick a Q4_K_M weight + matching mmproj for vision).
// fetch is injectable so this is unit-testable without the network.
-import type { ModelEntry, ModelFile, ModelKind } from './types';
-import { QUANTIZATION_INFO, extractQuantization, isMMProjFile } from './quant';
-import { determineCredibility, type Credibility } from './credibility';
-import { getModelType } from './filters';
+import type { ModelEntry, ModelFile, ModelKind } from './types'
+import { QUANTIZATION_INFO, extractQuantization, isMMProjFile } from './quant'
+import { determineCredibility, type Credibility } from './credibility'
+import { getModelType } from './filters'
// HF pipeline_tag per Off Grid modality — so a tab's search only returns models
// of that kind (text-gen, VLM, diffusion, ASR, TTS) instead of everything.
@@ -14,56 +14,59 @@ const KIND_PIPELINE: Record = {
vision: 'image-text-to-text',
image: 'text-to-image',
voice: 'text-to-speech',
- transcription: 'automatic-speech-recognition',
-};
+ transcription: 'automatic-speech-recognition'
+}
// Runtimes that consume GGUF (llama.cpp text/vision, sd.cpp image). Whisper (STT)
// is ggml .bin and Kokoro (TTS) is onnx, so we don't constrain those to gguf.
-const GGUF_KINDS: ReadonlySet = new Set(['text', 'vision', 'image']);
+const GGUF_KINDS: ReadonlySet = new Set(['text', 'vision', 'image'])
-const HF = 'https://huggingface.co';
-const HF_API = 'https://huggingface.co/api';
+const HF = 'https://huggingface.co'
+const HF_API = 'https://huggingface.co/api'
-type FetchLike = (url: string, init?: { headers?: Record }) => Promise<{
- ok: boolean;
- status: number;
- json: () => Promise;
-}>;
+type FetchLike = (
+ url: string,
+ init?: { headers?: Record }
+) => Promise<{
+ ok: boolean
+ status: number
+ json: () => Promise
+}>
-const defaultFetch: FetchLike = (url, init) => fetch(url, init) as unknown as ReturnType;
+const defaultFetch: FetchLike = (url, init) => fetch(url, init) as unknown as ReturnType
export interface HFSearchResult {
- id: string;
- name: string;
- org: string;
- downloads?: number;
- likes?: number;
- lastModified?: string;
- credibility: Credibility;
+ id: string
+ name: string
+ org: string
+ downloads?: number
+ likes?: number
+ lastModified?: string
+ credibility: Credibility
}
/** A selectable quantization variant within a HF repo (for the file picker). */
export interface ModelFileVariant {
- fileName: string;
- quant: string;
- quality: string;
- recommended: boolean;
- sizeBytes: number;
- downloadUrl: string;
+ fileName: string
+ quant: string
+ quality: string
+ recommended: boolean
+ sizeBytes: number
+ downloadUrl: string
/** Matched vision projector for this weight, when the repo is multimodal. */
- mmproj?: { fileName: string; url: string; sizeBytes?: number };
+ mmproj?: { fileName: string; url: string; sizeBytes?: number }
}
interface HFModel {
- id?: string;
- modelId?: string;
- downloads?: number;
- likes?: number;
- lastModified?: string;
- siblings?: { rfilename: string; size?: number }[];
+ id?: string
+ modelId?: string
+ downloads?: number
+ likes?: number
+ lastModified?: string
+ siblings?: { rfilename: string; size?: number }[]
}
-const isMmproj = (name: string): boolean => /mmproj|clip/i.test(name);
-const baseName = (p: string): string => p.split('/').pop() ?? p;
+const isMmproj = (name: string): boolean => /mmproj|clip/i.test(name)
+const baseName = (p: string): string => p.split('/').pop() ?? p
/** Search the HF hub for models, scoped to a modality (kind) when given so each
* tab only surfaces models it can actually use. */
@@ -71,35 +74,49 @@ export async function searchHuggingFace(
query: string,
opts: { limit?: number; sort?: string; kind?: ModelKind; fetchImpl?: FetchLike } = {}
): Promise {
- const fetchImpl = opts.fetchImpl ?? defaultFetch;
- const kind = opts.kind;
+ const fetchImpl = opts.fetchImpl ?? defaultFetch
+ const kind = opts.kind
const params = new URLSearchParams({
sort: opts.sort ?? 'downloads',
direction: '-1',
// Over-fetch so post-filtering by detected type still leaves a full page.
- limit: String((opts.limit ?? 30) * 2),
- });
+ limit: String((opts.limit ?? 30) * 2)
+ })
// GGUF kinds (text/vision/image): constrain to gguf and scope by the NAME
// heuristic below — HF's pipeline_tag is unreliable on gguf repos (it tags
// plain text models as image-text-to-text). Non-gguf kinds (ASR/TTS) have no
// name signal, so lean on HF's pipeline_tag, which is accurate for them.
- if (!kind || GGUF_KINDS.has(kind)) params.set('filter', 'gguf');
- else if (kind) params.set('pipeline_tag', KIND_PIPELINE[kind]);
- if (query) params.set('search', query);
- const res = await fetchImpl(`${HF_API}/models?${params.toString()}`, { headers: { Accept: 'application/json' } });
- if (!res.ok) throw new Error(`Hugging Face search failed: HTTP ${res.status}`);
- const data = (await res.json()) as HFModel[];
+ if (!kind || GGUF_KINDS.has(kind)) params.set('filter', 'gguf')
+ else if (kind) params.set('pipeline_tag', KIND_PIPELINE[kind])
+ if (query) params.set('search', query)
+ const res = await fetchImpl(`${HF_API}/models?${params.toString()}`, {
+ headers: { Accept: 'application/json' }
+ })
+ if (!res.ok) throw new Error(`Hugging Face search failed: HTTP ${res.status}`)
+ const data = (await res.json()) as HFModel[]
let out = data.map((m) => {
- const id = m.id ?? m.modelId ?? '';
- const org = id.split('/')[0] ?? '';
- return { id, name: baseName(id), org, downloads: m.downloads, likes: m.likes, lastModified: m.lastModified, credibility: determineCredibility(org) };
- });
+ const id = m.id ?? m.modelId ?? ''
+ const org = id.split('/')[0] ?? ''
+ return {
+ id,
+ name: baseName(id),
+ org,
+ downloads: m.downloads,
+ likes: m.likes,
+ lastModified: m.lastModified,
+ credibility: determineCredibility(org)
+ }
+ })
// HF's pipeline tags are inconsistent for gguf repos, so refine text/vision/image
// by the name heuristic too (e.g. keep VLMs off the Text tab and vice versa).
- if (kind === 'text') out = out.filter((m) => { const t = getModelType(m.name); return t === 'text' || t === 'code'; });
- else if (kind === 'vision') out = out.filter((m) => getModelType(m.name) === 'vision');
- else if (kind === 'image') out = out.filter((m) => getModelType(m.name) === 'image-gen');
- return out.slice(0, opts.limit ?? 30);
+ if (kind === 'text')
+ out = out.filter((m) => {
+ const t = getModelType(m.name)
+ return t === 'text' || t === 'code'
+ })
+ else if (kind === 'vision') out = out.filter((m) => getModelType(m.name) === 'vision')
+ else if (kind === 'image') out = out.filter((m) => getModelType(m.name) === 'image-gen')
+ return out.slice(0, opts.limit ?? 30)
}
/** List a repo's GGUF quantization variants (with matched mmproj), for a file
@@ -108,31 +125,36 @@ export async function getModelFiles(
repoId: string,
opts: { fetchImpl?: FetchLike } = {}
): Promise {
- const fetchImpl = opts.fetchImpl ?? defaultFetch;
- const res = await fetchImpl(`${HF_API}/models/${repoId}`, { headers: { Accept: 'application/json' } });
- if (!res.ok) return [];
- const data = (await res.json()) as HFModel;
- const gguf = (data.siblings ?? []).filter((f) => f.rfilename.endsWith('.gguf'));
- const mmprojFiles = gguf.filter((f) => isMMProjFile(f.rfilename));
- const weights = gguf.filter((f) => !isMMProjFile(f.rfilename));
- const url = (rf: string): string => `${HF}/${repoId}/resolve/main/${rf}`;
+ const fetchImpl = opts.fetchImpl ?? defaultFetch
+ const res = await fetchImpl(`${HF_API}/models/${repoId}`, {
+ headers: { Accept: 'application/json' }
+ })
+ if (!res.ok) return []
+ const data = (await res.json()) as HFModel
+ const gguf = (data.siblings ?? []).filter((f) => f.rfilename.endsWith('.gguf'))
+ const mmprojFiles = gguf.filter((f) => isMMProjFile(f.rfilename))
+ const weights = gguf.filter((f) => !isMMProjFile(f.rfilename))
+ const url = (rf: string): string => `${HF}/${repoId}/resolve/main/${rf}`
const matchMmproj = (weightName: string): ModelFileVariant['mmproj'] | undefined => {
- if (mmprojFiles.length === 0) return undefined;
- const wq = extractQuantization(weightName);
- const exact = wq !== 'Unknown' ? mmprojFiles.find((f) => extractQuantization(f.rfilename) === wq) : undefined;
+ if (mmprojFiles.length === 0) return undefined
+ const wq = extractQuantization(weightName)
+ const exact =
+ wq !== 'Unknown'
+ ? mmprojFiles.find((f) => extractQuantization(f.rfilename) === wq)
+ : undefined
const f16 = mmprojFiles.find((f) => {
- const l = f.rfilename.toLowerCase();
- return (l.includes('f16') || l.includes('fp16')) && !l.includes('bf16');
- });
- const pick = exact ?? f16 ?? mmprojFiles[0];
- return { fileName: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size };
- };
+ const l = f.rfilename.toLowerCase()
+ return (l.includes('f16') || l.includes('fp16')) && !l.includes('bf16')
+ })
+ const pick = exact ?? f16 ?? mmprojFiles[0]
+ return { fileName: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size }
+ }
return weights
.map((f) => {
- const quant = extractQuantization(f.rfilename);
- const info = QUANTIZATION_INFO[quant];
+ const quant = extractQuantization(f.rfilename)
+ const info = QUANTIZATION_INFO[quant]
return {
fileName: baseName(f.rfilename),
quant,
@@ -140,10 +162,10 @@ export async function getModelFiles(
recommended: info?.recommended ?? false,
sizeBytes: f.size ?? 0,
downloadUrl: url(f.rfilename),
- mmproj: matchMmproj(f.rfilename),
- };
+ mmproj: matchMmproj(f.rfilename)
+ }
})
- .sort((a, b) => Number(b.recommended) - Number(a.recommended) || a.sizeBytes - b.sizeBytes);
+ .sort((a, b) => Number(b.recommended) - Number(a.recommended) || a.sizeBytes - b.sizeBytes)
}
/**
@@ -155,50 +177,86 @@ export async function resolveHuggingFaceModel(
repoId: string,
opts: { kind?: ModelKind; fetchImpl?: FetchLike } = {}
): Promise {
- const fetchImpl = opts.fetchImpl ?? defaultFetch;
- const res = await fetchImpl(`${HF_API}/models/${repoId}`, { headers: { Accept: 'application/json' } });
- if (!res.ok) return null;
- const data = (await res.json()) as HFModel;
- const siblings = data.siblings ?? [];
- const url = (rf: string): string => `${HF}/${repoId}/resolve/main/${rf}`;
- const org = repoId.split('/')[0];
+ const fetchImpl = opts.fetchImpl ?? defaultFetch
+ const res = await fetchImpl(`${HF_API}/models/${repoId}`, {
+ headers: { Accept: 'application/json' }
+ })
+ if (!res.ok) return null
+ const data = (await res.json()) as HFModel
+ const siblings = data.siblings ?? []
+ const url = (rf: string): string => `${HF}/${repoId}/resolve/main/${rf}`
+ const org = repoId.split('/')[0]
// Non-GGUF runtimes our pipeline already supports: whisper transcription reads
// ggml `.bin`; Kokoro/Piper TTS read `.onnx`. Detect these first so a search →
// download works for them, not just llama.cpp GGUF models.
- const ggml = siblings.filter((f) => /ggml.*\.bin$/i.test(f.rfilename));
+ const ggml = siblings.filter((f) => /ggml.*\.bin$/i.test(f.rfilename))
if (ggml.length > 0) {
// Prefer the multilingual base (good speed/quality default), else smallest.
- const pick = ggml.find((f) => /ggml-base\.bin$/i.test(f.rfilename))
- ?? [...ggml].sort((a, b) => (a.size ?? 0) - (b.size ?? 0))[0];
+ const pick =
+ ggml.find((f) => /ggml-base\.bin$/i.test(f.rfilename)) ??
+ [...ggml].sort((a, b) => (a.size ?? 0) - (b.size ?? 0))[0]
return {
- id: repoId, name: baseName(repoId), kind: 'transcription', org,
- files: [{ name: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size, role: 'primary' }],
- };
+ id: repoId,
+ name: baseName(repoId),
+ kind: 'transcription',
+ org,
+ files: [
+ {
+ name: baseName(pick.rfilename),
+ url: url(pick.rfilename),
+ sizeBytes: pick.size,
+ role: 'primary'
+ }
+ ]
+ }
}
- const onnx = siblings.filter((f) => /\.onnx$/i.test(f.rfilename));
+ const onnx = siblings.filter((f) => /\.onnx$/i.test(f.rfilename))
if (onnx.length > 0 && siblings.every((f) => !f.rfilename.endsWith('.gguf'))) {
- const pick = onnx.find((f) => /quant/i.test(f.rfilename)) ?? onnx[0];
- const files: ModelFile[] = [{ name: baseName(pick.rfilename), url: url(pick.rfilename), sizeBytes: pick.size, role: 'primary' }];
+ const pick = onnx.find((f) => /quant/i.test(f.rfilename)) ?? onnx[0]
+ const files: ModelFile[] = [
+ {
+ name: baseName(pick.rfilename),
+ url: url(pick.rfilename),
+ sizeBytes: pick.size,
+ role: 'primary'
+ }
+ ]
// Piper voices ship a sidecar .onnx.json the runtime needs.
- const cfg = siblings.find((f) => f.rfilename === `${pick.rfilename}.json`);
- if (cfg) files.push({ name: baseName(cfg.rfilename), url: url(cfg.rfilename), sizeBytes: cfg.size, role: 'aux' });
- return { id: repoId, name: baseName(repoId), kind: 'voice', org, files };
+ const cfg = siblings.find((f) => f.rfilename === `${pick.rfilename}.json`)
+ if (cfg)
+ files.push({
+ name: baseName(cfg.rfilename),
+ url: url(cfg.rfilename),
+ sizeBytes: cfg.size,
+ role: 'aux'
+ })
+ return { id: repoId, name: baseName(repoId), kind: 'voice', org, files }
}
- const gguf = siblings.filter((f) => f.rfilename.endsWith('.gguf'));
- if (gguf.length === 0) return null;
+ const gguf = siblings.filter((f) => f.rfilename.endsWith('.gguf'))
+ if (gguf.length === 0) return null
- const weights = gguf.filter((f) => !isMmproj(f.rfilename));
- const mmprojFiles = gguf.filter((f) => isMmproj(f.rfilename));
- const primary = weights.find((f) => /q4_k_m/i.test(f.rfilename)) ?? weights[0] ?? gguf[0];
- if (!primary) return null;
+ const weights = gguf.filter((f) => !isMmproj(f.rfilename))
+ const mmprojFiles = gguf.filter((f) => isMmproj(f.rfilename))
+ const primary = weights.find((f) => /q4_k_m/i.test(f.rfilename)) ?? weights[0] ?? gguf[0]
+ if (!primary) return null
const files: ModelFile[] = [
- { name: baseName(primary.rfilename), url: url(primary.rfilename), sizeBytes: primary.size, role: 'primary' },
- ];
+ {
+ name: baseName(primary.rfilename),
+ url: url(primary.rfilename),
+ sizeBytes: primary.size,
+ role: 'primary'
+ }
+ ]
if (mmprojFiles[0]) {
- files.push({ name: baseName(mmprojFiles[0].rfilename), url: url(mmprojFiles[0].rfilename), sizeBytes: mmprojFiles[0].size, role: 'mmproj' });
+ files.push({
+ name: baseName(mmprojFiles[0].rfilename),
+ url: url(mmprojFiles[0].rfilename),
+ sizeBytes: mmprojFiles[0].size,
+ role: 'mmproj'
+ })
}
return {
@@ -206,6 +264,6 @@ export async function resolveHuggingFaceModel(
name: baseName(repoId),
kind: mmprojFiles.length ? 'vision' : (opts.kind ?? 'text'),
org,
- files,
- };
+ files
+ }
}
diff --git a/packages/models/src/imagegen.ts b/packages/models/src/imagegen.ts
index 0e0b9511..71a13aba 100644
--- a/packages/models/src/imagegen.ts
+++ b/packages/models/src/imagegen.ts
@@ -4,45 +4,48 @@
// defines the shared request/result shape + capability so UI and orchestration
// are identical across platforms.
-export type ImageGenMode = 'txt2img' | 'img2img';
+export type ImageGenMode = 'txt2img' | 'img2img'
export interface ImageGenRequest {
- prompt: string;
- mode: ImageGenMode;
- negativePrompt?: string;
+ prompt: string
+ mode: ImageGenMode
+ negativePrompt?: string
/** Input image for img2img (base64 data URL or local path). */
- initImage?: string;
+ initImage?: string
/** img2img denoising strength, 0..1 (how much to change the input). */
- strength?: number;
- width?: number;
- height?: number;
- steps?: number;
- seed?: number;
- signal?: AbortSignal;
+ strength?: number
+ width?: number
+ height?: number
+ steps?: number
+ seed?: number
+ signal?: AbortSignal
}
export interface ImageGenResult {
/** Output image (base64 data URL or local path). */
- image: string;
- seed?: number;
+ image: string
+ seed?: number
}
/** A platform diffusion runtime. Implemented per-platform, used the same way. */
export interface ImageGenProvider {
- readonly id: string;
+ readonly id: string
/** Modes this provider/model supports (e.g. ['txt2img','img2img']). */
- readonly modes: ImageGenMode[];
- generate(req: ImageGenRequest): Promise;
+ readonly modes: ImageGenMode[]
+ generate(req: ImageGenRequest): Promise
}
export function supportsMode(provider: ImageGenProvider, mode: ImageGenMode): boolean {
- return provider.modes.includes(mode);
+ return provider.modes.includes(mode)
}
/** Validate a request against a provider's capabilities before running it. */
-export function validateImageGenRequest(provider: ImageGenProvider, req: ImageGenRequest): string | null {
- if (!supportsMode(provider, req.mode)) return `provider does not support ${req.mode}`;
- if (req.mode === 'img2img' && !req.initImage) return 'img2img requires an initImage';
- if (!req.prompt.trim()) return 'prompt is required';
- return null;
+export function validateImageGenRequest(
+ provider: ImageGenProvider,
+ req: ImageGenRequest
+): string | null {
+ if (!supportsMode(provider, req.mode)) return `provider does not support ${req.mode}`
+ if (req.mode === 'img2img' && !req.initImage) return 'img2img requires an initImage'
+ if (!req.prompt.trim()) return 'prompt is required'
+ return null
}
diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts
index 9eb5f1d2..e9a00d82 100644
--- a/packages/models/src/index.ts
+++ b/packages/models/src/index.ts
@@ -3,29 +3,29 @@
// manager (text, vision, image, voice, transcription; more soon). The actual
// file IO is a platform DownloadBridge (see ./node for desktop/Electron).
-export * from './types';
+export * from './types'
export {
CATALOG,
MODEL_KINDS,
RECOMMENDATION_TIERS,
recommendForRam,
- modelsByKind,
-} from './catalog';
-export { ModelDownloader } from './download';
-export { searchHuggingFace, resolveHuggingFaceModel, getModelFiles } from './hf';
-export type { HFSearchResult, ModelFileVariant } from './hf';
-export { QUANTIZATION_INFO, extractQuantization, isMMProjFile, formatFileSize } from './quant';
-export type { QuantInfo } from './quant';
+ modelsByKind
+} from './catalog'
+export { ModelDownloader } from './download'
+export { searchHuggingFace, resolveHuggingFaceModel, getModelFiles } from './hf'
+export type { HFSearchResult, ModelFileVariant } from './hf'
+export { QUANTIZATION_INFO, extractQuantization, isMMProjFile, formatFileSize } from './quant'
+export type { QuantInfo } from './quant'
export {
determineCredibility,
CREDIBILITY_LABELS,
OFFICIAL_MODEL_AUTHORS,
- VERIFIED_QUANTIZERS,
-} from './credibility';
-export type { Credibility } from './credibility';
-export * from './providers';
-export * from './filters';
-export { supportsMode, validateImageGenRequest } from './imagegen';
-export type { ImageGenMode, ImageGenRequest, ImageGenResult, ImageGenProvider } from './imagegen';
-export { recommendedImageModelId, LIGHT_MODEL_RAM_CEILING_GB } from './recommend-image';
-export type { RecommendableModel } from './recommend-image';
+ VERIFIED_QUANTIZERS
+} from './credibility'
+export type { Credibility } from './credibility'
+export * from './providers'
+export * from './filters'
+export { supportsMode, validateImageGenRequest } from './imagegen'
+export type { ImageGenMode, ImageGenRequest, ImageGenResult, ImageGenProvider } from './imagegen'
+export { recommendedImageModelId, LIGHT_MODEL_RAM_CEILING_GB } from './recommend-image'
+export type { RecommendableModel } from './recommend-image'
diff --git a/packages/models/src/providers.ts b/packages/models/src/providers.ts
index 776b6c57..48be173d 100644
--- a/packages/models/src/providers.ts
+++ b/packages/models/src/providers.ts
@@ -5,91 +5,96 @@
// InferenceProvider interface directly. All shared; platforms inject nothing
// beyond an endpoint (or, for in-process, their own provider).
-export type ChatRole = 'system' | 'user' | 'assistant';
+export type ChatRole = 'system' | 'user' | 'assistant'
export interface ChatMessage {
- role: ChatRole;
- content: string;
+ role: ChatRole
+ content: string
}
export interface ChatOptions {
- model?: string;
- temperature?: number;
- maxTokens?: number;
- signal?: AbortSignal;
+ model?: string
+ temperature?: number
+ maxTokens?: number
+ signal?: AbortSignal
}
export interface ProviderModel {
- id: string;
- name: string;
+ id: string
+ name: string
}
/** Local or remote LLM. chat() streams text chunks. */
export interface InferenceProvider {
- readonly id: string;
- readonly name: string;
- listModels(): Promise;
- chat(messages: ChatMessage[], opts?: ChatOptions): AsyncIterable;
+ readonly id: string
+ readonly name: string
+ listModels(): Promise
+ chat(messages: ChatMessage[], opts?: ChatOptions): AsyncIterable
}
-export type RemoteServerKind = 'openai' | 'ollama';
+export type RemoteServerKind = 'openai' | 'ollama'
export interface RemoteServerConfig {
- id: string;
- name: string;
- kind: RemoteServerKind;
+ id: string
+ name: string
+ kind: RemoteServerKind
/** Base URL. OpenAI-compatible includes the /v1 suffix; Ollama is the host root. */
- endpoint: string;
- apiKey?: string;
+ endpoint: string
+ apiKey?: string
}
interface FetchResponse {
- ok: boolean;
- status: number;
- json(): Promise;
- body: ReadableStream | null;
+ ok: boolean
+ status: number
+ json(): Promise
+ body: ReadableStream | null
}
-export type FetchLike = (url: string, init?: {
- method?: string;
- headers?: Record;
- body?: string;
- signal?: AbortSignal;
-}) => Promise;
+export type FetchLike = (
+ url: string,
+ init?: {
+ method?: string
+ headers?: Record
+ body?: string
+ signal?: AbortSignal
+ }
+) => Promise
-const defaultFetch: FetchLike = (url, init) => fetch(url, init) as unknown as Promise;
+const defaultFetch: FetchLike = (url, init) => fetch(url, init) as unknown as Promise
function authHeaders(apiKey?: string): Record {
- return apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
+ return apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
}
async function* lines(body: ReadableStream): AsyncGenerator {
- const reader = body.getReader();
- const decoder = new TextDecoder();
- let buf = '';
+ const reader = body.getReader()
+ const decoder = new TextDecoder()
+ let buf = ''
for (;;) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- const parts = buf.split('\n');
- buf = parts.pop() ?? '';
- for (const line of parts) yield line;
+ const { done, value } = await reader.read()
+ if (done) break
+ buf += decoder.decode(value, { stream: true })
+ const parts = buf.split('\n')
+ buf = parts.pop() ?? ''
+ for (const line of parts) yield line
}
- if (buf.trim()) yield buf;
+ if (buf.trim()) yield buf
}
/** OpenAI-compatible provider: local llama-server, LM Studio, LocalAI, OpenAI. */
export function openAICompatibleProvider(cfg: {
- id: string;
- name: string;
- endpoint: string;
- apiKey?: string;
- fetchImpl?: FetchLike;
+ id: string
+ name: string
+ endpoint: string
+ apiKey?: string
+ fetchImpl?: FetchLike
}): InferenceProvider {
- const f = cfg.fetchImpl ?? defaultFetch;
+ const f = cfg.fetchImpl ?? defaultFetch
return {
id: cfg.id,
name: cfg.name,
async listModels() {
- const res = await f(`${cfg.endpoint}/models`, { headers: { Accept: 'application/json', ...authHeaders(cfg.apiKey) } });
- if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`);
- const data = (await res.json()) as { data?: { id: string }[] };
- return (data.data ?? []).map((m) => ({ id: m.id, name: m.id }));
+ const res = await f(`${cfg.endpoint}/models`, {
+ headers: { Accept: 'application/json', ...authHeaders(cfg.apiKey) }
+ })
+ if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`)
+ const data = (await res.json()) as { data?: { id: string }[] }
+ return (data.data ?? []).map((m) => ({ id: m.id, name: m.id }))
},
async *chat(messages, opts) {
const res = await f(`${cfg.endpoint}/chat/completions`, {
@@ -100,96 +105,110 @@ export function openAICompatibleProvider(cfg: {
messages,
stream: true,
temperature: opts?.temperature,
- max_tokens: opts?.maxTokens,
+ max_tokens: opts?.maxTokens
}),
- signal: opts?.signal,
- });
- if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`);
+ signal: opts?.signal
+ })
+ if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`)
for await (const line of lines(res.body)) {
- const t = line.trim();
- if (!t.startsWith('data:')) continue;
- const data = t.slice(5).trim();
- if (data === '[DONE]') return;
+ const t = line.trim()
+ if (!t.startsWith('data:')) continue
+ const data = t.slice(5).trim()
+ if (data === '[DONE]') return
try {
- const j = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] };
- const c = j.choices?.[0]?.delta?.content;
- if (c) yield c;
+ const j = JSON.parse(data) as { choices?: { delta?: { content?: string } }[] }
+ const c = j.choices?.[0]?.delta?.content
+ if (c) yield c
} catch {
// ignore keep-alives / malformed lines
}
}
- },
- };
+ }
+ }
}
/** Ollama provider (/api/tags, /api/chat NDJSON). */
export function ollamaProvider(cfg: {
- id: string;
- name: string;
- endpoint: string;
- fetchImpl?: FetchLike;
+ id: string
+ name: string
+ endpoint: string
+ fetchImpl?: FetchLike
}): InferenceProvider {
- const f = cfg.fetchImpl ?? defaultFetch;
+ const f = cfg.fetchImpl ?? defaultFetch
return {
id: cfg.id,
name: cfg.name,
async listModels() {
- const res = await f(`${cfg.endpoint}/api/tags`, { headers: { Accept: 'application/json' } });
- if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`);
- const data = (await res.json()) as { models?: { name: string }[] };
- return (data.models ?? []).map((m) => ({ id: m.name, name: m.name }));
+ const res = await f(`${cfg.endpoint}/api/tags`, { headers: { Accept: 'application/json' } })
+ if (!res.ok) throw new Error(`listModels failed: HTTP ${res.status}`)
+ const data = (await res.json()) as { models?: { name: string }[] }
+ return (data.models ?? []).map((m) => ({ id: m.name, name: m.name }))
},
async *chat(messages, opts) {
const res = await f(`${cfg.endpoint}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: opts?.model, messages, stream: true }),
- signal: opts?.signal,
- });
- if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`);
+ signal: opts?.signal
+ })
+ if (!res.ok || !res.body) throw new Error(`chat failed: HTTP ${res.status}`)
for await (const line of lines(res.body)) {
- const t = line.trim();
- if (!t) continue;
+ const t = line.trim()
+ if (!t) continue
try {
- const j = JSON.parse(t) as { message?: { content?: string }; done?: boolean };
- if (j.message?.content) yield j.message.content;
- if (j.done) return;
+ const j = JSON.parse(t) as { message?: { content?: string }; done?: boolean }
+ if (j.message?.content) yield j.message.content
+ if (j.done) return
} catch {
// ignore
}
}
- },
- };
+ }
+ }
}
/** Build a provider from a remote server config. */
-export function createProvider(server: RemoteServerConfig, fetchImpl?: FetchLike): InferenceProvider {
+export function createProvider(
+ server: RemoteServerConfig,
+ fetchImpl?: FetchLike
+): InferenceProvider {
if (server.kind === 'ollama') {
- return ollamaProvider({ id: server.id, name: server.name, endpoint: server.endpoint, fetchImpl });
+ return ollamaProvider({
+ id: server.id,
+ name: server.name,
+ endpoint: server.endpoint,
+ fetchImpl
+ })
}
- return openAICompatibleProvider({ id: server.id, name: server.name, endpoint: server.endpoint, apiKey: server.apiKey, fetchImpl });
+ return openAICompatibleProvider({
+ id: server.id,
+ name: server.name,
+ endpoint: server.endpoint,
+ apiKey: server.apiKey,
+ fetchImpl
+ })
}
/** Registry of available providers (local + remote) with an active selection. */
export class ProviderRegistry {
- private providers = new Map();
- private activeId: string | null = null;
+ private providers = new Map()
+ private activeId: string | null = null
register(provider: InferenceProvider): void {
- this.providers.set(provider.id, provider);
- if (!this.activeId) this.activeId = provider.id;
+ this.providers.set(provider.id, provider)
+ if (!this.activeId) this.activeId = provider.id
}
unregister(id: string): void {
- this.providers.delete(id);
- if (this.activeId === id) this.activeId = this.providers.keys().next().value ?? null;
+ this.providers.delete(id)
+ if (this.activeId === id) this.activeId = this.providers.keys().next().value ?? null
}
list(): InferenceProvider[] {
- return [...this.providers.values()];
+ return [...this.providers.values()]
}
setActive(id: string): void {
- if (this.providers.has(id)) this.activeId = id;
+ if (this.providers.has(id)) this.activeId = id
}
active(): InferenceProvider | null {
- return this.activeId ? this.providers.get(this.activeId) ?? null : null;
+ return this.activeId ? (this.providers.get(this.activeId) ?? null) : null
}
}
diff --git a/packages/models/src/quant.ts b/packages/models/src/quant.ts
index 23132e1b..a3fbfb1b 100644
--- a/packages/models/src/quant.ts
+++ b/packages/models/src/quant.ts
@@ -2,48 +2,102 @@
// and mobile share one definition of quant quality/size/recommendation.
export interface QuantInfo {
- bitsPerWeight: number;
- quality: string;
- description: string;
- recommended: boolean;
+ bitsPerWeight: number
+ quality: string
+ description: string
+ recommended: boolean
}
export const QUANTIZATION_INFO: Record = {
- Q2_K: { bitsPerWeight: 2.625, quality: 'Low', description: 'Extreme compression, noticeable quality loss', recommended: false },
- Q3_K_S: { bitsPerWeight: 3.4375, quality: 'Low-Medium', description: 'High compression, some quality loss', recommended: false },
- Q3_K_M: { bitsPerWeight: 3.4375, quality: 'Medium', description: 'Good compression with acceptable quality', recommended: false },
- Q4_0: { bitsPerWeight: 4, quality: 'Medium', description: 'Basic 4-bit quantization', recommended: false },
- Q4_K_S: { bitsPerWeight: 4.5, quality: 'Medium-Good', description: 'Good balance of size and quality', recommended: true },
- Q4_K_M: { bitsPerWeight: 4.5, quality: 'Good', description: 'Optimal balance - best for most devices', recommended: true },
- Q5_K_S: { bitsPerWeight: 5.5, quality: 'Good-High', description: 'Higher quality, larger size', recommended: false },
- Q5_K_M: { bitsPerWeight: 5.5, quality: 'High', description: 'Near original quality', recommended: false },
- Q6_K: { bitsPerWeight: 6.5, quality: 'Very High', description: 'Minimal quality loss', recommended: false },
- Q8_0: { bitsPerWeight: 8, quality: 'Excellent', description: 'Best quality, largest size', recommended: false },
-};
+ Q2_K: {
+ bitsPerWeight: 2.625,
+ quality: 'Low',
+ description: 'Extreme compression, noticeable quality loss',
+ recommended: false
+ },
+ Q3_K_S: {
+ bitsPerWeight: 3.4375,
+ quality: 'Low-Medium',
+ description: 'High compression, some quality loss',
+ recommended: false
+ },
+ Q3_K_M: {
+ bitsPerWeight: 3.4375,
+ quality: 'Medium',
+ description: 'Good compression with acceptable quality',
+ recommended: false
+ },
+ Q4_0: {
+ bitsPerWeight: 4,
+ quality: 'Medium',
+ description: 'Basic 4-bit quantization',
+ recommended: false
+ },
+ Q4_K_S: {
+ bitsPerWeight: 4.5,
+ quality: 'Medium-Good',
+ description: 'Good balance of size and quality',
+ recommended: true
+ },
+ Q4_K_M: {
+ bitsPerWeight: 4.5,
+ quality: 'Good',
+ description: 'Optimal balance - best for most devices',
+ recommended: true
+ },
+ Q5_K_S: {
+ bitsPerWeight: 5.5,
+ quality: 'Good-High',
+ description: 'Higher quality, larger size',
+ recommended: false
+ },
+ Q5_K_M: {
+ bitsPerWeight: 5.5,
+ quality: 'High',
+ description: 'Near original quality',
+ recommended: false
+ },
+ Q6_K: {
+ bitsPerWeight: 6.5,
+ quality: 'Very High',
+ description: 'Minimal quality loss',
+ recommended: false
+ },
+ Q8_0: {
+ bitsPerWeight: 8,
+ quality: 'Excellent',
+ description: 'Best quality, largest size',
+ recommended: false
+ }
+}
/** Extract a quantization label from a GGUF filename. */
export function extractQuantization(fileName: string): string {
- const upper = fileName.toUpperCase();
+ const upper = fileName.toUpperCase()
for (const quant of Object.keys(QUANTIZATION_INFO)) {
- if (upper.includes(quant.replace('_', '')) || upper.includes(quant)) return quant;
+ if (upper.includes(quant.replace('_', '')) || upper.includes(quant)) return quant
}
- const match = fileName.match(/[QqFf]\d+[_]?[KkMmSs]*/);
- return match ? match[0].toUpperCase() : 'Unknown';
+ const match = fileName.match(/[QqFf]\d+[_]?[KkMmSs]*/)
+ return match ? match[0].toUpperCase() : 'Unknown'
}
export function isMMProjFile(fileName: string): boolean {
- const lower = fileName.toLowerCase();
- return lower.includes('mmproj') || lower.includes('projector') || (lower.includes('clip') && lower.endsWith('.gguf'));
+ const lower = fileName.toLowerCase()
+ return (
+ lower.includes('mmproj') ||
+ lower.includes('projector') ||
+ (lower.includes('clip') && lower.endsWith('.gguf'))
+ )
}
export function formatFileSize(bytes: number): string {
- if (!bytes) return 'Unknown';
- const units = ['B', 'KB', 'MB', 'GB'];
- let n = bytes;
- let i = 0;
+ if (!bytes) return 'Unknown'
+ const units = ['B', 'KB', 'MB', 'GB']
+ let n = bytes
+ let i = 0
while (n >= 1024 && i < units.length - 1) {
- n /= 1024;
- i++;
+ n /= 1024
+ i++
}
- return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`;
+ return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${units[i]}`
}
diff --git a/packages/models/src/recommend-image.ts b/packages/models/src/recommend-image.ts
index 6b302f92..f1b88375 100644
--- a/packages/models/src/recommend-image.ts
+++ b/packages/models/src/recommend-image.ts
@@ -11,32 +11,32 @@
* callers can pass either the package `ModelEntry` or a renderer-local model type
* (whose `kind` is a plain string) without a cast. */
export interface RecommendableModel {
- id: string;
- kind: string;
- tags?: string[];
+ id: string
+ kind: string
+ tags?: string[]
}
/** RAM (GB) at or below which the lighter (Light-tagged) quant is recommended.
* 16GB is the ceiling: verified that the full Q8 DreamShaper pegs memory (~4.7GB
* peak) and can freeze a 16GB Mac, while the Q4 (~3.08GB peak) does not. */
-export const LIGHT_MODEL_RAM_CEILING_GB = 16;
+export const LIGHT_MODEL_RAM_CEILING_GB = 16
const hasLightTag = (m: RecommendableModel): boolean =>
- (m.tags ?? []).some((t) => /^light$/i.test(t));
+ (m.tags ?? []).some((t) => /^light$/i.test(t))
const isVersatile = (m: RecommendableModel): boolean =>
- (m.tags ?? []).some((t) => /^versatile$/i.test(t));
+ (m.tags ?? []).some((t) => /^versatile$/i.test(t))
/** Prefer the 'Versatile' all-rounder (DreamShaper) when several models qualify,
* so the badge is stable no matter how many Light variants the catalog lists /
* their order. Falls back to the first candidate. */
const pickVersatileFirst = (candidates: RecommendableModel[]): RecommendableModel | undefined =>
- candidates.find(isVersatile) ?? candidates[0];
+ candidates.find(isVersatile) ?? candidates[0]
/** Family key for pairing a full quant with its Light sibling: the id with any
* trailing quant suffix (e.g. "-Q4") stripped, so both DreamShaper entries map
* to the same family. */
-const familyKey = (m: RecommendableModel): string => m.id.replace(/-Q\d[\w]*$/i, '');
+const familyKey = (m: RecommendableModel): string => m.id.replace(/-Q\d[\w]*$/i, '')
/**
* The image model id best suited to a machine with `ramGb` RAM, or null when no
@@ -48,19 +48,23 @@ const familyKey = (m: RecommendableModel): string => m.id.replace(/-Q\d[\w]*$/i,
* family (DreamShaper) rather than an unrelated heavy model. Falls back to any
* Light model when only that exists (small machine) / the family's full entry.
*/
-export function recommendedImageModelId(models: RecommendableModel[], ramGb: number | null | undefined): string | null {
- if (!ramGb || !Number.isFinite(ramGb)) return null;
- const images = models.filter((m) => m.kind === 'image');
- if (!images.length) return null;
+export function recommendedImageModelId(
+ models: RecommendableModel[],
+ ramGb: number | null | undefined
+): string | null {
+ if (!ramGb || !Number.isFinite(ramGb)) return null
+ const images = models.filter((m) => m.kind === 'image')
+ if (!images.length) return null
- const light = images.filter(hasLightTag);
+ const light = images.filter(hasLightTag)
// Families that ship a Light variant — those are the ones we recommend within.
- const lightFamilies = new Set(light.map(familyKey));
- const fullOfLightFamily = images.filter((m) => !hasLightTag(m) && lightFamilies.has(familyKey(m)));
+ const lightFamilies = new Set(light.map(familyKey))
+ const fullOfLightFamily = images.filter((m) => !hasLightTag(m) && lightFamilies.has(familyKey(m)))
if (ramGb <= LIGHT_MODEL_RAM_CEILING_GB) {
- return (pickVersatileFirst(light) ?? images[0]).id;
+ return (pickVersatileFirst(light) ?? images[0]).id
}
// Above the ceiling: the full sibling of a Light family, else any non-Light image model.
- return (pickVersatileFirst(fullOfLightFamily) ?? images.find((m) => !hasLightTag(m)) ?? images[0]).id;
+ return (pickVersatileFirst(fullOfLightFamily) ?? images.find((m) => !hasLightTag(m)) ?? images[0])
+ .id
}
diff --git a/packages/models/src/types.ts b/packages/models/src/types.ts
index cd530e92..06adb713 100644
--- a/packages/models/src/types.ts
+++ b/packages/models/src/types.ts
@@ -5,73 +5,73 @@
// The catalog and downloader are kind-agnostic; the host runtime knows how to
// load each kind.
-import type { ImageGenMode } from './imagegen';
+import type { ImageGenMode } from './imagegen'
-export type ModelKind = 'text' | 'vision' | 'image' | 'voice' | 'transcription';
+export type ModelKind = 'text' | 'vision' | 'image' | 'voice' | 'transcription'
export interface ModelFile {
/** Filename on disk, e.g. "Qwen3.5-2B-Q4_K_M.gguf". */
- name: string;
+ name: string
/** Download URL (often a Hugging Face resolve URL). */
- url: string;
+ url: string
/** Size in bytes when known (for progress + RAM/disk checks). */
- sizeBytes?: number;
+ sizeBytes?: number
/** Marks an auxiliary file (e.g. a vision mmproj) vs the primary weights. */
- role?: 'primary' | 'mmproj' | 'tokenizer' | 'aux';
+ role?: 'primary' | 'mmproj' | 'tokenizer' | 'aux'
}
export interface ModelEntry {
/** Stable id, usually the HF repo id, e.g. "unsloth/Qwen3.5-2B-GGUF". */
- id: string;
- name: string;
- kind: ModelKind;
+ id: string
+ name: string
+ kind: ModelKind
/** Provider/org, e.g. "google", "Qwen", "openai-whisper". */
- org?: string;
- description?: string;
+ org?: string
+ description?: string
/** Billions of parameters (LLMs); omitted for non-LLM kinds. */
- params?: number;
+ params?: number
/** Minimum device RAM (GB) recommended. */
- minRamGb?: number;
+ minRamGb?: number
/** Quantization label, e.g. "Q4_K_M". */
- quant?: string;
+ quant?: string
/** Files to download for this model. */
- files: ModelFile[];
+ files: ModelFile[]
/** For image models: which generation modes it supports (txt2img/img2img). */
- imageModes?: ImageGenMode[];
- isNew?: boolean;
+ imageModes?: ImageGenMode[]
+ isNew?: boolean
/** Short capability labels shown as chips, e.g. ['Recommended','Fast','Photoreal']. */
- tags?: string[];
+ tags?: string[]
/** Which on-device runtime serves this model. Default 'sd-cli' (stable-diffusion.cpp).
* 'mflux' = the bundled MLX runtime (Apple-Silicon-only; fetches its own weights). */
- runtime?: 'sd-cli' | 'mflux';
+ runtime?: 'sd-cli' | 'mflux'
/** For transcription models: which STT engine serves it. Default 'whisper' when
* omitted. 'parakeet' models are multi-file ONNX sets run by the sherpa-onnx CLI. */
- engine?: 'whisper' | 'parakeet';
+ engine?: 'whisper' | 'parakeet'
/** Release date (ISO yyyy-mm-dd), from the source repo's createdAt. Shown on the
* card and used to surface only recent ("post-Jan-2026") small models. */
- releaseDate?: string;
+ releaseDate?: string
}
/** RAM tier -> max model size + quant, for recommending a default model. */
export interface ModelRecommendationTier {
- minRamGb: number;
- maxRamGb: number;
- maxParams: number;
- quantization: string;
+ minRamGb: number
+ maxRamGb: number
+ maxParams: number
+ quantization: string
}
-export type DownloadStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed';
+export type DownloadStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed'
export interface DownloadProgress {
- modelId: string;
- status: DownloadStatus;
+ modelId: string
+ status: DownloadStatus
/** 0..1 across all of the model's files. */
- progress: number;
- bytesDownloaded: number;
- totalBytes: number;
- currentFile?: string;
- speedBytesPerSec?: number;
- error?: string;
+ progress: number
+ bytesDownloaded: number
+ totalBytes: number
+ currentFile?: string
+ speedBytesPerSec?: number
+ error?: string
}
/** Platform file download (Node/Electron streams to disk; RN background downloader). */
@@ -82,17 +82,17 @@ export interface DownloadBridge {
url: string,
destPath: string,
opts: { onProgress?: (written: number, total: number) => void; signal?: AbortSignal }
- ): Promise;
+ ): Promise
/** Whether a fully-downloaded file already exists (size match). */
- exists(destPath: string, expectedBytes?: number): Promise;
+ exists(destPath: string, expectedBytes?: number): Promise
/** Join the models directory with a filename. */
- pathFor(fileName: string): string;
+ pathFor(fileName: string): string
}
/** Records which models are installed (a memory entity or local store). */
export interface ModelStore {
- markInstalled(entry: ModelEntry): void;
- isInstalled(modelId: string): boolean;
- installed(): ModelEntry[];
- remove(modelId: string): void;
+ markInstalled(entry: ModelEntry): void
+ isInstalled(modelId: string): boolean
+ installed(): ModelEntry[]
+ remove(modelId: string): void
}
diff --git a/packages/rag/src/bridges.ts b/packages/rag/src/bridges.ts
index a6be2702..4e8317ed 100644
--- a/packages/rag/src/bridges.ts
+++ b/packages/rag/src/bridges.ts
@@ -3,24 +3,24 @@
// better-sqlite3 store + Node/native extractors; mobile wires llama.rn + op-sqlite
// + RNFS/PDFKit. This keeps the chunking/retrieval/orchestration logic shared.
-import type { MediaKind, RagDocument } from './types';
+import type { MediaKind, RagDocument } from './types'
/** Turns text into an embedding vector. Same model must be used for index + query. */
export interface EmbeddingProvider {
- embed(text: string): Promise;
+ embed(text: string): Promise
/** Optional batch path; the service falls back to mapped embed() if absent. */
- embedBatch?(texts: string[]): Promise;
+ embedBatch?(texts: string[]): Promise
/** Embedding dimension (e.g. 384 for all-MiniLM-L6-v2). */
- readonly dimension: number;
+ readonly dimension: number
}
/** A stored chunk with its embedding, returned as a retrieval candidate. */
export interface ChunkCandidate {
- docId: number;
- name: string;
- content: string;
- position: number;
- embedding: number[];
+ docId: number
+ name: string
+ content: string
+ position: number
+ embedding: number[]
}
/**
@@ -31,22 +31,22 @@ export interface ChunkCandidate {
*/
export interface VectorStore {
addDocument(doc: {
- projectId: string;
- name: string;
- path: string;
- size: number;
- kind: MediaKind;
- }): Promise;
+ projectId: string
+ name: string
+ path: string
+ size: number
+ kind: MediaKind
+ }): Promise
addChunks(
docId: number,
chunks: { content: string; position: number }[],
embeddings: number[][]
- ): Promise;
+ ): Promise
/** Enabled chunks (and any extra sources) eligible for retrieval in a project. */
- getChunkCandidates(projectId: string): Promise;
- listDocuments(projectId: string): Promise;
- setDocumentEnabled(docId: number, enabled: boolean): Promise;
- deleteDocument(docId: number): Promise;
+ getChunkCandidates(projectId: string): Promise
+ listDocuments(projectId: string): Promise
+ setDocumentEnabled(docId: number, enabled: boolean): Promise
+ deleteDocument(docId: number): Promise
}
/**
@@ -56,16 +56,16 @@ export interface VectorStore {
* the engine when a file needs an unavailable capability (e.g. no vision model).
*/
export interface ExtractionBridges {
- readText(path: string): Promise;
- extractPdf?(path: string, maxChars?: number): Promise;
- extractDocx?(path: string, maxChars?: number): Promise;
+ readText(path: string): Promise
+ extractPdf?(path: string, maxChars?: number): Promise
+ extractDocx?(path: string, maxChars?: number): Promise
/** Transcribe an audio file to text (uses a downloaded transcription model). */
- transcribeAudio?(path: string): Promise;
+ transcribeAudio?(path: string): Promise
/** Sample frames from a video; returns image paths (or data URIs) to caption. */
sampleVideoFrames?(
path: string,
opts: { everySeconds?: number; maxFrames?: number }
- ): Promise;
+ ): Promise
/** Describe/OCR a single image (uses a downloaded vision model). */
- captionImage?(imagePath: string): Promise;
+ captionImage?(imagePath: string): Promise
}
diff --git a/packages/rag/src/chunking.ts b/packages/rag/src/chunking.ts
index 1bcb43ea..b9a193e2 100644
--- a/packages/rag/src/chunking.ts
+++ b/packages/rag/src/chunking.ts
@@ -5,58 +5,58 @@
export interface ChunkOptions {
/** Target chunk size in characters (default 500). */
- chunkSize?: number;
+ chunkSize?: number
/** Sliding-window overlap for oversized paragraphs (default 100). */
- overlap?: number;
+ overlap?: number
/** Drop chunks shorter than this (default 20). */
- minChunkLength?: number;
+ minChunkLength?: number
}
export interface Chunk {
- content: string;
- position: number;
+ content: string
+ position: number
}
export function chunkText(text: string, opts: ChunkOptions = {}): Chunk[] {
- const chunkSize = opts.chunkSize ?? 500;
- const overlap = opts.overlap ?? 100;
- const minChunkLength = opts.minChunkLength ?? 20;
+ const chunkSize = opts.chunkSize ?? 500
+ const overlap = opts.overlap ?? 100
+ const minChunkLength = opts.minChunkLength ?? 20
- const clean = text.replace(/\r\n/g, '\n').trim();
- if (!clean) return [];
+ const clean = text.replace(/\r\n/g, '\n').trim()
+ if (!clean) return []
const paragraphs = clean
.split(/\n\n+/)
.map((p) => p.trim())
- .filter(Boolean);
+ .filter(Boolean)
- const chunks: string[] = [];
- let buffer = '';
+ const chunks: string[] = []
+ let buffer = ''
const flush = (): void => {
- const t = buffer.trim();
- if (t.length >= minChunkLength) chunks.push(t);
- buffer = '';
- };
+ const t = buffer.trim()
+ if (t.length >= minChunkLength) chunks.push(t)
+ buffer = ''
+ }
for (const para of paragraphs) {
if (para.length > chunkSize) {
// Oversized paragraph: emit the buffer, then sliding-window the paragraph.
- flush();
- const step = Math.max(1, chunkSize - overlap);
+ flush()
+ const step = Math.max(1, chunkSize - overlap)
for (let start = 0; start < para.length; start += step) {
- const slice = para.slice(start, start + chunkSize).trim();
- if (slice.length >= minChunkLength) chunks.push(slice);
- if (start + chunkSize >= para.length) break;
+ const slice = para.slice(start, start + chunkSize).trim()
+ if (slice.length >= minChunkLength) chunks.push(slice)
+ if (start + chunkSize >= para.length) break
}
- } else if (buffer && (buffer.length + 2 + para.length) > chunkSize) {
- flush();
- buffer = para;
+ } else if (buffer && buffer.length + 2 + para.length > chunkSize) {
+ flush()
+ buffer = para
} else {
- buffer = buffer ? `${buffer}\n\n${para}` : para;
+ buffer = buffer ? `${buffer}\n\n${para}` : para
}
}
- flush();
+ flush()
- return chunks.map((content, position) => ({ content, position }));
+ return chunks.map((content, position) => ({ content, position }))
}
diff --git a/packages/rag/src/extract.ts b/packages/rag/src/extract.ts
index a5a33372..f524f865 100644
--- a/packages/rag/src/extract.ts
+++ b/packages/rag/src/extract.ts
@@ -11,45 +11,77 @@
// Audio and video "just work" off the capabilities we already ship: a
// transcription model for audio, a vision model for video frames / images.
-import type { MediaKind } from './types';
-import type { ExtractionBridges } from './bridges';
+import type { MediaKind } from './types'
+import type { ExtractionBridges } from './bridges'
const TEXT_EXT = new Set([
- 'txt', 'md', 'markdown', 'csv', 'json', 'xml', 'html', 'htm', 'log', 'rtf',
- 'py', 'js', 'ts', 'tsx', 'jsx', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'swift',
- 'kt', 'go', 'rs', 'rb', 'php', 'sql', 'sh', 'yaml', 'yml', 'toml', 'ini', 'cfg', 'conf',
-]);
-const AUDIO_EXT = new Set(['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wma']);
-const VIDEO_EXT = new Set(['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'mpg', 'mpeg']);
-const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'heic']);
+ 'txt',
+ 'md',
+ 'markdown',
+ 'csv',
+ 'json',
+ 'xml',
+ 'html',
+ 'htm',
+ 'log',
+ 'rtf',
+ 'py',
+ 'js',
+ 'ts',
+ 'tsx',
+ 'jsx',
+ 'java',
+ 'c',
+ 'cpp',
+ 'h',
+ 'hpp',
+ 'cs',
+ 'swift',
+ 'kt',
+ 'go',
+ 'rs',
+ 'rb',
+ 'php',
+ 'sql',
+ 'sh',
+ 'yaml',
+ 'yml',
+ 'toml',
+ 'ini',
+ 'cfg',
+ 'conf'
+])
+const AUDIO_EXT = new Set(['mp3', 'wav', 'm4a', 'aac', 'flac', 'ogg', 'opus', 'wma'])
+const VIDEO_EXT = new Set(['mp4', 'mov', 'mkv', 'avi', 'webm', 'm4v', 'mpg', 'mpeg'])
+const IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'heic'])
export function extensionOf(fileName: string): string {
- return (fileName.split('.').pop() ?? '').toLowerCase();
+ return (fileName.split('.').pop() ?? '').toLowerCase()
}
/** Classify a file by extension into a MediaKind. */
export function detectKind(fileName: string): MediaKind {
- const ext = extensionOf(fileName);
- if (ext === 'pdf') return 'pdf';
- if (ext === 'docx' || ext === 'doc') return 'docx';
- if (AUDIO_EXT.has(ext)) return 'audio';
- if (VIDEO_EXT.has(ext)) return 'video';
- if (IMAGE_EXT.has(ext)) return 'image';
- return 'text';
+ const ext = extensionOf(fileName)
+ if (ext === 'pdf') return 'pdf'
+ if (ext === 'docx' || ext === 'doc') return 'docx'
+ if (AUDIO_EXT.has(ext)) return 'audio'
+ if (VIDEO_EXT.has(ext)) return 'video'
+ if (IMAGE_EXT.has(ext)) return 'image'
+ return 'text'
}
export interface ExtractOptions {
/** Hard cap on extracted characters (default 500_000). */
- maxChars?: number;
+ maxChars?: number
/** Video: sample one frame every N seconds (default 5). */
- videoEverySeconds?: number;
+ videoEverySeconds?: number
/** Video: cap total sampled frames (default 24). */
- videoMaxFrames?: number;
+ videoMaxFrames?: number
}
export interface ExtractedContent {
- text: string;
- kind: MediaKind;
+ text: string
+ kind: MediaKind
}
/** Extract plain text from a file, routing by kind through the given bridges. */
@@ -59,48 +91,49 @@ export async function extractContent(
bridges: ExtractionBridges,
opts: ExtractOptions = {}
): Promise {
- const kind = detectKind(fileName);
- const maxChars = opts.maxChars ?? 500_000;
- let text = '';
+ const kind = detectKind(fileName)
+ const maxChars = opts.maxChars ?? 500_000
+ let text = ''
switch (kind) {
case 'pdf':
- if (!bridges.extractPdf) throw new Error('PDF extraction is not available on this platform.');
- text = await bridges.extractPdf(path, maxChars);
- break;
+ if (!bridges.extractPdf) throw new Error('PDF extraction is not available on this platform.')
+ text = await bridges.extractPdf(path, maxChars)
+ break
case 'docx':
- if (!bridges.extractDocx) throw new Error('DOCX extraction is not available on this platform.');
- text = await bridges.extractDocx(path, maxChars);
- break;
+ if (!bridges.extractDocx)
+ throw new Error('DOCX extraction is not available on this platform.')
+ text = await bridges.extractDocx(path, maxChars)
+ break
case 'audio':
if (!bridges.transcribeAudio)
- throw new Error('Audio ingestion needs a transcription model — download one from Models.');
- text = await bridges.transcribeAudio(path);
- break;
+ throw new Error('Audio ingestion needs a transcription model — download one from Models.')
+ text = await bridges.transcribeAudio(path)
+ break
case 'video': {
if (!bridges.sampleVideoFrames || !bridges.captionImage)
- throw new Error('Video ingestion needs a vision model — download one from Models.');
+ throw new Error('Video ingestion needs a vision model — download one from Models.')
const frames = await bridges.sampleVideoFrames(path, {
everySeconds: opts.videoEverySeconds ?? 5,
- maxFrames: opts.videoMaxFrames ?? 24,
- });
- const captions: string[] = [];
+ maxFrames: opts.videoMaxFrames ?? 24
+ })
+ const captions: string[] = []
for (let i = 0; i < frames.length; i++) {
- const c = (await bridges.captionImage(frames[i]))?.trim();
- if (c) captions.push(`[frame ${i + 1}] ${c}`);
+ const c = (await bridges.captionImage(frames[i]))?.trim()
+ if (c) captions.push(`[frame ${i + 1}] ${c}`)
}
- text = captions.join('\n');
- break;
+ text = captions.join('\n')
+ break
}
case 'image':
if (!bridges.captionImage)
- throw new Error('Image ingestion needs a vision model — download one from Models.');
- text = await bridges.captionImage(path);
- break;
+ throw new Error('Image ingestion needs a vision model — download one from Models.')
+ text = await bridges.captionImage(path)
+ break
default:
- text = await bridges.readText(path);
+ text = await bridges.readText(path)
}
- if (text.length > maxChars) text = text.slice(0, maxChars);
- return { text, kind };
+ if (text.length > maxChars) text = text.slice(0, maxChars)
+ return { text, kind }
}
diff --git a/packages/rag/src/index.ts b/packages/rag/src/index.ts
index fc15350c..ec57626a 100644
--- a/packages/rag/src/index.ts
+++ b/packages/rag/src/index.ts
@@ -1,32 +1,22 @@
// @offgrid/rag — shared projects + RAG engine (pure TS over injectable bridges).
-export type { MediaKind, Project, RagDocument, RagSearchResult, SearchResult } from './types';
+export type { MediaKind, Project, RagDocument, RagSearchResult, SearchResult } from './types'
-export { chunkText, type Chunk, type ChunkOptions } from './chunking';
-export { dotProduct, cosineSimilarity, topKSimilar, type SimilarityResult } from './vectorMath';
+export { chunkText, type Chunk, type ChunkOptions } from './chunking'
+export { dotProduct, cosineSimilarity, topKSimilar, type SimilarityResult } from './vectorMath'
export {
rankBySimilarity,
estimateCharBudget,
selectWithinBudget,
- formatForPrompt,
-} from './retrieval';
+ formatForPrompt
+} from './retrieval'
export {
detectKind,
extensionOf,
extractContent,
type ExtractOptions,
- type ExtractedContent,
-} from './extract';
-export {
- RagService,
- type RagServiceDeps,
- type IndexResult,
- type IndexStage,
-} from './service';
-export { SEARCH_KB_TOOL, makeSearchKnowledgeBaseHandler } from './tools';
-export type {
- EmbeddingProvider,
- VectorStore,
- ChunkCandidate,
- ExtractionBridges,
-} from './bridges';
+ type ExtractedContent
+} from './extract'
+export { RagService, type RagServiceDeps, type IndexResult, type IndexStage } from './service'
+export { SEARCH_KB_TOOL, makeSearchKnowledgeBaseHandler } from './tools'
+export type { EmbeddingProvider, VectorStore, ChunkCandidate, ExtractionBridges } from './bridges'
diff --git a/packages/rag/src/retrieval.ts b/packages/rag/src/retrieval.ts
index 5de7456e..f22677da 100644
--- a/packages/rag/src/retrieval.ts
+++ b/packages/rag/src/retrieval.ts
@@ -3,9 +3,9 @@
// from Off Grid Mobile (rag/retrieval.ts). Pure: scoring only — fetching is the
// VectorStore's job.
-import { cosineSimilarity } from './vectorMath';
-import type { ChunkCandidate } from './bridges';
-import type { RagSearchResult } from './types';
+import { cosineSimilarity } from './vectorMath'
+import type { ChunkCandidate } from './bridges'
+import type { RagSearchResult } from './types'
/** Score every candidate by cosine similarity and return the top-k, desc. */
export function rankBySimilarity(
@@ -19,41 +19,44 @@ export function rankBySimilarity(
name: c.name,
content: c.content,
position: c.position,
- score: cosineSimilarity(queryVec, c.embedding),
+ score: cosineSimilarity(queryVec, c.embedding)
}))
.sort((a, b) => b.score - a.score)
- .slice(0, topK);
+ .slice(0, topK)
}
/** Characters of context to spend on retrieved KB excerpts for a given window.
* ~4 chars/token, reserve ~40% of the window for the knowledge base. */
export function estimateCharBudget(contextLengthTokens: number): number {
- return Math.max(1000, Math.floor(contextLengthTokens * 4 * 0.4));
+ return Math.max(1000, Math.floor(contextLengthTokens * 4 * 0.4))
}
/** Greedily keep top chunks until the character budget is exhausted. */
-export function selectWithinBudget(chunks: RagSearchResult[], charBudget: number): RagSearchResult[] {
- const out: RagSearchResult[] = [];
- let total = 0;
+export function selectWithinBudget(
+ chunks: RagSearchResult[],
+ charBudget: number
+): RagSearchResult[] {
+ const out: RagSearchResult[] = []
+ let total = 0
for (const c of chunks) {
- if (out.length > 0 && total + c.content.length > charBudget) break;
- out.push(c);
- total += c.content.length;
+ if (out.length > 0 && total + c.content.length > charBudget) break
+ out.push(c)
+ total += c.content.length
}
- return out;
+ return out
}
/** Wrap retrieved excerpts in a tagged block for the system/user prompt. */
export function formatForPrompt(result: { chunks: RagSearchResult[] }): string {
- if (!result.chunks.length) return '';
+ if (!result.chunks.length) return ''
const body = result.chunks
.map((c) => `[Source: ${c.name} (part ${c.position + 1})]\n${c.content}`)
- .join('\n---\n');
+ .join('\n---\n')
return (
'\n' +
"The following excerpts are from the user's project knowledge base. " +
'Use them to answer and cite the source filename when you do.\n' +
`${body}\n` +
''
- );
+ )
}
diff --git a/packages/rag/src/service.ts b/packages/rag/src/service.ts
index f739d287..8276aa69 100644
--- a/packages/rag/src/service.ts
+++ b/packages/rag/src/service.ts
@@ -2,30 +2,30 @@
// embeds -> stores; searchProject embeds the query and ranks stored chunks.
// Mirrors Off Grid Mobile's RagService surface so the apps wire it the same way.
-import { chunkText, type ChunkOptions } from './chunking';
-import { extractContent, type ExtractOptions } from './extract';
+import { chunkText, type ChunkOptions } from './chunking'
+import { extractContent, type ExtractOptions } from './extract'
import {
rankBySimilarity,
selectWithinBudget,
estimateCharBudget,
- formatForPrompt,
-} from './retrieval';
-import type { EmbeddingProvider, VectorStore, ExtractionBridges } from './bridges';
-import type { RagDocument, SearchResult } from './types';
+ formatForPrompt
+} from './retrieval'
+import type { EmbeddingProvider, VectorStore, ExtractionBridges } from './bridges'
+import type { RagDocument, SearchResult } from './types'
-export type IndexStage = 'extracting' | 'chunking' | 'embedding' | 'indexing' | 'done';
+export type IndexStage = 'extracting' | 'chunking' | 'embedding' | 'indexing' | 'done'
export interface RagServiceDeps {
- store: VectorStore;
- embeddings: EmbeddingProvider;
- extraction: ExtractionBridges;
- chunkOptions?: ChunkOptions;
+ store: VectorStore
+ embeddings: EmbeddingProvider
+ extraction: ExtractionBridges
+ chunkOptions?: ChunkOptions
}
export interface IndexResult {
- docId: number;
- chunkCount: number;
- kind: RagDocument['kind'];
+ docId: number
+ chunkCount: number
+ kind: RagDocument['kind']
}
export class RagService {
@@ -34,49 +34,49 @@ export class RagService {
/** Ingest a file into a project's knowledge base. */
async indexDocument(
params: {
- projectId: string;
- path: string;
- fileName: string;
- size: number;
- extract?: ExtractOptions;
+ projectId: string
+ path: string
+ fileName: string
+ size: number
+ extract?: ExtractOptions
},
onProgress?: (stage: IndexStage) => void
): Promise {
- onProgress?.('extracting');
+ onProgress?.('extracting')
const { text, kind } = await extractContent(
params.path,
params.fileName,
this.deps.extraction,
params.extract
- );
+ )
- onProgress?.('chunking');
- const chunks = chunkText(text, this.deps.chunkOptions);
+ onProgress?.('chunking')
+ const chunks = chunkText(text, this.deps.chunkOptions)
const docId = await this.deps.store.addDocument({
projectId: params.projectId,
name: params.fileName,
path: params.path,
size: params.size,
- kind,
- });
+ kind
+ })
if (chunks.length === 0) {
- onProgress?.('done');
- return { docId, chunkCount: 0, kind };
+ onProgress?.('done')
+ return { docId, chunkCount: 0, kind }
}
- onProgress?.('embedding');
- const texts = chunks.map((c) => c.content);
+ onProgress?.('embedding')
+ const texts = chunks.map((c) => c.content)
const embeddings = this.deps.embeddings.embedBatch
? await this.deps.embeddings.embedBatch(texts)
- : await Promise.all(texts.map((t) => this.deps.embeddings.embed(t)));
+ : await Promise.all(texts.map((t) => this.deps.embeddings.embed(t)))
- onProgress?.('indexing');
- await this.deps.store.addChunks(docId, chunks, embeddings);
+ onProgress?.('indexing')
+ await this.deps.store.addChunks(docId, chunks, embeddings)
- onProgress?.('done');
- return { docId, chunkCount: chunks.length, kind };
+ onProgress?.('done')
+ return { docId, chunkCount: chunks.length, kind }
}
/** Retrieve the most relevant excerpts for a query within a project. */
@@ -85,31 +85,31 @@ export class RagService {
query: string,
opts: { topK?: number; contextLength?: number } = {}
): Promise {
- const candidates = await this.deps.store.getChunkCandidates(projectId);
- if (candidates.length === 0) return { chunks: [], query };
+ const candidates = await this.deps.store.getChunkCandidates(projectId)
+ if (candidates.length === 0) return { chunks: [], query }
- const queryVec = await this.deps.embeddings.embed(query);
- let ranked = rankBySimilarity(queryVec, candidates, opts.topK ?? 5);
+ const queryVec = await this.deps.embeddings.embed(query)
+ let ranked = rankBySimilarity(queryVec, candidates, opts.topK ?? 5)
if (opts.contextLength) {
- ranked = selectWithinBudget(ranked, estimateCharBudget(opts.contextLength));
+ ranked = selectWithinBudget(ranked, estimateCharBudget(opts.contextLength))
}
- return { chunks: ranked, query };
+ return { chunks: ranked, query }
}
/** Build a prompt-ready block from a search result. */
formatForPrompt(result: SearchResult): string {
- return formatForPrompt(result);
+ return formatForPrompt(result)
}
listDocuments(projectId: string): Promise {
- return this.deps.store.listDocuments(projectId);
+ return this.deps.store.listDocuments(projectId)
}
toggleDocument(docId: number, enabled: boolean): Promise {
- return this.deps.store.setDocumentEnabled(docId, enabled);
+ return this.deps.store.setDocumentEnabled(docId, enabled)
}
deleteDocument(docId: number): Promise {
- return this.deps.store.deleteDocument(docId);
+ return this.deps.store.deleteDocument(docId)
}
}
diff --git a/packages/rag/src/tools.ts b/packages/rag/src/tools.ts
index 7bd42361..ccf25e26 100644
--- a/packages/rag/src/tools.ts
+++ b/packages/rag/src/tools.ts
@@ -3,7 +3,7 @@
// to the always-on retrieval that injects context up front). The OpenAI-style
// schema works with our local llama-server tool calling and remote providers.
-import type { SearchResult } from './types';
+import type { SearchResult } from './types'
export const SEARCH_KB_TOOL = {
type: 'function' as const,
@@ -14,23 +14,23 @@ export const SEARCH_KB_TOOL = {
parameters: {
type: 'object',
properties: {
- query: { type: 'string', description: 'What to look up in the knowledge base.' },
+ query: { type: 'string', description: 'What to look up in the knowledge base.' }
},
- required: ['query'],
- },
- },
-};
+ required: ['query']
+ }
+ }
+}
/** Build a tool handler bound to a searcher. Returns a model-ready string. */
export function makeSearchKnowledgeBaseHandler(searcher: {
- searchProject(projectId: string, query: string): Promise;
+ searchProject(projectId: string, query: string): Promise
}) {
return async (args: { query: string }, projectId?: string): Promise => {
- if (!projectId) return 'No active project. The knowledge base requires an open project.';
- const result = await searcher.searchProject(projectId, args.query);
- if (!result.chunks.length) return `No knowledge-base results found for "${args.query}".`;
+ if (!projectId) return 'No active project. The knowledge base requires an open project.'
+ const result = await searcher.searchProject(projectId, args.query)
+ if (!result.chunks.length) return `No knowledge-base results found for "${args.query}".`
return result.chunks
.map((c, i) => `[${i + 1}] ${c.name} (part ${c.position + 1}):\n${c.content}`)
- .join('\n\n---\n\n');
- };
+ .join('\n\n---\n\n')
+ }
}
diff --git a/packages/rag/src/types.ts b/packages/rag/src/types.ts
index 09d9a41b..0ffda324 100644
--- a/packages/rag/src/types.ts
+++ b/packages/rag/src/types.ts
@@ -3,42 +3,42 @@
// platform's VectorStore implementation; these are the engine-facing types.
/** What a knowledge-base file is, which decides how its text is extracted. */
-export type MediaKind = 'text' | 'pdf' | 'docx' | 'audio' | 'video' | 'image';
+export type MediaKind = 'text' | 'pdf' | 'docx' | 'audio' | 'video' | 'image'
/** A workspace: a named container scoping chat threads + a knowledge base. */
export interface Project {
- id: string;
- name: string;
- description: string;
- systemPrompt: string;
- icon?: string;
- createdAt: string;
- updatedAt: string;
+ id: string
+ name: string
+ description: string
+ systemPrompt: string
+ icon?: string
+ createdAt: string
+ updatedAt: string
}
/** A file added to a project's knowledge base. */
export interface RagDocument {
- id: number;
- projectId: string;
- name: string;
- path: string;
- size: number;
- kind: MediaKind;
- createdAt: string;
- enabled: boolean;
+ id: number
+ projectId: string
+ name: string
+ path: string
+ size: number
+ kind: MediaKind
+ createdAt: string
+ enabled: boolean
}
/** One ranked excerpt returned from retrieval. */
export interface RagSearchResult {
- docId: number;
- name: string;
- content: string;
- position: number;
- score: number;
+ docId: number
+ name: string
+ content: string
+ position: number
+ score: number
}
/** The result of a knowledge-base query: ranked excerpts for `query`. */
export interface SearchResult {
- chunks: RagSearchResult[];
- query: string;
+ chunks: RagSearchResult[]
+ query: string
}
diff --git a/packages/rag/src/vectorMath.ts b/packages/rag/src/vectorMath.ts
index 8d32f6b7..ee99e73f 100644
--- a/packages/rag/src/vectorMath.ts
+++ b/packages/rag/src/vectorMath.ts
@@ -3,35 +3,39 @@
// for the brute-force search the RAG store does over a project's chunks.
export function dotProduct(a: number[], b: number[]): number {
- let dot = 0;
- const n = Math.min(a.length, b.length);
- for (let i = 0; i < n; i++) dot += a[i] * b[i];
- return dot;
+ let dot = 0
+ const n = Math.min(a.length, b.length)
+ for (let i = 0; i < n; i++) dot += a[i] * b[i]
+ return dot
}
export function cosineSimilarity(a: number[], b: number[]): number {
- let dot = 0;
- let normA = 0;
- let normB = 0;
- const n = Math.min(a.length, b.length);
+ let dot = 0
+ let normA = 0
+ let normB = 0
+ const n = Math.min(a.length, b.length)
for (let i = 0; i < n; i++) {
- dot += a[i] * b[i];
- normA += a[i] * a[i];
- normB += b[i] * b[i];
+ dot += a[i] * b[i]
+ normA += a[i] * a[i]
+ normB += b[i] * b[i]
}
- const denom = Math.sqrt(normA) * Math.sqrt(normB);
- return denom === 0 ? 0 : dot / denom;
+ const denom = Math.sqrt(normA) * Math.sqrt(normB)
+ return denom === 0 ? 0 : dot / denom
}
export interface SimilarityResult {
- index: number;
- score: number;
+ index: number
+ score: number
}
/** Top-k most similar candidate vectors to `query`, score-descending. */
-export function topKSimilar(query: number[], candidates: number[][], k: number): SimilarityResult[] {
+export function topKSimilar(
+ query: number[],
+ candidates: number[][],
+ k: number
+): SimilarityResult[] {
return candidates
.map((c, index) => ({ index, score: cosineSimilarity(query, c) }))
.sort((a, b) => b.score - a.score)
- .slice(0, k);
+ .slice(0, k)
}
diff --git a/playwright.config.ts b/playwright.config.ts
index fd2315ee..0680b43b 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,4 +1,4 @@
-import { defineConfig } from '@playwright/test';
+import { defineConfig } from '@playwright/test'
// Electron E2E. We drive the real app via Playwright's Electron support and read
// the renderer DOM directly (no OCR needed — it's a Chromium page). Single worker:
@@ -9,5 +9,5 @@ export default defineConfig({
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
- reporter: 'list',
-});
+ reporter: 'list'
+})
diff --git a/pr-targets.local.md b/pr-targets.local.md
index cf4cf425..4ec7f6b1 100644
--- a/pr-targets.local.md
+++ b/pr-targets.local.md
@@ -108,3 +108,33 @@ altstackHQ/altstack-data, AmineDjeghri/awesome-os-setup, Axorax/awesome-free-app
open-saas-directory/awesome-native-macosx-apps, momenbasel/awesome-mac-mini-homeserver, linsa-io/macos-apps,
EndoTheDev/Awesome-Ollama, BrethofAI/awesome-local-ai, jaywcjlove/awesome-swift-macos-apps,
Danielskry/Awesome-RAG, hasura/awesome-RAG-tools, coree/awesome-rag
+
+## RAISED (batch 5, 2026-07-10)
+- [x] AlexMili/Awesome-MCP — https://github.com/AlexMili/Awesome-MCP/pull/150
+- [x] raullenchai/awesome-mlx — https://github.com/raullenchai/awesome-mlx/pull/17
+- [x] Axorax/awesome-free-apps — https://github.com/Axorax/awesome-free-apps/pull/189
+- [x] e2b-dev/awesome-ai-agents — https://github.com/e2b-dev/awesome-ai-agents/pull/1226
+- [x] linsa-io/macos-apps — https://github.com/linsa-io/macos-apps/pull/62
+
+## RAISED (batch 6, 2026-07-15)
+- [x] serhii-londar/open-source-mac-os-apps — https://github.com/serhii-londar/open-source-mac-os-apps/pull/1199
+- [x] phmullins/awesome-macos — https://github.com/phmullins/awesome-macos/pull/217
+- [x] tehtbl/awesome-note-taking — https://github.com/tehtbl/awesome-note-taking/pull/109
+- [x] vince-lam/awesome-local-llms — https://github.com/vince-lam/awesome-local-llms/issues/55 (issue-first)
+- [x] abordage/awesome-mcp — https://github.com/abordage/awesome-mcp/pull/73
+- [x] IrtezaAsadRizvi/ai-megalist — https://github.com/IrtezaAsadRizvi/ai-megalist/pull/19
+- [x] altstackHQ/altstack-data — https://github.com/altstackHQ/altstack-data/pull/28
+
+## REVIEW REPLIES HANDLED
+- aristoapp/awesome-second-brain#32 — CHANGES_REQUESTED (rokpiy): rewrote row to make AGPL open-core vs paid Pro split explicit; replied. (2026-07-15)
+
+## SKIPPED / BLOCKED (batch 5-6)
+- appcypher/awesome-mcp-servers — BLOCKED: GitHub refuses PR from alichherawalla (interaction/permission). Branch staged: alichherawalla:add-off-grid-ai-desktop. Manual-file later.
+- punkpeye/awesome-mcp-devtools — server-devtools/SDK list, no client section (points clients to awesome-mcp-clients, already raised #245).
+- open-saas-directory/awesome-native-macosx-apps — excludes Electron apps.
+- sindresorhus/awesome-whisper — requires 100+ stars (repo has 38). Recheck when repo passes 100.
+- slimhk45/awesome-obsidian-alternatives, arkydon/Awesome-desktop-notes — relevance <3 (not an Obsidian/notes app).
+- electron/apps — DEFERRED: needs a STABLE release + 20-day wait; we only ship betas (0.0.39-beta.x) today.
+
+## GATING SIGNAL
+Repo off-grid-ai/off-grid-ai-desktop is at ~38 stars. Star-gated lists (awesome-whisper 100+, likely others) are blocked until the star count rises. Factor this before targeting large curated lists.
diff --git a/resources/tts-worker.mjs b/resources/tts-worker.mjs
index c1289fb0..ce0b0825 100644
--- a/resources/tts-worker.mjs
+++ b/resources/tts-worker.mjs
@@ -11,68 +11,68 @@
// tts-worker.mjs voices -> prints JSON array of voice ids to stdout
// tts-worker.mjs speak -> reads text from stdin, writes WAV to
-import fs from 'node:fs';
+import fs from 'node:fs'
-const MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';
-const DEFAULT_VOICE = 'af_heart';
+const MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX'
+const DEFAULT_VOICE = 'af_heart'
// kokoro-js' RawAudio.toWav() emits 32-bit IEEE-float WAV (format 3), which
// Chromium's