From a14e18d137abe710dae9077487e6b0c0e58e7560 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:12:33 -0700 Subject: [PATCH 01/21] chore(deps): bump actions/checkout from 4 to 6 (#275) Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 6 +++--- .github/workflows/codeql.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35a1a3ac..e4e22da6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: name: Lint (ruff) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v5 @@ -37,7 +37,7 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -58,7 +58,7 @@ jobs: run: working-directory: frontend-svelte steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c35861e4..8a86d2ef 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -32,7 +32,7 @@ jobs: build-mode: none steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v3 From 8c539706ca60096477d3cb41d6e77dd106512407 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:12:36 -0700 Subject: [PATCH 02/21] chore(deps): bump actions/setup-node from 4 to 6 (#276) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4e22da6..6e64b422 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,7 @@ jobs: - uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: npm From 987fd24ea3ba07a6910f387c8b7495f47c829956 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:12:38 -0700 Subject: [PATCH 03/21] chore(deps): bump actions/setup-python from 5 to 6 (#277) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e64b422..c0cc16b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v6 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" cache: pip @@ -40,7 +40,7 @@ jobs: - uses: actions/checkout@v6 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: pip From e07619d0dfa4b6f22e0669bb7e905a2f503770ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:12:41 -0700 Subject: [PATCH 04/21] chore(deps): bump the npm-minor-patch group (#278) Bumps the npm-minor-patch group in /frontend-svelte with 3 updates: [svelte-spa-router](https://github.com/ItalyPaleAle/svelte-spa-router), [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite). Updates `svelte-spa-router` from 5.0.0 to 5.0.1 - [Release notes](https://github.com/ItalyPaleAle/svelte-spa-router/releases) - [Changelog](https://github.com/ItalyPaleAle/svelte-spa-router/blob/main/CHANGELOG.md) - [Commits](https://github.com/ItalyPaleAle/svelte-spa-router/compare/v5.0.0...v5.0.1) Updates `svelte` from 5.55.0 to 5.55.4 - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.55.4/packages/svelte) Updates `vite` from 8.0.3 to 8.0.8 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.0.8/packages/vite) --- updated-dependencies: - dependency-name: svelte-spa-router dependency-version: 5.0.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: svelte dependency-version: 5.55.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch - dependency-name: vite dependency-version: 8.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: npm-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend-svelte/package-lock.json | 235 +++++++++++++----------------- frontend-svelte/package.json | 6 +- 2 files changed, 105 insertions(+), 136 deletions(-) diff --git a/frontend-svelte/package-lock.json b/frontend-svelte/package-lock.json index 74ffaa81..828749b1 100644 --- a/frontend-svelte/package-lock.json +++ b/frontend-svelte/package-lock.json @@ -9,47 +9,44 @@ "version": "0.0.0", "dependencies": { "svelte-i18n": "^4.0.1", - "svelte-spa-router": "^5.0.0" + "svelte-spa-router": "^5.0.1" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", - "svelte": "^5.53.12", - "vite": "^8.0.1" + "svelte": "^5.55.4", + "vite": "^8.0.8" } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -151,9 +148,9 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", - "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, @@ -170,9 +167,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.122.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", - "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -180,9 +177,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -197,9 +194,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -214,9 +211,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -231,9 +228,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", - "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -248,9 +245,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", - "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -265,16 +262,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -285,16 +279,13 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -305,16 +296,13 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -325,16 +313,13 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -345,16 +330,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", - "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -365,16 +347,13 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", - "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -385,9 +364,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", - "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -402,9 +381,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", - "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -412,16 +391,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -436,9 +417,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", - "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -453,9 +434,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", - "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -946,9 +927,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -970,9 +948,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -994,9 +969,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1018,9 +990,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1223,14 +1192,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", - "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.122.0", - "@rolldown/pluginutils": "1.0.0-rc.12" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -1239,21 +1208,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", - "@rolldown/binding-darwin-x64": "1.0.0-rc.12", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/sade": { @@ -1279,9 +1248,9 @@ } }, "node_modules/svelte": { - "version": "5.55.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", - "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "version": "5.55.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", + "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -1295,7 +1264,7 @@ "clsx": "^2.1.1", "devalue": "^5.6.4", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.4", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -1736,9 +1705,9 @@ } }, "node_modules/svelte-spa-router": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-5.0.0.tgz", - "integrity": "sha512-4F2irDGf2CkPxAz/tr/GwnAErZuG9l8J3ClHo2QfKPh0Ul4j9K9R3q91RaoekXZzJW90NHMY+dKLM9eF6vdcyA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/svelte-spa-router/-/svelte-spa-router-5.0.1.tgz", + "integrity": "sha512-AU/4rosuCnkMLqVYV+HRYP/jLVBAhGzeIUaPGaSmFj7wLfyfZeW18iSiYlMEj2y4Uh3+8GYUt3tvObJiBmgzyA==", "license": "MIT", "dependencies": { "regexparam": "2.0.2" @@ -1800,16 +1769,16 @@ "license": "ISC" }, "node_modules/vite": { - "version": "8.0.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", - "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.12", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -1827,7 +1796,7 @@ "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", diff --git a/frontend-svelte/package.json b/frontend-svelte/package.json index 87e7f153..729bd439 100644 --- a/frontend-svelte/package.json +++ b/frontend-svelte/package.json @@ -11,11 +11,11 @@ }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.0.0", - "svelte": "^5.53.12", - "vite": "^8.0.1" + "svelte": "^5.55.4", + "vite": "^8.0.8" }, "dependencies": { "svelte-i18n": "^4.0.1", - "svelte-spa-router": "^5.0.0" + "svelte-spa-router": "^5.0.1" } } From 2c538168d0026bf8825975772a93b7d8aeb95fca Mon Sep 17 00:00:00 2001 From: olegbrok Date: Sun, 19 Apr 2026 06:33:03 -0700 Subject: [PATCH 05/21] feat(presence): server-side last_seen stamping for non-CC agents (#280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(presence): server-side last_seen stamping for non-CC agents (#279) Non-CC agents (Codex, GLM) never call the send_heartbeat MCP tool, so their presence showed stale despite active streaming sessions. Stamp agents.last_seen_at server-side on every evidence of a working pipe: broker delivery (inbound + inter-agent), and turn completion/error in both StreamingSession and CodexSession. - Add last_seen_at column + Agent.last_seen_at field + stamp_last_seen() - _agent_presence takes max(server_ts, heartbeat_ts) — heartbeat path preserved so CC agents keep working unchanged - Plumb AgentRegistry into StreamingSession/CodexSession via new `registry` kwarg (mirrors analytics_store pattern) Co-Authored-By: Claude Opus 4 * fix(presence): honor server stamp in status + stamp on codex error event Addresses murzik's PR #280 review findings: 1. _agent_presence() status branch previously returned "unknown" for any agent without a heartbeat — even when last_seen_at was recently stamped by server-side activity. Status now falls through to server_ts when no fresher heartbeat exists, using the same online/idle/offline age bands. Heartbeat logic still wins when hb_ts >= server_ts so dead/non-alive heartbeats aren't masked by an older server stamp. 2. codex_session "error" event path now stamps last_seen. Previously only turn.completed and turn.failed stamped, so transport/runtime errors surfaced via the generic error event missed a real liveness signal. Tests: - tests/test_api.py — 5 new presence tests (server-stamped online/idle/ offline, no-stamp-no-hb unknown, heartbeat-wins-when-fresher) - tests/test_codex_session.py — 3 new stamp tests (error event stamps, plus regression guards for turn.completed and turn.failed) 1637 passed, ruff clean. Co-Authored-By: Claude Opus 4 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_daemon/agent_registry.py | 12 +++++- src/pinky_daemon/api.py | 23 ++++++++-- src/pinky_daemon/broker.py | 10 +++++ src/pinky_daemon/codex_session.py | 14 ++++++ src/pinky_daemon/streaming_session.py | 14 ++++++ tests/test_agent_registry.py | 22 ++++++++++ tests/test_api.py | 61 +++++++++++++++++++++++++++ tests/test_broker.py | 32 ++++++++++++++ tests/test_codex_session.py | 60 ++++++++++++++++++++++++++ 9 files changed, 244 insertions(+), 4 deletions(-) diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py index 9dbf4e9d..bd9e823b 100644 --- a/src/pinky_daemon/agent_registry.py +++ b/src/pinky_daemon/agent_registry.py @@ -184,6 +184,7 @@ class Agent: retired_at: float = 0.0 # When was this agent retired working_status: str = "idle" # idle, working, offline working_status_updated_at: float = 0.0 # When working_status last changed + last_seen_at: float = 0.0 # Server-side presence: updated on delivery/turn completion provider_url: str = "" # e.g. "http://localhost:11434" for Ollama, empty = Anthropic default provider_key: str = "" # API key override, empty = use ANTHROPIC_API_KEY env var provider_model: str = "" # model name override (e.g. "llama3.2"), empty = use agent.model @@ -241,6 +242,7 @@ def to_dict(self) -> dict: "retired_at": self.retired_at, "working_status": self.working_status, "working_status_updated_at": self.working_status_updated_at, + "last_seen_at": self.last_seen_at, "provider_url": self.provider_url, "provider_key": self.provider_key, "provider_model": self.provider_model, @@ -726,6 +728,7 @@ def _migrate(self) -> None: ("disallowed_tools", "TEXT NOT NULL DEFAULT '[]'"), ("thinking_effort", "TEXT NOT NULL DEFAULT 'medium'"), ("watchdog_config", "TEXT NOT NULL DEFAULT '{}'"), + ("last_seen_at", "REAL NOT NULL DEFAULT 0"), ] for col, typedef in migrations: if col not in existing: @@ -1058,7 +1061,7 @@ def register(self, name: str, **kwargs) -> Agent: "librarian_enabled, librarian_schedule, " "working_status, working_status_updated_at, " "provider_url, provider_key, provider_model, provider_ref, " - "disallowed_tools, thinking_effort, watchdog_config" + "disallowed_tools, thinking_effort, watchdog_config, last_seen_at" ) def get(self, name: str) -> Agent | None: @@ -1127,6 +1130,12 @@ def restore(self, name: str) -> bool: return True return False + def stamp_last_seen(self, name: str, ts: float | None = None) -> None: + """Server-side presence: stamp agents.last_seen_at. Agent-agnostic.""" + ts = ts if ts is not None else time.time() + self._db.execute("UPDATE agents SET last_seen_at = ? WHERE name = ?", (ts, name)) + self._db.commit() + def set_working_status(self, name: str, status: str) -> bool: """Update an agent's working status (idle, working, offline).""" if status not in ("idle", "working", "offline"): @@ -2581,6 +2590,7 @@ def _row_to_agent(self, row: tuple) -> Agent: disallowed_tools=json.loads(row[43]) if len(row) > 43 and row[43] else [], thinking_effort=row[44] if len(row) > 44 and row[44] else "medium", watchdog_config=json.loads(row[45]) if len(row) > 45 and row[45] else {}, + last_seen_at=row[46] if len(row) > 46 else 0.0, ) # ── Cost Tracking ────────────────────────────────────── diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index 27669145..edad45cd 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -2491,6 +2491,7 @@ async def _start_streaming_session( "conversation_store": store, "cost_callback": _make_cost_callback(agents), "analytics_store": analytics, + "registry": agents, } if is_codex: init_kwargs["stream_event_callback"] = await _make_streaming_event_callback(agent_name, label) @@ -5173,19 +5174,35 @@ def _agent_presence(agent_name: str) -> dict: live = broker.get_live_agents() streaming = agent_name in live hb = agents.get_latest_heartbeat(agent_name) + agent_obj = agents.get(agent_name) + hb_ts = hb.timestamp if hb else 0 + server_ts = agent_obj.last_seen_at if agent_obj else 0 + last_seen = max(server_ts, hb_ts) + now = time.time() if streaming: status = "online" - elif hb: - age = time.time() - hb.timestamp + elif hb and hb_ts >= server_ts: + # Heartbeat is the freshest liveness signal — honor its status field. + age = now - hb.timestamp if hb.status == "alive" and age < 300: status = "online" elif hb.status == "alive" and age < 1800: status = "idle" else: status = "offline" + elif server_ts > 0: + # Server-stamped activity (turn completion, delivered inter-agent msg, etc.) + # is authoritative liveness evidence for agents that don't emit heartbeats. + age = now - server_ts + if age < 300: + status = "online" + elif age < 1800: + status = "idle" + else: + status = "offline" else: status = "unknown" - return {"status": status, "streaming": streaming, "last_seen": hb.timestamp if hb else 0} + return {"status": status, "streaming": streaming, "last_seen": last_seen} @app.get("/agents/presence") async def get_all_agent_presence(): diff --git a/src/pinky_daemon/broker.py b/src/pinky_daemon/broker.py index b5eab626..94b9ae5c 100644 --- a/src/pinky_daemon/broker.py +++ b/src/pinky_daemon/broker.py @@ -808,6 +808,11 @@ async def _route_streaming(self, agent_name: str, message: BrokerMessage) -> Non message_id=message.message_id, agent_hint=hint, ) + # Server-side presence: successful inbound delivery = agent pipe is working + try: + self._registry.stamp_last_seen(agent_name) + except Exception as e: + _log(f"broker: stamp_last_seen failed for {agent_name}: {e}") # Start typing indicator for Telegram chats if message.chat_id: await self._start_typing(agent_name, message.platform, message.chat_id, streaming) @@ -828,6 +833,11 @@ async def inject_agent_message( ts = datetime.now(tz.utc).strftime("%Y-%m-%d %H:%M:%S UTC") prompt = f"[agent | {from_agent} | internal | {ts}]\n{message}" await streaming.send(prompt) + # Server-side presence: successful delivery = agent is reachable + try: + self._registry.stamp_last_seen(to_agent) + except Exception as e: + _log(f"broker: stamp_last_seen failed for {to_agent}: {e}") self._stats["routed"] += 1 _log(f"broker: injected agent message {from_agent} -> {to_agent}") return True diff --git a/src/pinky_daemon/codex_session.py b/src/pinky_daemon/codex_session.py index 4d99a1f2..ae1dc5bf 100644 --- a/src/pinky_daemon/codex_session.py +++ b/src/pinky_daemon/codex_session.py @@ -62,6 +62,7 @@ def __init__( cost_callback=None, # fn(agent_name, cost_usd, input_tokens, output_tokens, session_id) stream_event_callback=None, # async fn(event: dict) for incremental UI streaming analytics_store=None, + registry=None, # AgentRegistry — for server-side presence stamping ) -> None: self._config = config self._response_callback = response_callback @@ -69,6 +70,7 @@ def __init__( self._conversation_store = conversation_store self._stream_event_callback = stream_event_callback self._analytics_store = analytics_store + self._registry = registry self._connected = False self._processing = False # True while a codex exec is running self._message_queue: asyncio.Queue[tuple[str, str, str, str]] = asyncio.Queue() @@ -680,6 +682,7 @@ async def _handle_event(self, event: dict, result: CodexTurnResult) -> None: "cached_input_tokens": result.cached_input_tokens, }, ) + self._stamp_last_seen() await self._emit_stream_event({ "type": "turn_completed", "agent": self.agent_name, @@ -697,6 +700,7 @@ async def _handle_event(self, event: dict, result: CodexTurnResult) -> None: err_msg = err.get("message", "turn failed") result.errors.append(err_msg) self._analytics_log_activity("turn_failed", metadata={"error": err_msg}) + self._stamp_last_seen() await self._emit_stream_event({ "type": "turn_failed", "agent": self.agent_name, @@ -708,6 +712,7 @@ async def _handle_event(self, event: dict, result: CodexTurnResult) -> None: err_msg = event.get("message", "unknown error") result.errors.append(err_msg) self._analytics_log_activity("turn_error", metadata={"error": err_msg}) + self._stamp_last_seen() await self._emit_stream_event({ "type": "turn_error", "agent": self.agent_name, @@ -933,6 +938,15 @@ def _analytics_log_activity(self, event_type: str, *, metadata: dict | None = No except Exception as e: _log(f"codex[{self.agent_name}]: analytics activity failed: {e}") + def _stamp_last_seen(self) -> None: + """Server-side presence: stamp agent last_seen_at (agent-agnostic).""" + if not self._registry: + return + try: + self._registry.stamp_last_seen(self.agent_name) + except Exception as e: + _log(f"codex[{self.agent_name}]: stamp_last_seen failed: {e}") + def _analytics_log_turn_usage( self, *, diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py index fd910242..d0c6f79f 100644 --- a/src/pinky_daemon/streaming_session.py +++ b/src/pinky_daemon/streaming_session.py @@ -173,12 +173,14 @@ def __init__( conversation_store=None, # ConversationStore for history logging cost_callback=None, # fn(agent_name, cost_usd, input_tokens, output_tokens, session_id) analytics_store=None, + registry=None, # AgentRegistry — for server-side presence stamping ) -> None: self._config = config self._response_callback = response_callback self._cost_callback = cost_callback # Sync callback to persist costs self._conversation_store = conversation_store self._analytics_store = analytics_store + self._registry = registry self._client = None self._reader_task: asyncio.Task | None = None self._connected = False @@ -548,6 +550,7 @@ async def _reader_loop(self) -> None: "errors": str(msg.errors or "")[:200], }, ) + self._stamp_last_seen() self._last_response = "" self._current_activity = "" self._activity_log = [] @@ -663,6 +666,8 @@ async def _reader_loop(self) -> None: "cached_input_tokens": agg_cached, }, ) + # Stamp on any turn end (complete or error) — proves pipe is live + self._stamp_last_seen() # Log assistant response to conversation store with metadata if self._last_response and self._conversation_store: @@ -960,6 +965,15 @@ def _analytics_log_activity( except Exception as e: _log(f"streaming[{self.agent_name}]: analytics activity failed: {e}") + def _stamp_last_seen(self) -> None: + """Server-side presence: stamp agent last_seen_at (agent-agnostic).""" + if not self._registry: + return + try: + self._registry.stamp_last_seen(self.agent_name) + except Exception as e: + _log(f"streaming[{self.agent_name}]: stamp_last_seen failed: {e}") + def _analytics_log_turn_usage( self, *, diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index 6dca4166..286d7f43 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -128,6 +128,28 @@ def test_to_dict(self, registry): assert d["model"] == "opus" assert d["enabled"] is True + def test_stamp_last_seen_updates_column(self, registry): + registry.register("seen") + agent = registry.get("seen") + assert agent.last_seen_at == 0.0 + + registry.stamp_last_seen("seen", ts=1234567.0) + updated = registry.get("seen") + assert updated.last_seen_at == 1234567.0 + + def test_stamp_last_seen_default_ts_is_now(self, registry): + import time as _time + registry.register("seen2") + before = _time.time() + registry.stamp_last_seen("seen2") + after = _time.time() + updated = registry.get("seen2") + assert before <= updated.last_seen_at <= after + + def test_stamp_last_seen_missing_agent_is_noop(self, registry): + # Should not raise — just affects zero rows. + registry.stamp_last_seen("ghost", ts=42.0) + class TestDirectives: def test_add_directive(self, registry): diff --git a/tests/test_api.py b/tests/test_api.py index 3efc07f7..5f6730f8 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1438,6 +1438,67 @@ def test_all_agents_presence(self): data = resp.json() assert "agents" in data + def _make_client_with_registry(self): + """Return (client, registry) both pointing at the same in-api agents.db.""" + from pinky_daemon.agent_registry import AgentRegistry + from pinky_daemon.api import create_api + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + app = create_api(max_sessions=10, default_working_dir="/tmp", db_path=path) + # create_api splits storage: agents live in {db_path}_agents.db + registry = AgentRegistry(db_path=path.replace(".db", "_agents.db")) + return TestClient(app), registry + + def test_agent_presence_server_stamped_online(self): + """Non-heartbeat agent with fresh last_seen_at should be online, not unknown.""" + client, registry = self._make_client_with_registry() + client.post("/agents", json={"name": "codex-a", "model": "opus"}) + registry.stamp_last_seen("codex-a", ts=time.time()) + resp = client.get("/agents/codex-a/presence") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "online", f"expected online, got {data['status']}" + assert data["last_seen"] > 0 + assert data["streaming"] is False + + def test_agent_presence_server_stamped_idle(self): + """Non-heartbeat agent stamped 10min ago should be idle.""" + client, registry = self._make_client_with_registry() + client.post("/agents", json={"name": "codex-b", "model": "opus"}) + registry.stamp_last_seen("codex-b", ts=time.time() - 600) + resp = client.get("/agents/codex-b/presence") + data = resp.json() + assert data["status"] == "idle", f"expected idle, got {data['status']}" + + def test_agent_presence_server_stamped_offline(self): + """Non-heartbeat agent stamped 1hr ago should be offline.""" + client, registry = self._make_client_with_registry() + client.post("/agents", json={"name": "codex-c", "model": "opus"}) + registry.stamp_last_seen("codex-c", ts=time.time() - 3600) + resp = client.get("/agents/codex-c/presence") + data = resp.json() + assert data["status"] == "offline", f"expected offline, got {data['status']}" + + def test_agent_presence_no_stamp_no_heartbeat_is_unknown(self): + """Preserve existing behavior: no server stamp and no heartbeat → unknown.""" + client = self._make_client() + client.post("/agents", json={"name": "ghost", "model": "sonnet"}) + resp = client.get("/agents/ghost/presence") + data = resp.json() + assert data["status"] == "unknown" + + def test_agent_presence_heartbeat_wins_when_fresher(self): + """If heartbeat is fresher than server stamp, heartbeat status logic applies.""" + client, registry = self._make_client_with_registry() + client.post("/agents", json={"name": "cc-agent", "model": "sonnet"}) + # Old server stamp + registry.stamp_last_seen("cc-agent", ts=time.time() - 3600) + # Fresh heartbeat — should override server stamp + registry.record_heartbeat("cc-agent", status="alive") + resp = client.get("/agents/cc-agent/presence") + data = resp.json() + assert data["status"] == "online" + def test_agent_directives_crud(self): client = self._make_client() client.post("/agents", json={"name": "alice", "model": "sonnet"}) diff --git a/tests/test_broker.py b/tests/test_broker.py index 9f05a8ba..910ce97c 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -94,6 +94,38 @@ async def test_route_response_skips_plain_text_when_outreach_used(self): finally: tmpdir.cleanup() + @pytest.mark.asyncio + async def test_inject_agent_message_stamps_last_seen_on_success(self): + tmpdir, registry, broker, _, _ = self._make_broker() + try: + class _FakeStreaming: + is_connected = True + sent: list[str] = [] + + async def send(self, prompt: str) -> None: + _FakeStreaming.sent.append(prompt) + + broker.register_streaming("barsik", _FakeStreaming(), label="main") + assert registry.get("barsik").last_seen_at == 0.0 + + ok = await broker.inject_agent_message("pushok", "barsik", "hi") + assert ok is True + assert registry.get("barsik").last_seen_at > 0.0 + assert _FakeStreaming.sent # delivery happened + finally: + tmpdir.cleanup() + + @pytest.mark.asyncio + async def test_inject_agent_message_does_not_stamp_when_not_connected(self): + tmpdir, registry, broker, _, _ = self._make_broker() + try: + # No streaming session registered — inject should fail without stamping. + ok = await broker.inject_agent_message("pushok", "barsik", "hi") + assert ok is False + assert registry.get("barsik").last_seen_at == 0.0 + finally: + tmpdir.cleanup() + def test_remember_message_context_tracks_voice_and_reply_metadata(self): tmpdir, _, broker, _, _ = self._make_broker() try: diff --git a/tests/test_codex_session.py b/tests/test_codex_session.py index d75680f8..d89e45a6 100644 --- a/tests/test_codex_session.py +++ b/tests/test_codex_session.py @@ -164,6 +164,66 @@ async def test_error_item(self): r = await self._parse_events(events) assert "bad model" in r.errors + @pytest.mark.asyncio + async def test_error_event_stamps_last_seen(self): + """Transport/runtime errors (event_type=error) should stamp last_seen — + it's a real liveness signal even when no turn.failed fires.""" + stamps: list[str] = [] + + class _MockRegistry: + def stamp_last_seen(self, name: str, ts: float | None = None) -> None: + stamps.append(name) + + config = StreamingSessionConfig( + agent_name="test-agent", working_dir="/tmp", provider_url="codex_cli", + ) + session = CodexSession(config, registry=_MockRegistry()) + result = CodexTurnResult() + await session._handle_event( + {"type": "error", "message": "transport closed"}, result, + ) + assert stamps == ["test-agent"] + assert "transport closed" in result.errors[0] + + @pytest.mark.asyncio + async def test_turn_completed_stamps_last_seen(self): + """Regression guard: turn.completed continues to stamp.""" + stamps: list[str] = [] + + class _MockRegistry: + def stamp_last_seen(self, name: str, ts: float | None = None) -> None: + stamps.append(name) + + config = StreamingSessionConfig( + agent_name="test-agent", working_dir="/tmp", provider_url="codex_cli", + ) + session = CodexSession(config, registry=_MockRegistry()) + result = CodexTurnResult() + await session._handle_event( + {"type": "turn.completed", "usage": {"input_tokens": 5, "output_tokens": 2}}, + result, + ) + assert stamps == ["test-agent"] + + @pytest.mark.asyncio + async def test_turn_failed_stamps_last_seen(self): + """Regression guard: turn.failed continues to stamp.""" + stamps: list[str] = [] + + class _MockRegistry: + def stamp_last_seen(self, name: str, ts: float | None = None) -> None: + stamps.append(name) + + config = StreamingSessionConfig( + agent_name="test-agent", working_dir="/tmp", provider_url="codex_cli", + ) + session = CodexSession(config, registry=_MockRegistry()) + result = CodexTurnResult() + await session._handle_event( + {"type": "turn.failed", "error": {"message": "rate limited"}}, result, + ) + assert stamps == ["test-agent"] + class TestCodexSessionSendDrop: """Verify send() drops messages when not connected.""" From 37a303bd27a30a6b5e5e6ff4b9e9a764cd8f2ea4 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 03:14:09 -0700 Subject: [PATCH 06/21] fix(memory): distinguish zero-norm query embedding from empty results (#285) (#297) search_by_embedding silently returned [] when the query embedding had zero norm, making it impossible for callers to distinguish a broken embedding from a legitimate 'no matches found' result. - Add InvalidQueryEmbeddingError(ValueError) in pinky_memory.store. - search_by_embedding_scored raises on empty or zero-norm queries. - Inner _search_by_numpy keeps a defense-in-depth raise on zero-norm. - MCP recall() in pinky_memory.server catches and logs the error before falling back to keyword search, so broken embeddings surface in logs instead of vanishing. - Add regression tests covering zero-norm and empty query embeddings. Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_memory/server.py | 29 ++++++++++++++++++--------- src/pinky_memory/store.py | 41 +++++++++++++++++++++++++++++++++++++- tests/test_memory_store.py | 23 ++++++++++++++++++++- 3 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/pinky_memory/server.py b/src/pinky_memory/server.py index c37498a1..f155b4ae 100644 --- a/src/pinky_memory/server.py +++ b/src/pinky_memory/server.py @@ -7,6 +7,7 @@ from mcp.server.fastmcp import FastMCP +from pinky_memory.store import InvalidQueryEmbeddingError from pinky_memory.types import ( PRESET_NAMES, IntrospectInput, @@ -153,15 +154,25 @@ def recall( # Try vector search first query_embedding = embedder.embed(input_data.query) if query_embedding: - results = s.search_by_embedding( - query_embedding=query_embedding, - limit=input_data.limit, - active_only=input_data.active_only, - type_filter=input_data.type, - project_filter=input_data.project, - min_weight=input_data.min_weight, - entity_filter=input_data.entity, - ) + try: + results = s.search_by_embedding( + query_embedding=query_embedding, + limit=input_data.limit, + active_only=input_data.active_only, + type_filter=input_data.type, + project_filter=input_data.project, + min_weight=input_data.min_weight, + entity_filter=input_data.entity, + ) + except InvalidQueryEmbeddingError as exc: + # Broken query embedding (zero-norm/empty). Log loudly and + # fall back to keyword search so the user still gets a + # meaningful response instead of a silent empty result. + _log( + f"[recall] invalid query embedding ({exc}); " + f"falling back to keyword search" + ) + results = [] # Fall back to keyword search if no vector results if not results: diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py index 010743ed..9fd16624 100644 --- a/src/pinky_memory/store.py +++ b/src/pinky_memory/store.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import logging import shutil import sqlite3 import struct @@ -23,6 +24,18 @@ if TYPE_CHECKING: pass +logger = logging.getLogger(__name__) + + +class InvalidQueryEmbeddingError(ValueError): + """Raised when a query embedding cannot be used for similarity search. + + Distinguishes a broken/degenerate query (all zeros, empty, wrong shape) + from a legitimate 'no matches found' result. Callers should catch and + either log + fall back to keyword search or surface to the user. + """ + + # ── Memory linking constants ── LINK_THRESHOLD = 0.78 @@ -538,7 +551,26 @@ def search_by_embedding_scored( final_score = similarity * weight * (recency_factor ^ hours_since_access) If access_boost > 0, boosts weight on each accessed reflection. If entity_filter is set, only returns reflections tagged with that entity. + + Raises: + InvalidQueryEmbeddingError: if query_embedding is empty or has zero + norm. Distinguishes a broken embedding from a legitimate + "no matches" empty result. """ + if not query_embedding: + logger.warning("search_by_embedding_scored: empty query embedding") + raise InvalidQueryEmbeddingError("query embedding is empty") + + query_norm = float(np.linalg.norm(np.asarray(query_embedding, dtype=np.float32))) + if query_norm == 0.0: + logger.warning( + "search_by_embedding_scored: zero-norm query embedding (len=%d)", + len(query_embedding), + ) + raise InvalidQueryEmbeddingError( + "query embedding has zero norm; cannot compute cosine similarity" + ) + with self._lock: if ( self._vec_available @@ -719,7 +751,14 @@ def _search_by_numpy( query_vec = np.array(query_embedding, dtype=np.float32) query_norm = np.linalg.norm(query_vec) if query_norm == 0: - return [] + logger.warning( + "search_by_embedding: zero-norm query embedding " + "(len=%d) — refusing to conflate with 'no matches'", + len(query_embedding), + ) + raise InvalidQueryEmbeddingError( + "query embedding has zero norm; cannot compute cosine similarity" + ) now_dt = datetime.now(timezone.utc) now = now_dt.isoformat() diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index 293811f2..2610ff06 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -4,7 +4,9 @@ from datetime import datetime, timedelta, timezone from pathlib import Path -from pinky_memory.store import ReflectionStore +import pytest + +from pinky_memory.store import InvalidQueryEmbeddingError, ReflectionStore from pinky_memory.types import MemoryQueryFilters, Reflection, ReflectionType # ── Helpers ──────────────────────────────────────────────────────────────────── @@ -284,6 +286,25 @@ def test_search_excludes_no_recall(self, tmp_path): results = store.search_by_embedding(emb) assert all(x.id != r.id for x in results) + def test_zero_norm_query_raises(self, tmp_path): + """A zero-norm query embedding must raise — not silently return []. + + Regression for #285: zero-norm previously returned [] which could + not be distinguished from a legitimate 'no matches' result. + """ + store = _store(tmp_path) + store.insert(_fact("some content", embedding=_emb())) + with pytest.raises(InvalidQueryEmbeddingError): + store.search_by_embedding([0.0] * 8) + with pytest.raises(InvalidQueryEmbeddingError): + store.search_by_embedding_scored([0.0] * 8) + + def test_empty_query_embedding_raises(self, tmp_path): + """An empty query embedding must raise — regression for #285.""" + store = _store(tmp_path) + with pytest.raises(InvalidQueryEmbeddingError): + store.search_by_embedding_scored([]) + # ── Near-Duplicate Detection ─────────────────────────────────────────────────── From 84a4ab486448eb781e860188f9ee4674f208c7b6 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 03:14:12 -0700 Subject: [PATCH 07/21] fix(outreach): let httpx set Discord multipart content type (#283) (#298) * fix(outreach): let httpx set Discord multipart content type (#283) Discord file uploads crashed because the adapter set `Content-Type: application/json` as a Client-level default, then tried to override it per-request with `headers={"Content-Type": None}`. httpx rejects `None` as a header value and raises before sending, so DiscordAdapter.send_file() never worked. - Drop the Client-level Content-Type default; httpx now sets the correct content type per request (application/json for json= bodies, multipart/form-data with boundary for files= bodies). - Remove the invalid `Content-Type: None` override in send_file(). - Add a regression test that exercises the real send_file() path via httpx.MockTransport and asserts a proper multipart request is emitted. Co-Authored-By: Murzik Co-Authored-By: Claude Opus 4 * style: ruff isort fix for test_discord.py Co-Authored-By: Claude Opus 4 --------- Co-authored-by: Oleg Co-authored-by: Murzik Co-authored-by: Claude Opus 4 --- src/pinky_outreach/discord.py | 2 -- tests/test_discord.py | 40 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/pinky_outreach/discord.py b/src/pinky_outreach/discord.py index d4b9fa0c..1d73d6df 100644 --- a/src/pinky_outreach/discord.py +++ b/src/pinky_outreach/discord.py @@ -37,7 +37,6 @@ def __init__(self, bot_token: str, *, timeout: float = 30.0) -> None: base_url=self.BASE_URL, headers={ "Authorization": f"Bot {bot_token}", - "Content-Type": "application/json", }, timeout=timeout, ) @@ -108,7 +107,6 @@ def send_file( f"/channels/{channel_id}/messages", data={"content": content} if content else {}, files={"files[0]": (filename, f)}, - headers={"Content-Type": None}, # Let httpx set multipart headers ) if resp.status_code >= 400: diff --git a/tests/test_discord.py b/tests/test_discord.py index a9552e83..4de93eb2 100644 --- a/tests/test_discord.py +++ b/tests/test_discord.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock +import httpx import pytest from pinky_outreach.discord import DiscordAdapter, DiscordError @@ -83,6 +84,45 @@ def test_send_message_error(self): assert exc.value.status_code == 403 adapter.close() + def test_send_file_success_uses_httpx_multipart_header(self, tmp_path): + """Regression: passing Content-Type=None makes httpx raise before sending.""" + adapter = self._make_adapter() + file_path = tmp_path / "note.txt" + file_path.write_text("hello discord") + seen_requests = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen_requests.append(request) + body = request.read() + assert request.method == "POST" + assert request.url.path.endswith("/channels/99999/messages") + assert request.headers["content-type"].startswith("multipart/form-data; boundary=") + assert b'name="files[0]"' in body + assert b'filename="note.txt"' in body + assert b"hello discord" in body + return httpx.Response( + 200, + json={ + "id": "file-msg-1", + "channel_id": "99999", + "content": "upload caption", + "timestamp": "2026-03-27T12:02:00+00:00", + }, + ) + + adapter._client.close() + adapter._client = httpx.Client( + base_url=adapter.BASE_URL, + headers={"Authorization": "Bot fake-discord-token"}, + transport=httpx.MockTransport(handler), + ) + + msg = adapter.send_file("99999", str(file_path), content="upload caption") + assert msg.message_id == "file-msg-1" + assert msg.content == "upload caption" + assert len(seen_requests) == 1 + adapter.close() + def test_get_messages_empty(self): adapter = self._make_adapter() mock_response = MagicMock() From 4f3521ee26c565884f4e33a2b74f8564fd5257fd Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:28:28 -0700 Subject: [PATCH 08/21] fix(memory): validate and log embed() failures on reflect/recall (#291) (#299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(memory): validate and log embed() failures on reflect/recall (#291) embed() failures (empty returns or raised exceptions) previously stored empty embeddings silently. Downstream semantic search quietly missed those memories with no signal that anything was wrong. - Add _safe_embed() helper in pinky_memory.server that: - Skips the call entirely for NoOpEmbeddingClient (expected downgrade) - Catches exceptions from real embedders and logs with type + message - Logs a "degraded" warning when a non-NoOp embedder returns [] - reflect() now surfaces `embedded: bool` in its JSON response so callers can see when a memory was stored without a usable vector. - recall() routes through the same helper — failures no longer masquerade as "no matches"; keyword fallback proceeds with a visible log entry. - Add regression tests for: NoOp embedder, real embedder returning value, real embedder returning [] (degraded log), and embedder raising. Co-Authored-By: Claude Opus 4 * fix(memory): treat zero-norm and non-finite embeddings as degraded (#291) Murzik review: a real embedder returning a non-empty but zero-norm (e.g., [0.0, ..., 0.0]) or non-finite (NaN/inf) vector passed the previous `if not embedding` check, so reflect() reported embedded=True and stored an unusable vector. Downstream search then silently skipped those rows via the existing zero-norm row guard in _search_by_numpy — reproducing the original silent-degrade bug with different inputs. - Extend _safe_embed() to validate non-empty vectors: zero-norm or any non-finite component → log "degenerate" and return [] so reflect() reports embedded=False and stores no vector. - Add regression tests for [0.0]*8 and NaN-containing embeddings. Co-Authored-By: Claude Opus 4 Co-Authored-By: Murzik --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4 Co-authored-by: Murzik --- src/pinky_memory/server.py | 69 ++++++++++++++++++++++++++--- tests/test_memory_server.py | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 5 deletions(-) diff --git a/src/pinky_memory/server.py b/src/pinky_memory/server.py index f155b4ae..658d39a3 100644 --- a/src/pinky_memory/server.py +++ b/src/pinky_memory/server.py @@ -1,12 +1,14 @@ from __future__ import annotations import json +import math import sys from collections.abc import Callable from typing import TYPE_CHECKING from mcp.server.fastmcp import FastMCP +from pinky_memory.embeddings import NoOpEmbeddingClient from pinky_memory.store import InvalidQueryEmbeddingError from pinky_memory.types import ( PRESET_NAMES, @@ -19,7 +21,7 @@ ) if TYPE_CHECKING: - from pinky_memory.embeddings import EmbeddingClient, NoOpEmbeddingClient + from pinky_memory.embeddings import EmbeddingClient from pinky_memory.store import ReflectionStore @@ -28,6 +30,61 @@ def _log(msg: str) -> None: print(msg, file=sys.stderr, flush=True) +def _safe_embed( + embedder: "EmbeddingClient | NoOpEmbeddingClient", + text: str, + op: str, +) -> list[float]: + """Call embedder.embed() with loud failure logging. + + Returns the embedding vector, or [] on failure. Distinguishes four + cases for the logs: + - NoOp embedder (no OPENAI_API_KEY): silent, expected downgrade + - embed() raised: log with exception type + message + - embed() returned [] from a non-NoOp client: log as degraded + - embed() returned a zero-norm or non-finite vector: log as degraded + and coerce to [] so downstream search can treat it as "no embedding" + rather than silently storing/querying an unusable vector + (see #285 zero-norm query handling). + """ + if isinstance(embedder, NoOpEmbeddingClient): + return [] + try: + embedding = embedder.embed(text) + except Exception as exc: # noqa: BLE001 — we want any embed failure visible + _log(f"[{op}] embedder.embed() raised {type(exc).__name__}: {exc}") + return [] + if not embedding: + _log( + f"[{op}] embedder.embed() returned empty vector with non-NoOp client — " + f"embeddings are degraded; semantic search will miss this entry" + ) + return [] + # Guard against non-empty-but-unusable vectors: all zeros, NaN, or inf. + # These pass `if not embedding` but are semantically equivalent to a + # failed embed — storing them creates rows that zero-norm-skip in search + # (see ReflectionStore._search_by_numpy) with no signal upstream. + try: + norm_sq = 0.0 + has_bad = False + for x in embedding: + if not math.isfinite(x): + has_bad = True + break + norm_sq += x * x + if has_bad or norm_sq == 0.0: + _log( + f"[{op}] embedder.embed() returned a degenerate vector " + f"(zero-norm or non-finite) — treating as degraded; " + f"semantic search will miss this entry" + ) + return [] + except Exception as exc: # noqa: BLE001 — defensive + _log(f"[{op}] embedding validation raised {type(exc).__name__}: {exc}") + return [] + return embedding + + def create_server( store: ReflectionStore | None = None, embedder: EmbeddingClient | NoOpEmbeddingClient | None = None, @@ -90,8 +147,9 @@ def reflect( source_message_ids=source_message_ids or [], ) - # Generate embedding - embedding = embedder.embed(input_data.content) + # Generate embedding (tolerant: empty / raised embeddings downgrade + # to keyword-only search rather than aborting storage). + embedding = _safe_embed(embedder, input_data.content, "reflect") # Build reflection ref = Reflection( @@ -122,6 +180,7 @@ def reflect( "type": ref.type.value, "salience": ref.salience, "stored": True, + "embedded": bool(embedding), }) @mcp.tool() @@ -151,8 +210,8 @@ def recall( s = _get_store() if input_data.query: - # Try vector search first - query_embedding = embedder.embed(input_data.query) + # Try vector search first (tolerant of embedder failures / degrades). + query_embedding = _safe_embed(embedder, input_data.query, "recall") if query_embedding: try: results = s.search_by_embedding( diff --git a/tests/test_memory_server.py b/tests/test_memory_server.py index 73398def..89df702e 100644 --- a/tests/test_memory_server.py +++ b/tests/test_memory_server.py @@ -188,6 +188,94 @@ def test_reflect_entities_lowercased(self, srv, store): assert "brad" in ref.entities assert "oleg" in ref.entities + def test_reflect_reports_embedded_false_with_noop(self, srv): + """Regression for #291: response must surface whether an embedding was generated.""" + result = json.loads(_tools(srv)["reflect"](content="fact without embedding", type="fact")) + assert result["stored"] is True + assert result["embedded"] is False + + def test_reflect_reports_embedded_true_with_real_embedder(self, store, capsys): + """When a real embedder returns a vector, response reflects embedded=True.""" + class FakeRealEmbedder: + dimensions = 8 + + def embed(self, text: str) -> list[float]: + return [0.1] * 8 + + srv = create_server(store, FakeRealEmbedder()) + result = json.loads(_tools(srv)["reflect"](content="embedded fact", type="fact")) + assert result["stored"] is True + assert result["embedded"] is True + + def test_reflect_logs_when_real_embedder_returns_empty(self, store, capsys): + """Regression for #291: real embedder returning [] must log degraded mode.""" + class DegradedRealEmbedder: + dimensions = 8 + + def embed(self, text: str) -> list[float]: + return [] + + srv = create_server(store, DegradedRealEmbedder()) + result = json.loads(_tools(srv)["reflect"](content="degraded", type="fact")) + assert result["stored"] is True + assert result["embedded"] is False + captured = capsys.readouterr() + assert "degraded" in captured.err.lower() + + def test_reflect_survives_embedder_exception(self, store, capsys): + """Regression for #291: embedder raising must not abort reflect(); log instead.""" + class RaisingEmbedder: + dimensions = 8 + + def embed(self, text: str) -> list[float]: + raise RuntimeError("network error") + + srv = create_server(store, RaisingEmbedder()) + result = json.loads(_tools(srv)["reflect"](content="survives", type="fact")) + assert result["stored"] is True + assert result["embedded"] is False + captured = capsys.readouterr() + assert "runtimeerror" in captured.err.lower() + assert "network error" in captured.err.lower() + + def test_reflect_treats_zero_norm_vector_as_degraded(self, store, capsys): + """Regression for #291 (Murzik review): real embedder returning [0.0, ...] + must be treated as degraded, not stored as a usable embedding. + + Without this check, reflect() would report embedded=True and store an + all-zero vector that search_by_embedding silently skips (zero-norm row + guard in _search_by_numpy), reproducing the original silent-degrade bug. + """ + class ZeroVectorEmbedder: + dimensions = 8 + + def embed(self, text: str) -> list[float]: + return [0.0] * 8 + + srv = create_server(store, ZeroVectorEmbedder()) + result = json.loads(_tools(srv)["reflect"](content="all zeros", type="fact")) + assert result["stored"] is True + assert result["embedded"] is False + ref = store.get(result["id"]) + assert ref.embedding == [] + captured = capsys.readouterr() + assert "degenerate" in captured.err.lower() + + def test_reflect_treats_nonfinite_vector_as_degraded(self, store, capsys): + """Non-finite values (NaN/inf) in an embedding are also degraded.""" + class NaNEmbedder: + dimensions = 8 + + def embed(self, text: str) -> list[float]: + return [float("nan")] + [0.1] * 7 + + srv = create_server(store, NaNEmbedder()) + result = json.loads(_tools(srv)["reflect"](content="nan inside", type="fact")) + assert result["stored"] is True + assert result["embedded"] is False + captured = capsys.readouterr() + assert "degenerate" in captured.err.lower() + # ── recall tool ──────────────────────────────────────────────────────────────── From 65b947ef49e9658fc34588c56394730143d2c92f Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:28:37 -0700 Subject: [PATCH 09/21] fix(memory): log sqlite-vec query failures before numpy fallback (#290) (#300) sqlite-vec query exceptions were swallowed and _search_by_numpy was invoked silently. Users got slower, possibly differently ordered search with no signal that vec search was broken. - Log a WARNING via pinky_memory.store logger with exception type and message before falling back to numpy. - Track a per-instance `_vec_fallback_count` bumped on every fallback. - Surface `vec_available` and `vec_fallback_count` in introspect() so health checks / dashboards can detect a degraded vec backend. - Regression test simulates a vec failure via a connection wrapper and asserts the warning is logged, counter incremented, introspect reports it, and numpy fallback still returns results. Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_memory/store.py | 20 ++++++++- tests/test_memory_store.py | 90 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py index 9fd16624..974f1d92 100644 --- a/src/pinky_memory/store.py +++ b/src/pinky_memory/store.py @@ -127,6 +127,9 @@ def __init__(self, db_path: str = "data/reflections.db") -> None: self._lock = threading.RLock() self._vec_available = False self._vec_dimensions = 0 + # Runtime counter of sqlite-vec → numpy fallbacks. Surfaced in + # introspect() so health checks can detect a degraded vec backend. + self._vec_fallback_count = 0 Path(db_path).parent.mkdir(parents=True, exist_ok=True) self._conn = sqlite3.connect(db_path, check_same_thread=False) self._conn.row_factory = sqlite3.Row @@ -613,12 +616,23 @@ def _search_by_vec( "WHERE embedding MATCH ? AND k = ? ORDER BY distance", (query_blob, fetch_k), ).fetchall() - except Exception: - # Fall back to numpy on any vec query failure + except Exception as exc: + # Fall back to numpy on any vec query failure. Log loudly — + # silent degradation here previously meant slower, differently + # ordered search with no signal that vec was broken. + self._vec_fallback_count += 1 + logger.warning( + "sqlite-vec query failed (%s: %s) — falling back to numpy " + "scan (fallback #%d this process)", + type(exc).__name__, + exc, + self._vec_fallback_count, + ) return self._search_by_numpy( query_embedding, limit, active_only, type_filter, project_filter, min_weight, recency_factor, access_boost, entity_filter, + type_exclude, ) if not vec_rows: @@ -1290,6 +1304,8 @@ def introspect( "by_project": by_project, "by_salience": by_salience, "recent": recent, + "vec_available": self._vec_available, + "vec_fallback_count": self._vec_fallback_count, } @staticmethod diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index 2610ff06..288ed35d 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -305,6 +305,96 @@ def test_empty_query_embedding_raises(self, tmp_path): with pytest.raises(InvalidQueryEmbeddingError): store.search_by_embedding_scored([]) + def test_vec_fallback_logs_and_counts(self, tmp_path, caplog, monkeypatch): + """Regression for #290: sqlite-vec failures must log + bump fallback counter. + + Previously the fallback to numpy was silent, so a broken vec extension + meant slower/differently-ordered search with no signal. + """ + import logging as _logging + + store = _store(tmp_path) + emb = _emb() + store.insert(_fact("a", embedding=emb)) + + if not store._vec_available: + # Make _search_by_vec reachable for this test even without sqlite-vec. + store._vec_available = True + store._vec_dimensions = len(emb) + + class FailingVecConn: + """Wraps a real sqlite3.Connection; raises on reflections_vec queries only.""" + + def __init__(self, inner): + self._inner = inner + + def execute(self, sql, *args, **kwargs): + if "reflections_vec" in sql: + raise RuntimeError("simulated vec failure") + return self._inner.execute(sql, *args, **kwargs) + + def __getattr__(self, name): + return getattr(self._inner, name) + + monkeypatch.setattr(store, "_conn", FailingVecConn(store._conn)) + + before = store._vec_fallback_count + with caplog.at_level(_logging.WARNING, logger="pinky_memory.store"): + results = store.search_by_embedding(emb) + # Numpy fallback still returns results + assert len(results) >= 1 + # Counter bumped + assert store._vec_fallback_count == before + 1 + # Warning logged with exception info + messages = " ".join(r.getMessage() for r in caplog.records) + assert "sqlite-vec" in messages + assert "simulated vec failure" in messages + # Introspect exposes the counter + stats = store.introspect() + assert stats["vec_fallback_count"] == before + 1 + + def test_vec_fallback_preserves_type_exclude(self, tmp_path, monkeypatch): + """Regression for #290 (Murzik review): fallback must preserve + `type_exclude` — previously it was dropped when vec failed, so + callers that excluded a type could get excluded rows back. + """ + store = _store(tmp_path) + emb = _emb() + # Insert one memory of each type so the test can detect leakage. + insight = store.insert(Reflection( + type=ReflectionType.insight, content="should be excluded", embedding=emb, + )) + fact = store.insert(Reflection( + type=ReflectionType.fact, content="should remain", embedding=emb, + )) + + if not store._vec_available: + store._vec_available = True + store._vec_dimensions = len(emb) + + class FailingVecConn: + def __init__(self, inner): + self._inner = inner + + def execute(self, sql, *args, **kwargs): + if "reflections_vec" in sql: + raise RuntimeError("simulated vec failure") + return self._inner.execute(sql, *args, **kwargs) + + def __getattr__(self, name): + return getattr(self._inner, name) + + monkeypatch.setattr(store, "_conn", FailingVecConn(store._conn)) + + scored = store.search_by_embedding_scored( + emb, limit=10, type_exclude=[ReflectionType.insight], + ) + returned_ids = {ref.id for _, ref in scored} + assert insight.id not in returned_ids, ( + "fallback leaked a type that was in type_exclude" + ) + assert fact.id in returned_ids + # ── Near-Duplicate Detection ─────────────────────────────────────────────────── From 03a3db6fdeb4cf6ccffb38cce6e2999a6f820570 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:29:14 -0700 Subject: [PATCH 10/21] fix(outreach): reject failed WhatsApp media downloads (#284) (#302) WhatsAppAdapter.download_file() wrote the raw response body to disk without checking the second-stage media GET status. 401/404 responses (HTML or Graph API error JSON) were persisted as wa_. files, silently corrupting the media cache. - Raise WhatsAppError on any status_code >= 400 from the media download. - Extract Graph API error message/code when the response is JSON; fall back to the raw HTTP status otherwise. - Existing happy-path test now asserts status_code=200 explicitly so the new guard is exercised. - New regression asserts a 401 Expired-token response raises with the correct error_code/message and leaves no wa_.* file behind. Co-authored-by: Oleg Co-authored-by: Murzik Co-authored-by: Claude Opus 4 --- src/pinky_outreach/whatsapp.py | 11 +++++++++++ tests/test_whatsapp.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/pinky_outreach/whatsapp.py b/src/pinky_outreach/whatsapp.py index 335be4f5..371f5264 100644 --- a/src/pinky_outreach/whatsapp.py +++ b/src/pinky_outreach/whatsapp.py @@ -246,6 +246,17 @@ def download_file(self, media_id: str, dest_dir: str = "/tmp/pinky_files") -> st raise WhatsAppError("No download URL for media", 0) # Step 2: Download the file resp = self._client.get(url, headers={"Authorization": f"Bearer {self._token}"}) + if resp.status_code >= 400: + message = f"Media download failed with HTTP {resp.status_code}" + code = resp.status_code + try: + error = resp.json().get("error", {}) + except Exception: + error = {} + if error: + message = error.get("message", message) + code = error.get("code", code) + raise WhatsAppError(message, code) ext = data.get("mime_type", "application/octet-stream").split("/")[-1].split(";")[0] path = os.path.join(dest_dir, f"wa_{media_id}.{ext}") with open(path, "wb") as f: diff --git a/tests/test_whatsapp.py b/tests/test_whatsapp.py index f8892ec6..c656edeb 100644 --- a/tests/test_whatsapp.py +++ b/tests/test_whatsapp.py @@ -232,6 +232,7 @@ def test_two_step_download(self, tmp_path): # Step 2: download the file file_content = b"fake_image_bytes" download_resp = MagicMock() + download_resp.status_code = 200 download_resp.content = file_content def fake_request(method, path, **kwargs): @@ -255,6 +256,25 @@ def test_missing_url_raises(self, tmp_path): adapter.download_file("bad_id", dest_dir=str(tmp_path)) assert "No download URL" in exc_info.value.message + def test_download_http_error_raises_without_writing_file(self, tmp_path): + adapter = _wa() + media_info = {"url": "https://example.com/file.jpg", "mime_type": "image/jpeg"} + error_resp = MagicMock() + error_resp.status_code = 401 + error_resp.json.return_value = { + "error": {"message": "Expired token", "code": 190} + } + error_resp.content = b"not an image" + + with patch.object(adapter, "_request", return_value=media_info), \ + patch.object(adapter._client, "get", return_value=error_resp): + with pytest.raises(WhatsAppError) as exc_info: + adapter.download_file("media_id_123", dest_dir=str(tmp_path)) + + assert exc_info.value.error_code == 190 + assert "Expired token" in exc_info.value.message + assert not (tmp_path / "wa_media_id_123.jpeg").exists() + # ── get_me ───────────────────────────────────────────────────────────────────── From 374e1608e156a81d6788336989c316258fbe7314 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:29:20 -0700 Subject: [PATCH 11/21] fix(outreach): omit null Telegram media reply ids (#288) (#303) Telegram media senders (send_photo, send_document, send_animation) now filter None-valued fields from multipart form data. Passing reply_to_message_id=None was being serialized as the literal string "None" in the multipart body, which Telegram's API rejected or treated as an invalid message id. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg Co-authored-by: Murzik --- src/pinky_outreach/telegram.py | 22 +++++++++++++++++++--- tests/test_outreach.py | 26 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/pinky_outreach/telegram.py b/src/pinky_outreach/telegram.py index 23f7d2ab..df249b6b 100644 --- a/src/pinky_outreach/telegram.py +++ b/src/pinky_outreach/telegram.py @@ -54,6 +54,10 @@ def _request(self, method: str, **params) -> dict: return data.get("result", {}) + def _media_data(self, **params) -> dict: + """Build multipart form data without sending Telegram null fields.""" + return {k: v for k, v in params.items() if v is not None} + # ── Sending ────────────────────────────────────────────── def send_message( @@ -98,7 +102,11 @@ def send_photo( with open(photo_path, "rb") as f: resp = self._client.post( url, - data={"chat_id": chat_id, "caption": caption, "reply_to_message_id": reply_to_message_id}, + data=self._media_data( + chat_id=chat_id, + caption=caption, + reply_to_message_id=reply_to_message_id, + ), files={"photo": f}, ) data = resp.json() @@ -132,7 +140,11 @@ def send_document( with open(file_path, "rb") as f: resp = self._client.post( url, - data={"chat_id": chat_id, "caption": caption, "reply_to_message_id": reply_to_message_id}, + data=self._media_data( + chat_id=chat_id, + caption=caption, + reply_to_message_id=reply_to_message_id, + ), files={"document": f}, ) data = resp.json() @@ -166,7 +178,11 @@ def send_animation( with open(file_path, "rb") as f: resp = self._client.post( url, - data={"chat_id": chat_id, "caption": caption, "reply_to_message_id": reply_to_message_id}, + data=self._media_data( + chat_id=chat_id, + caption=caption, + reply_to_message_id=reply_to_message_id, + ), files={"animation": f}, ) data = resp.json() diff --git a/tests/test_outreach.py b/tests/test_outreach.py index 74ee73ce..768ce889 100644 --- a/tests/test_outreach.py +++ b/tests/test_outreach.py @@ -148,6 +148,32 @@ def test_send_message_error(self): assert "chat not found" in str(exc.value) adapter.close() + @pytest.mark.parametrize( + "method_name", + ["send_photo", "send_document", "send_animation"], + ) + def test_media_sends_omit_none_reply_to_message_id(self, method_name, tmp_path): + adapter = self._make_adapter() + media_path = tmp_path / "media.bin" + media_path.write_bytes(b"media") + mock_response = MagicMock() + mock_response.json.return_value = { + "ok": True, + "result": { + "message_id": 42, + "chat": {"id": 12345, "type": "private"}, + "date": 1711584000, + }, + } + adapter._client.post = MagicMock(return_value=mock_response) + + getattr(adapter, method_name)("12345", str(media_path), caption="hi") + + data = adapter._client.post.call_args.kwargs["data"] + assert "reply_to_message_id" not in data + assert data["chat_id"] == "12345" + adapter.close() + def test_get_updates_empty(self): adapter = self._make_adapter() mock_response = MagicMock() From 45567f39e4db30c31a6b5beba508d8267a263ece Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:29:27 -0700 Subject: [PATCH 12/21] fix(self): encode KB tool URL parameters (#292) (#304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KB self-tools now URL-encode search query params and path variables: - `kb_search` builds its querystring via urllib.parse.urlencode so that queries containing `&`, `#`, `?`, or spaces round-trip correctly instead of silently injecting into the request URI. - `kb_get_wiki` / `kb_save_wiki` / `kb_delete_wiki` quote the slug with `safe="/"` — nested slugs like `topics/llm-knowledge-bases` still work, but spaces / specials inside a segment are escaped. - `kb_delete_raw` / `kb_update_raw` quote the raw source id as a single path segment (`safe=""`), so ids containing `/` or other specials no longer break routing. Added regressions for query injection, wiki slug specials, and raw source IDs containing `/` + space. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg Co-authored-by: Murzik --- src/pinky_self/server.py | 23 ++++++++++++++----- tests/test_pinky_self_tools.py | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/pinky_self/server.py b/src/pinky_self/server.py index 9b944468..0b9a0098 100644 --- a/src/pinky_self/server.py +++ b/src/pinky_self/server.py @@ -18,6 +18,7 @@ import os import sys import urllib.error +import urllib.parse import urllib.request from datetime import datetime, timezone @@ -2138,7 +2139,12 @@ def kb_ingest( @mcp.tool() def kb_search(query: str, scope: str = "all") -> str: """Search the knowledge base. scope: all | raw | wiki.""" - result = _api("GET", f"/kb/search?q={query}&scope={scope}&limit=15") + params = urllib.parse.urlencode({ + "q": query, + "scope": scope, + "limit": 15, + }) + result = _api("GET", f"/kb/search?{params}") if "error" in result: return f"Error: {result['error']}" @@ -2157,7 +2163,8 @@ def kb_search(query: str, scope: str = "all") -> str: @mcp.tool() def kb_get_wiki(topic: str) -> str: """Get a wiki page by slug (e.g. "topics/llm-knowledge-bases").""" - result = _api("GET", f"/kb/wiki/{topic}?include_content=true") + topic_path = urllib.parse.quote(topic, safe="/") + result = _api("GET", f"/kb/wiki/{topic_path}?include_content=true") if "error" in result: return f"No wiki page found for: {topic}" @@ -2217,7 +2224,8 @@ def kb_save_wiki( source_list = [s.strip() for s in sources.split(",") if s.strip()] if sources else [] related_list = [r.strip() for r in related.split(",") if r.strip()] if related else [] - result = _api("PUT", f"/kb/wiki/{slug}", body={ + slug_path = urllib.parse.quote(slug, safe="/") + result = _api("PUT", f"/kb/wiki/{slug_path}", body={ "title": title, "content": content, "sources": source_list, @@ -2230,7 +2238,8 @@ def kb_save_wiki( @mcp.tool() def kb_delete_wiki(slug: str) -> str: """Delete a wiki page by slug.""" - result = _api("DELETE", f"/kb/wiki/{slug}") + slug_path = urllib.parse.quote(slug, safe="/") + result = _api("DELETE", f"/kb/wiki/{slug_path}") if "error" in result: return f"Error: {result['error']}" return f"🗑️ Wiki page deleted: {slug}" @@ -2238,7 +2247,8 @@ def kb_delete_wiki(slug: str) -> str: @mcp.tool() def kb_delete_raw(source_id: str) -> str: """Delete a raw KB source by ID (e.g. 'raw-2026-04-08-001').""" - result = _api("DELETE", f"/kb/raw/{source_id}") + source_path = urllib.parse.quote(source_id, safe="") + result = _api("DELETE", f"/kb/raw/{source_path}") if "error" in result: return f"Error: {result['error']}" return f"🗑️ Raw source deleted: {source_id}" @@ -2272,7 +2282,8 @@ def kb_update_raw( body["owner_notes"] = owner_notes if not body: return "No fields to update — provide at least one field." - result = _api("PUT", f"/kb/raw/{source_id}", body=body) + source_path = urllib.parse.quote(source_id, safe="") + result = _api("PUT", f"/kb/raw/{source_path}", body=body) if "error" in result: return f"Error: {result['error']}" return f"✅ Raw source updated: {source_id} — {result.get('title', '')}" diff --git a/tests/test_pinky_self_tools.py b/tests/test_pinky_self_tools.py index f04585a9..1d6cf86a 100644 --- a/tests/test_pinky_self_tools.py +++ b/tests/test_pinky_self_tools.py @@ -1902,6 +1902,47 @@ def test_error_returns_fallback(self, srv): } +class TestKbUrlEncoding: + def _capture_urls(self, response: dict): + seen: list[str] = [] + + def _urlopen(req, timeout=30): + seen.append(req.full_url if hasattr(req, "full_url") else str(req)) + body = json.dumps(response).encode() + resp = MagicMock() + resp.read.return_value = body + resp.__enter__ = lambda s: s + resp.__exit__ = MagicMock(return_value=False) + return resp + + return seen, patch("urllib.request.urlopen", side_effect=_urlopen) + + def test_kb_search_encodes_query_params(self, srv): + seen, patched = self._capture_urls({"results": []}) + with patched: + _tools(srv)["kb_search"](query="alpha & scope=raw", scope="wiki") + + assert "q=alpha+%26+scope%3Draw" in seen[0] + assert "&scope=wiki" in seen[0] + assert "alpha & scope=raw" not in seen[0] + + def test_kb_wiki_slug_encodes_path_specials(self, srv): + seen, patched = self._capture_urls({"content": "ok"}) + with patched: + result = _tools(srv)["kb_get_wiki"](topic="topics/foo bar?draft=1") + + assert result == "ok" + assert "/kb/wiki/topics/foo%20bar%3Fdraft%3D1?include_content=true" in seen[0] + + def test_kb_raw_source_id_encodes_path_segment(self, srv): + seen, patched = self._capture_urls({}) + with patched: + result = _tools(srv)["kb_delete_raw"](source_id="raw/odd id") + + assert "deleted" in result.lower() + assert "/kb/raw/raw%2Fodd%20id" in seen[0] + + class TestToolGates: def test_core_only_has_23_tools(self): """No gates → only core tools registered.""" From 9d23874e50fc41c56b8c06013dc485c6caf08fdb Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:29:34 -0700 Subject: [PATCH 13/21] fix(calendar): return tool error for invalid event dates (#296) (#305) `get_events()` now parses start_date / end_date inside its error boundary and returns JSON `{error: ...}` for invalid ISO dates instead of letting `ValueError` escape the MCP tool (which surfaces as an unhandled exception to the caller). Added a new `tests/test_calendar_server.py` with a regression for invalid `start_date`. Patch authored by Murzik; applied + committed with attribution. Co-authored-by: Oleg Co-authored-by: Murzik --- src/pinky_calendar/server.py | 9 +++++---- tests/test_calendar_server.py | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 tests/test_calendar_server.py diff --git a/src/pinky_calendar/server.py b/src/pinky_calendar/server.py index 8a2ab3f7..21ca7415 100644 --- a/src/pinky_calendar/server.py +++ b/src/pinky_calendar/server.py @@ -91,11 +91,10 @@ def get_events( "Set CALDAV_URL, CALDAV_USERNAME, CALDAV_PASSWORD." ) - now = datetime.now(tz=timezone.utc) - start = _parse_dt(start_date) if start_date else now - end = _parse_dt(end_date) if end_date else start + timedelta(days=days) - try: + now = datetime.now(tz=timezone.utc) + start = _parse_dt(start_date) if start_date else now + end = _parse_dt(end_date) if end_date else start + timedelta(days=days) events = a.get_events(start, end) return json.dumps([ { @@ -109,6 +108,8 @@ def get_events( } for e in events ]) + except ValueError as e: + return _err(f"Invalid date: {e}") except Exception as e: return _err(f"Failed to fetch events: {e}") diff --git a/tests/test_calendar_server.py b/tests/test_calendar_server.py new file mode 100644 index 00000000..bf65bfe8 --- /dev/null +++ b/tests/test_calendar_server.py @@ -0,0 +1,26 @@ +"""Tests for pinky_calendar MCP tools.""" + +from __future__ import annotations + +import json + +from pinky_calendar.server import create_server + + +def _tools(srv): + return {t.name: t.fn for t in srv._tool_manager.list_tools()} + + +class TestCalendarDateParsing: + def test_get_events_invalid_start_date_returns_tool_error(self): + srv = create_server( + caldav_url="https://calendar.example.test", + caldav_username="user", + caldav_password="pass", + ) + + raw = _tools(srv)["get_events"](start_date="not-a-date") + result = json.loads(raw) + + assert "error" in result + assert "Invalid date" in result["error"] From 8c67a236826dd0d90a7695ba20c16167c754a3fc Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:29:40 -0700 Subject: [PATCH 14/21] fix(voice): guard finalize_call against None session under expiry races (#286) (#307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `voice_routes.voice_websocket` was handing `_voice_store.get_session(...)` directly to `finalize_call()` in the WS disconnect path. `get_session()` can legitimately return None under session-expiry races (session was cleaned up, or was never committed after an earlier failure), and `finalize_call()`'s first line accesses `session.call_sid` — boom, `AttributeError: 'NoneType' object has no attribute 'call_sid'`, and the whole finalize path (Opus review + artifact + owner notification) breaks. Two changes, belt + suspenders: 1. Call site (`voice_routes.py`): look up the session before scheduling `finalize_call`, skip + log if it's gone. This prevents the task from being scheduled in the first place. 2. Callee (`voice_engine.py::finalize_call`): accept None defensively and early-return with a log line. Any future caller that forgets the guard gets a clean no-op instead of a crash. Added tests/test_voice_engine.py covering: - `finalize_call(None, ...)` no-ops and does not touch voice_store / agents / broker - `finalize_call(real_session, ...)` still proceeds past the guard Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_daemon/voice_engine.py | 11 +++- src/pinky_daemon/voice_routes.py | 26 ++++++--- tests/test_voice_engine.py | 94 ++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 tests/test_voice_engine.py diff --git a/src/pinky_daemon/voice_engine.py b/src/pinky_daemon/voice_engine.py index fc4b2149..d91a2e8b 100644 --- a/src/pinky_daemon/voice_engine.py +++ b/src/pinky_daemon/voice_engine.py @@ -366,7 +366,16 @@ async def finalize_call( broker_send: Callable | None = None, api_key: str = "", ) -> None: - """Post-call finalization: build transcript, Opus review, save artifact, notify.""" + """Post-call finalization: build transcript, Opus review, save artifact, notify. + + Callers in the WS disconnect path can hit a race where the session has + already been cleaned up by the time we try to finalize. We accept None + here as a defensive belt-and-suspenders guard so a stray call can't + raise AttributeError on `session.call_sid`. (#286) + """ + if session is None: + _log("voice: finalize_call called with None session — skipping") + return _log(f"voice: finalizing call {session.call_sid}") # Get call request for goal context diff --git a/src/pinky_daemon/voice_routes.py b/src/pinky_daemon/voice_routes.py index 226a6142..b5712c3a 100644 --- a/src/pinky_daemon/voice_routes.py +++ b/src/pinky_daemon/voice_routes.py @@ -865,15 +865,27 @@ async def conversationrelay_ws(ws: WebSocket, call_session_id: str): session.id, status="completed", ended_at=time.time() ) - # Finalize call in background (Opus review + artifact + notify) + # Finalize call in background (Opus review + artifact + notify). + # get_session() can return None under session-expiry races (session + # was cleaned up between the update above and this lookup, or it was + # never committed). Check explicitly — handing None to finalize_call() + # would raise AttributeError on session.call_sid. (#286) if messages: - asyncio.create_task( - finalize_call( - _voice_store.get_session(call_session_id), - _voice_store, _agents, _broker_send, - api_key=_api_key, + finalize_session = _voice_store.get_session(call_session_id) + if finalize_session is None: + _log( + f"voice: cannot finalize call — session {call_session_id} " + f"no longer in store (likely expired or cleaned up); " + f"skipping Opus review + artifact" + ) + else: + asyncio.create_task( + finalize_call( + finalize_session, + _voice_store, _agents, _broker_send, + api_key=_api_key, + ) ) - ) _log(f"voice: WS closed for session {call_session_id}") diff --git a/tests/test_voice_engine.py b/tests/test_voice_engine.py new file mode 100644 index 00000000..c4f6e099 --- /dev/null +++ b/tests/test_voice_engine.py @@ -0,0 +1,94 @@ +"""Tests for pinky_daemon.voice_engine helpers. + +Focused on regression coverage for #286 — finalize_call() getting handed +a None session under WS-disconnect races. +""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +import pytest + +from pinky_daemon.voice_engine import finalize_call + + +class TestFinalizeCallNoneGuard: + """Regression for #286: under session-expiry races, + `voice_store.get_session()` can return None. Previously this raised + `AttributeError: 'NoneType' object has no attribute 'call_sid'` because + `finalize_call()` went straight to `session.call_sid` in the first log + line. It now no-ops + logs instead.""" + + def test_finalize_call_with_none_session_noops(self): + voice_store = MagicMock() + agents = MagicMock() + broker_send = MagicMock() + + # Must not raise AttributeError. + asyncio.run( + finalize_call( + None, + voice_store, + agents, + broker_send=broker_send, + api_key="test", + ) + ) + + # No voice_store / agents / broker side effects on the None path. + voice_store.get_call_request.assert_not_called() + voice_store.get_events.assert_not_called() + voice_store.save_artifact.assert_not_called() + broker_send.assert_not_called() + + def test_finalize_call_with_real_session_proceeds(self): + """Sanity check: a real session is not short-circuited by the guard. + + Uses an empty events list so finalize returns after transcript check + (no Opus/network dependency).""" + session = MagicMock() + session.call_sid = "CA123" + session.id = "session-abc" + session.call_request_id = None + + voice_store = MagicMock() + voice_store.get_events.return_value = [] + agents = MagicMock() + + asyncio.run( + finalize_call( + session, + voice_store, + agents, + broker_send=None, + api_key="test", + ) + ) + + # We got past the None guard and the initial log line — evidenced by + # get_events being called (which is the step right after the goal + # lookup). + voice_store.get_events.assert_called_once_with("session-abc") + # Empty transcript → save_artifact should be skipped. + voice_store.save_artifact.assert_not_called() + + +@pytest.mark.parametrize( + "bad_value", + [None], + ids=["none"], +) +def test_finalize_call_parametrized_nones(bad_value): + """Keep the bad-input set explicit so regressions show up in the + parametrize ID.""" + asyncio.run( + finalize_call( + bad_value, + MagicMock(), + MagicMock(), + broker_send=None, + api_key="", + ) + ) From 48cc48653a0b660e55f0b8f90f4ea1271948fca5 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:29:46 -0700 Subject: [PATCH 15/21] fix(datetime): replace deprecated utcnow() + naive now() with tz-aware (#294) (#308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `datetime.utcnow()` is deprecated as of Python 3.12 (will emit DeprecationWarning and eventually be removed). Naive `datetime.now()` silently picks up local tz, which breaks any cross-tz serialization or comparison and — in the case of streaming_session.py:300 — labelled a local-time string as "UTC". Five call sites swept: - `pinky_daemon/api.py:2946` — version string now uses `now(timezone.utc)` so the built version is deterministic regardless of host tz. - `pinky_daemon/api.py:6966` — web/DM timestamp fallback uses `now(utc)`. - `pinky_daemon/api.py:7026` — broker-message timestamp fallback uses `now(utc)`. - `pinky_daemon/streaming_session.py:300` — fallback labelled UTC now actually uses UTC (was implicit local-tz bug). - `pinky_memory/kg_extractor.py:449` — hoisted out of the per-triple loop into a single cached `_now_ymd` value per `_resolve_functional_conflict` call. Already tz-aware; this is just the "cache once per call" cleanup flagged in the issue. Verified: `grep -n 'datetime\\.utcnow\\|datetime\\.now()'` returns only comments. Full test suite for touched areas (test_api, test_kg_extractor, test_streaming_session) — 305 passed. Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_daemon/api.py | 14 +++++++++----- src/pinky_daemon/streaming_session.py | 8 ++++++-- src/pinky_memory/kg_extractor.py | 8 +++++--- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index edad45cd..d1bf9973 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -2943,7 +2943,9 @@ async def _trigger_librarian() -> None: except Exception: _git_branch = "unknown" import datetime as _dt - _now = _dt.datetime.now() + # UTC so the version string is deterministic regardless of where the + # daemon runs (was previously implicit local-tz). #294 + _now = _dt.datetime.now(_dt.timezone.utc) _pinky_version = f"{_now.strftime('%y')}.{int(_now.strftime('%m')):02d}.{_git_hash}" if not os.environ.get("PINKY_SESSION_SECRET", "").strip(): @@ -6957,13 +6959,14 @@ async def chat_with_agent(name: str, req: dict, session: str = "main"): raise HTTPException(503, f"Agent '{name}' session '{label}' could not be started") # Format with metadata like broker messages - from datetime import datetime + from datetime import datetime, timezone from zoneinfo import ZoneInfo tz_str = agents.get_default_timezone() try: ts = datetime.now(ZoneInfo(tz_str)).strftime(f"%Y-%m-%d %H:%M:%S {tz_str}") except Exception: - ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + # datetime.utcnow() is deprecated in Python 3.12. #294 + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") prompt = f"[web | dm | Admin | web | {ts}]\n{content}" await streaming.send(prompt, platform="web", chat_id="web") @@ -7017,13 +7020,14 @@ async def upload_file_to_agent(name: str, file: UploadFile): size = len(content_bytes) # Route to agent as a message with file attachment - from datetime import datetime + from datetime import datetime, timezone from zoneinfo import ZoneInfo tz_str = agents.get_default_timezone() try: ts = datetime.now(ZoneInfo(tz_str)).strftime(f"%Y-%m-%d %H:%M:%S {tz_str}") except Exception: - ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + # datetime.utcnow() is deprecated in Python 3.12. #294 + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") msg = BrokerMessage( platform="web", diff --git a/src/pinky_daemon/streaming_session.py b/src/pinky_daemon/streaming_session.py index d0c6f79f..03963730 100644 --- a/src/pinky_daemon/streaming_session.py +++ b/src/pinky_daemon/streaming_session.py @@ -16,7 +16,7 @@ import time import zoneinfo from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from pinky_daemon.sessions import SessionUsage @@ -297,7 +297,11 @@ async def connect(self) -> None: _now = datetime.now(_tz) time_str = _now.strftime("%A, %B %-d, %Y at %-I:%M %p %Z") except Exception: - time_str = datetime.now().strftime("%A, %B %-d, %Y at %-I:%M %p UTC") + # Fallback labelled "UTC" was previously using implicit local-tz + # datetime.now() — fix to actual UTC. #294 + time_str = datetime.now(timezone.utc).strftime( + "%A, %B %-d, %Y at %-I:%M %p UTC" + ) tools_hint = ( "You have explicit pinky-messaging outreach tools: " diff --git a/src/pinky_memory/kg_extractor.py b/src/pinky_memory/kg_extractor.py index e47ddd9d..46ba7158 100644 --- a/src/pinky_memory/kg_extractor.py +++ b/src/pinky_memory/kg_extractor.py @@ -420,6 +420,10 @@ def _resolve_functional_conflict( limit=10, ) + # Cached once per call — previously computed per-iteration inside the + # loop even though the value never changes within one call. #294 + _now_ymd = datetime.now(timezone.utc).strftime("%Y-%m-%d") + for old in existing: # Only consider triples where subject matches exactly and object differs if not ( @@ -446,9 +450,7 @@ def _resolve_functional_conflict( }) return False # Don't insert this triple - valid_to = new_valid_from or datetime.now( - timezone.utc - ).strftime("%Y-%m-%d") + valid_to = new_valid_from or _now_ymd self._store.kg_invalidate( old["subject"], old["predicate"], old["object"], valid_to=valid_to, From 8e1bd750c10cf2433ada91aea8066cbb9d130f74 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:32:27 -0700 Subject: [PATCH 16/21] fix(memory): handle corrupt embedding JSON in dim detection + backfill (#289) (#301) _detect_embedding_dimensions() and _backfill_vec() both called json.loads() on stored embedding blobs with no guard. A single corrupted row crashed dimension detection (blocking startup) or logged nothing in backfill. - _detect_embedding_dimensions() now scans up to 50 newest-first rows, skips malformed JSON with a logger.warning carrying the reflection id, and returns the first valid embedding's length. - _backfill_vec() catches ValueError/TypeError on json.loads, logs the reflection id, counts corrupt rows, and reports them in the completion message. The bare `except Exception: pass` on the insert step is replaced with a targeted sqlite3.Error catch + debug log. - Regression test corrupts one row's embedding, makes it the newest, asserts _detect_embedding_dimensions still returns 8 from the good row and that the bad row id appears in the warning log. Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_memory/store.py | 56 ++++++++++++++++++++++++++++++-------- tests/test_memory_store.py | 31 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py index 974f1d92..66d50851 100644 --- a/src/pinky_memory/store.py +++ b/src/pinky_memory/store.py @@ -294,12 +294,29 @@ def _create_vec_table(self, dims: int) -> None: pass def _detect_embedding_dimensions(self) -> int: - """Detect embedding dimensions from the first non-empty embedding in the DB.""" - row = self._conn.execute( - "SELECT embedding FROM reflections WHERE embedding != '[]' LIMIT 1" - ).fetchone() - if row: - emb = json.loads(row[0]) + """Detect embedding dimensions from the first valid embedding in the DB. + + Scans until a parseable embedding is found; a single corrupt row no + longer aborts startup. Malformed rows are logged with their id so + they can be reviewed or GC'd manually. + """ + rows = self._conn.execute( + "SELECT id, embedding FROM reflections WHERE embedding != '[]' " + "ORDER BY created_at DESC LIMIT 50" + ).fetchall() + for row in rows: + rid = row[0] if not isinstance(row, sqlite3.Row) else row["id"] + raw = row[1] if not isinstance(row, sqlite3.Row) else row["embedding"] + try: + emb = json.loads(raw) + except (ValueError, TypeError) as exc: + logger.warning( + "reflection %s has malformed embedding JSON (%s); skipping " + "during dimension detection", + rid, + exc, + ) + continue if emb: return len(emb) return 0 @@ -310,15 +327,26 @@ def _backfill_vec(self) -> None: return # Find reflections with embeddings that are NOT yet in the vec table rows = self._conn.execute( - "SELECT r.rowid, r.embedding FROM reflections r " + "SELECT r.rowid, r.id, r.embedding FROM reflections r " "WHERE r.embedding != '[]' AND r.rowid NOT IN " "(SELECT rowid FROM reflections_vec)" ).fetchall() if not rows: return inserted = 0 + corrupt = 0 for row in rows: - emb = json.loads(row[1]) + try: + emb = json.loads(row[2]) + except (ValueError, TypeError) as exc: + logger.warning( + "reflection %s has malformed embedding JSON (%s); " + "skipping during vec backfill", + row[1], + exc, + ) + corrupt += 1 + continue if len(emb) != self._vec_dimensions: continue # skip mismatched dimensions blob = struct.pack(f"{len(emb)}f", *emb) @@ -328,11 +356,17 @@ def _backfill_vec(self) -> None: (row[0], blob), ) inserted += 1 - except Exception: - pass # rowid conflict or other issue — skip + except sqlite3.Error as exc: + logger.debug( + "vec backfill skipped rowid=%s id=%s: %s", + row[0], row[1], exc, + ) if inserted: self._conn.commit() - print(f"[memory] vec_backfill_complete inserted={inserted} skipped={len(rows) - inserted}") + print( + f"[memory] vec_backfill_complete inserted={inserted} " + f"skipped={len(rows) - inserted} corrupt={corrupt}" + ) @staticmethod def _embedding_to_blob(embedding: list[float]) -> bytes: diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index 288ed35d..6587ec94 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -395,6 +395,37 @@ def __getattr__(self, name): ) assert fact.id in returned_ids + def test_detect_dims_skips_corrupt_embedding_json(self, tmp_path, caplog): + """Regression for #289: a single corrupt embedding row must not abort + dimension detection / startup.""" + import logging as _logging + + store = _store(tmp_path) + good_emb = _emb(8, 0.2) + good = store.insert(_fact("good row", embedding=good_emb)) + # Corrupt the embedding of one other row directly via SQL so the + # created_at ordering still lets the detector walk past it. + bad = store.insert(_fact("bad row", embedding=_emb(8, 0.3))) + store._conn.execute( + "UPDATE reflections SET embedding = ? WHERE id = ?", + ("{not json", bad.id), + ) + # Make the corrupt row newer so DESC ordering hits it first. + store._conn.execute( + "UPDATE reflections SET created_at = '2099-01-01T00:00:00+00:00' " + "WHERE id = ?", + (bad.id,), + ) + store._conn.commit() + with caplog.at_level(_logging.WARNING, logger="pinky_memory.store"): + dims = store._detect_embedding_dimensions() + assert dims == 8 # recovered from the good row + messages = " ".join(r.getMessage() for r in caplog.records) + assert bad.id in messages + assert "malformed" in messages + # Good row is untouched + assert store.get(good.id) is not None + # ── Near-Duplicate Detection ─────────────────────────────────────────────────── From 7a980857fa5c7bf21cefbd1d6887d021fd44e3f8 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 04:39:33 -0700 Subject: [PATCH 17/21] fix(memory): replace bare excepts with typed exceptions in store.py (#295) (#309) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #295. Replaces 10 `except:` / `except Exception:` sites with specific exception classes (mostly `sqlite3.OperationalError`) plus logger calls at appropriate severity levels. Preserves fallback semantics — callers still get numpy/LIKE fallbacks, migrations stay idempotent. The one site calling user-supplied `llm_classify` keeps a broad catch with a noqa BLE001 + justification since the callable can raise anything. Co-authored-by: Oleg Co-authored-by: Murzik --- src/pinky_memory/store.py | 99 ++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/src/pinky_memory/store.py b/src/pinky_memory/store.py index 66d50851..a8cea281 100644 --- a/src/pinky_memory/store.py +++ b/src/pinky_memory/store.py @@ -160,8 +160,12 @@ def _init_schema(self) -> None: "ON reflections(source_session_id)" ) self._conn.commit() - except Exception: - pass + except sqlite3.OperationalError as e: # pragma: no cover — rare migration failure + # Index creation is non-critical (queries fall back to full scan). + # Log so an admin can diagnose silent degradation. #295 + logger.warning( + "failed to create idx_reflections_source_session: %s", e + ) # Migrate: spaced review columns (memory review system) self._migrate_add_column("next_review_date", "TEXT DEFAULT NULL") self._migrate_add_column("review_interval_days", "INTEGER DEFAULT 7") @@ -180,7 +184,13 @@ def _init_schema(self) -> None: self._conn.executescript(_FTS5_TRIGGERS) self._conn.commit() self._fts5_available = True - except Exception: + except sqlite3.OperationalError as e: + # FTS5 extension not compiled into this sqlite build. Search + # falls back to LIKE — log so admins can diagnose slow queries. #295 + logger.warning( + "FTS5 initialization failed — keyword search degraded to LIKE: %s", + e, + ) self._fts5_available = False # sqlite-vec virtual table (separate try — graceful if not available) self._init_vec() @@ -252,8 +262,16 @@ def _init_vec(self) -> None: self._conn.enable_load_extension(True) self._conn.load_extension(ext_path) self._conn.enable_load_extension(False) - except Exception: - return # sqlite-vec not available — fall back to numpy + except (ImportError, sqlite3.OperationalError, AttributeError) as e: + # sqlite-vec not installed (ImportError), extension loading denied + # by the sqlite build (OperationalError), or loadable_path() absent + # (AttributeError on older sqlite_vec). Semantic search falls back + # to in-process numpy — log so admins can diagnose degraded search. #295 + logger.info( + "sqlite-vec unavailable — vector search will use numpy fallback: %s", + e, + ) + return # Detect dimensions from existing embeddings dims = self._detect_embedding_dimensions() @@ -272,7 +290,14 @@ def _init_vec(self) -> None: f"USING vec0(embedding float[{dims}] distance_metric=cosine)" ) self._conn.commit() - except Exception: + except sqlite3.OperationalError as e: + # vec0 table creation failed — leave _vec_available = False so + # downstream paths use the numpy fallback. #295 + logger.warning( + "failed to create reflections_vec vec0 table (dims=%d): %s", + dims, + e, + ) return # Vec table created successfully — mark as available @@ -290,8 +315,11 @@ def _create_vec_table(self, dims: int) -> None: ) self._conn.commit() self._vec_dimensions = dims - except Exception: - pass + except sqlite3.OperationalError as e: + # vec0 table creation failed — caller will keep numpy fallback. #295 + logger.warning( + "failed to create reflections_vec table (dims=%d): %s", dims, e + ) def _detect_embedding_dimensions(self) -> int: """Detect embedding dimensions from the first valid embedding in the DB. @@ -431,8 +459,14 @@ def insert(self, reflection: Reflection) -> Reflection: "INSERT INTO reflections_vec(rowid, embedding) VALUES (?, ?)", (cursor.lastrowid, blob), ) - except Exception: - pass # non-fatal — vector search degrades to numpy fallback + except sqlite3.OperationalError as e: + # vec insert failed — non-fatal, vector search degrades + # to numpy fallback for this row. #295 + logger.debug( + "vec insert failed for rowid=%s: %s", + cursor.lastrowid, + e, + ) self._conn.commit() return reflection @@ -1020,8 +1054,9 @@ def _search_by_fts5( try: rows = self._conn.execute(sql, params).fetchall() - except Exception: - # FTS5 query syntax error — fall back to LIKE + except sqlite3.OperationalError as e: + # FTS5 query syntax error or missing table — fall back to LIKE. #295 + logger.debug("FTS5 query failed, falling back to LIKE: %s", e) return self._search_by_like( query, limit, active_only, type_filter, project_filter, min_weight, entity_filter, ) @@ -1208,7 +1243,9 @@ def _find_near_duplicate_vec( "WHERE embedding MATCH ? AND k = 5 ORDER BY distance", (query_blob,), ).fetchall() - except Exception: + except sqlite3.OperationalError as e: + # vec query failed — fall back to numpy near-duplicate scan. #295 + logger.debug("vec near-dup query failed, using numpy: %s", e) return self._find_near_duplicate_numpy(embedding, threshold, active_only) for rowid, distance in vec_rows: @@ -1449,8 +1486,12 @@ def gc_inactive(self, max_age_days: int = 30) -> int: self._conn.execute( "DELETE FROM reflections_vec WHERE rowid = ?", (rowid,) ) - except Exception: - pass + except sqlite3.OperationalError as e: + # vec row missing or write failure — non-fatal; main row + # is still deleted below. #295 + logger.debug( + "vec delete failed for rowid=%s: %s", rowid, e + ) # Delete the reflections placeholders = ",".join("?" for _ in delete_ids) @@ -1535,7 +1576,10 @@ def consolidate_batch( # LLM review band try: verdict = llm_classify(ref.content, candidate.content) - except Exception: + except Exception as e: # noqa: BLE001 — user callable can raise anything + # LLM classifier is user-supplied; default to "different" on any + # error (network, rate limit, parse, etc.). #295 + logger.warning("llm_classify failed, treating as different: %s", e) verdict = "different" if verdict == "same": @@ -1592,7 +1636,9 @@ def _knn_vec( "WHERE embedding MATCH ? AND k = ? ORDER BY distance", (query_blob, fetch_k), ).fetchall() - except Exception: + except sqlite3.OperationalError as e: + # vec query failed during consolidation — use numpy k-NN. #295 + logger.debug("vec k-NN failed, using numpy: %s", e) return self._knn_numpy(ref, k, exclude) results = [] @@ -2215,8 +2261,9 @@ def rebuild_fts(self) -> int: return -1 try: self._conn.execute("DELETE FROM reflections_fts") - except Exception: - # FTS5 table itself is corrupt — nuke and recreate + except sqlite3.OperationalError as e: + # FTS5 table itself is corrupt — nuke and recreate. #295 + logger.warning("FTS5 table corrupt, rebuilding: %s", e) self._conn.executescript( "DROP TRIGGER IF EXISTS reflections_ai;" "DROP TRIGGER IF EXISTS reflections_ad;" @@ -2270,8 +2317,10 @@ def reopen(self) -> None: with self._lock: try: self._conn.close() - except Exception: - pass + except sqlite3.Error as e: + # Connection may already be closed — ignore and proceed to + # reopen below. #295 + logger.debug("close on reopen failed (already closed?): %s", e) self._conn = sqlite3.connect(self._db_path, check_same_thread=False) self._conn.row_factory = sqlite3.Row self._conn.execute("PRAGMA journal_mode=WAL") @@ -2330,8 +2379,12 @@ def _migrate_kg_phase2(self) -> None: f"ALTER TABLE kg_triples ADD COLUMN {col_name} {col_def}" ) self._conn.commit() - except Exception: - pass # Column already exists + except sqlite3.OperationalError as e: + # Column already exists (idempotent migration) — expected on + # upgrade paths. #295 + logger.debug( + "kg_triples column %s add skipped: %s", col_name, e + ) # Extraction log: tracks which reflections have been KG-processed self._conn.executescript(""" From 74bdd647ee3bc75142ba8b4330e0a1133ff1e97d Mon Sep 17 00:00:00 2001 From: olegbrok Date: Tue, 21 Apr 2026 22:31:30 -0700 Subject: [PATCH 18/21] fix(oauth): validate state nonce on legacy Google Calendar callback (#287) (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(oauth): validate state nonce on legacy Google Calendar callback (#287) **Security fix (high).** The legacy `/calendar/google/callback` endpoint accepted `state` but never compared it against any stored value before exchanging the authorization code and saving tokens. A crafted callback hitting the owner's browser with an attacker-controlled code could bind the wrong Google account. Changes: - New `/calendar/google/direct-auth-url` endpoint that calls the existing `pinky_calendar.oauth.get_auth_url()` *and* persists the returned state nonce with an ISO timestamp under `GOOGLE_OAUTH_STATE_{nonce}` (TTL 10m). - `/calendar/google/callback` now requires + validates + single-use-consumes the state nonce before any client-credential lookup or code exchange. Missing / unknown / replayed / expired / corrupt / future-dated states are all hard-rejected with HTTP 400 + a readable HTML error page. - State is deleted immediately on consumption (even on exchange failure or replay attempt), preventing TOCTOU reuse. - `pinky_calendar.oauth.exchange_code`: updated the misleading "validated by caller" marker to a real SECURITY note that spells out the caller's obligation. Tests (`TestGoogleOAuthStateValidation`): missing state, unknown/forged state, expired state (purged afterward), replay rejection, state consumption on exchange failure, direct-auth-url state persistence, direct-auth-url credential gate. Co-Authored-By: Claude Opus 4 * fix(oauth): fail closed on state consume + reject naive timestamps (#287) Addresses Murzik's review on PR #306: **High — consume must fail closed.** `_consume_google_oauth_state()` was ignoring both the return value of `delete_setting()` and any exception it raised. That meant: - If a concurrent consumer won the race (e.g. legitimate callback + attacker replay fired in parallel), *both* could proceed past the validation check. - If deletion itself failed (DB locked, disk full, any OperationalError), the request still proceeded to token exchange. DELETE is now the authoritative consume step: - Returns False (row not deleted) → "unknown or replayed state" - Raises → "could not consume state" Either way, `exchange_code()` is never invoked. **Medium — naive timestamps.** `datetime.fromisoformat()` accepts ISO strings without a tz suffix and returns a naive datetime. Subtracting a naive dt from an aware `now()` raises TypeError → the endpoint 500s instead of returning the promised 400. We only ever *issue* aware UTC timestamps, so naive records now hard-reject as "corrupt state record". Regressions added: - `test_consume_fails_closed_when_delete_returns_false` — simulates concurrent consumer, asserts callback 400 + `exchange_code` not called. - `test_consume_fails_closed_when_delete_raises` — simulates DB failure, asserts callback 400 + `exchange_code` not called. - `test_callback_rejects_naive_timestamp_state` — seeds a naive ISO string, asserts 400 + row purged. Co-Authored-By: Claude Opus 4 --------- Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- src/pinky_calendar/oauth.py | 7 +- src/pinky_daemon/api.py | 102 ++++++++++++++- tests/test_api.py | 245 ++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 3 deletions(-) diff --git a/src/pinky_calendar/oauth.py b/src/pinky_calendar/oauth.py index 650da029..5285edd9 100644 --- a/src/pinky_calendar/oauth.py +++ b/src/pinky_calendar/oauth.py @@ -53,10 +53,15 @@ def exchange_code( client_id: str, client_secret: str, code: str, - state: str, # noqa: ARG001 — validated by caller + state: str, # noqa: ARG001 — caller is responsible for validating state first ) -> dict: """Exchange an authorisation code for access + refresh tokens. + SECURITY: Callers MUST validate `state` against a previously issued, + unexpired, unconsumed nonce before invoking this function. Passing an + attacker-controlled `state` here without prior validation re-opens the + CSRF / account-linking hole fixed by #287. + Returns: dict with keys: access_token, refresh_token, expiry (datetime | None). """ diff --git a/src/pinky_daemon/api.py b/src/pinky_daemon/api.py index d1bf9973..4337276a 100644 --- a/src/pinky_daemon/api.py +++ b/src/pinky_daemon/api.py @@ -4952,6 +4952,84 @@ async def google_auth_url(): "session": session_id, } + # ── Legacy direct-Google OAuth state lifecycle (#287) ───────── + # Keys: GOOGLE_OAUTH_STATE_{state_nonce} → ISO-8601 issued timestamp. + # State nonces are single-use with a 10-minute TTL; consumed by the + # callback handler before the code exchange is attempted. + _google_oauth_state_ttl_sec = 600 + _google_oauth_state_prefix = "GOOGLE_OAUTH_STATE_" + + def _issue_google_oauth_state(nonce: str) -> None: + from datetime import datetime, timezone + agents.set_setting( + f"{_google_oauth_state_prefix}{nonce}", + datetime.now(tz=timezone.utc).isoformat(), + ) + + def _consume_google_oauth_state(nonce: str) -> str: + """Validate + delete a state nonce. Returns '' on success, else error reason. + + Callers MUST treat any non-empty return as a hard rejection — the token + exchange must not proceed. Fails closed on every error path: the nonce + is considered consumed only if the DELETE observably removed a row. + + Race semantics: if two callbacks fire concurrently with the same nonce + (one legitimate, one forged replay), exactly one `delete_setting()` + returns True and is accepted; the loser sees False and is rejected + as "unknown or replayed state". + """ + from datetime import datetime, timezone + if not nonce: + return "missing state parameter" + key = f"{_google_oauth_state_prefix}{nonce}" + issued_raw = agents.get_setting(key) + if not issued_raw: + return "unknown or replayed state" + # DELETE is the authoritative consume step: its return value tells us + # whether *we* were the one to remove the row. If False, another + # caller beat us to it → treat as replay. If it raises, fail closed. + try: + deleted = agents.delete_setting(key) + except Exception: + return "could not consume state" + if not deleted: + return "unknown or replayed state" + try: + issued = datetime.fromisoformat(issued_raw) + except ValueError: + return "corrupt state record" + # Reject naive timestamps — `datetime.fromisoformat` happily accepts + # ISO strings without a tz suffix and returns a naive datetime, and + # subtracting a naive dt from an aware `now()` raises TypeError. + # We only ever *issue* aware UTC timestamps, so a naive record here + # implies corruption or tampering. + if issued.tzinfo is None: + return "corrupt state record" + age_sec = (datetime.now(tz=timezone.utc) - issued).total_seconds() + if age_sec > _google_oauth_state_ttl_sec: + return "expired state" + if age_sec < 0: + return "state issued in the future" + return "" + + @app.get("/calendar/google/direct-auth-url") + async def google_direct_auth_url(): + """Generate a direct-Google OAuth auth URL with a persisted state nonce. + + Used by the backward-compat flow where a user has registered their own + Google Cloud client credentials and `/calendar/google/callback` as the + redirect URI. The state nonce is persisted here so the callback can + validate it (CSRF defense — see #287). + """ + store = _google_token_store() + client_id, client_secret = store.get_client_credentials() + if not client_id or not client_secret: + raise HTTPException(400, "Google client credentials not configured") + from pinky_calendar.oauth import get_auth_url + auth_url, state = get_auth_url(client_id, client_secret) + _issue_google_oauth_state(state) + return {"auth_url": auth_url, "state": state} + @app.get("/calendar/google/fetch-token") async def fetch_google_token(session: str): """Retrieve tokens from pinkybot.ai proxy after OAuth completes.""" @@ -4987,9 +5065,29 @@ async def fetch_google_token(session: str): return {"connected": True} @app.get("/calendar/google/callback") - async def google_callback(code: str, state: str): - """Handle the OAuth2 redirect (backward-compat for users with own credentials).""" + async def google_callback(code: str = "", state: str = ""): + """Handle the OAuth2 redirect (backward-compat for users with own credentials). + + Requires a previously issued + unexpired + unconsumed state nonce + (see `/calendar/google/direct-auth-url`). Missing / unknown / replayed / + expired states are rejected before any token exchange happens, which + closes the CSRF / account-linking hole described in #287. + """ from fastapi.responses import HTMLResponse + state_error = _consume_google_oauth_state(state) + if state_error: + return HTMLResponse( + f"

OAuth state validation failed: {state_error}.

" + "

Please retry from Settings — do not re-use an old link.

" + "", + status_code=400, + ) + if not code: + return HTMLResponse( + "

OAuth error: missing authorization code.

" + "", + status_code=400, + ) store = _google_token_store() client_id, client_secret = store.get_client_credentials() if not client_id or not client_secret: diff --git a/tests/test_api.py b/tests/test_api.py index 5f6730f8..9b4d431c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3022,3 +3022,248 @@ def test_resolver_agent_explicit_fields_override_refs(self): db=db, ) assert (url, key, model) == ("https://api.deepseek.com/anthropic", "", "deepseek-chat") + + +# ── Google OAuth CSRF state validation (#287) ──────────────────── + + +class TestGoogleOAuthStateValidation: + """Regression for #287: the legacy /calendar/google/callback endpoint must + validate a previously-issued state nonce before exchanging the code.""" + + def _make_app(self): + from pinky_daemon.api import create_api + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + return create_api(max_sessions=10, default_working_dir="/tmp", db_path=path) + + def _seed_credentials(self, app): + """Seed client credentials so the callback can't early-return on them.""" + agents = app.state.agents + # TokenStore uses these exact setting keys (see pinky_calendar.store). + agents.set_setting("GOOGLE_CALENDAR_CLIENT_ID", "test-client-id") + agents.set_setting("GOOGLE_CALENDAR_CLIENT_SECRET", "test-client-secret") + + def test_callback_rejects_missing_state(self): + app = self._make_app() + self._seed_credentials(app) + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": ""}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "missing state" in resp.text.lower() + + def test_callback_rejects_unknown_state(self): + app = self._make_app() + self._seed_credentials(app) + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "attacker-forged-nonce"}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "unknown or replayed" in resp.text.lower() + + def test_callback_rejects_expired_state(self): + from datetime import datetime, timedelta, timezone + app = self._make_app() + self._seed_credentials(app) + # Manually insert a state key with an issued timestamp in the distant past. + stale = (datetime.now(tz=timezone.utc) - timedelta(hours=1)).isoformat() + app.state.agents.set_setting("GOOGLE_OAUTH_STATE_stale-nonce", stale) + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "stale-nonce"}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "expired" in resp.text.lower() + # Expired state must be purged so it can't be retried. + assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_stale-nonce") == "" + + def test_callback_rejects_replayed_state(self): + """A state nonce must be single-use. After one consume, a second hit + with the same nonce must be treated as unknown.""" + from datetime import datetime, timezone + from unittest.mock import patch as _patch + + app = self._make_app() + self._seed_credentials(app) + fresh = datetime.now(tz=timezone.utc).isoformat() + app.state.agents.set_setting("GOOGLE_OAUTH_STATE_one-shot", fresh) + + # First use consumes the nonce. We don't care about the token-exchange + # outcome here (which will fail because we're not mocking Google), we + # only need to confirm the state was accepted + deleted. + fake_tokens = {"access_token": "a", "refresh_token": "r", "expiry": None} + with _patch("pinky_calendar.oauth.exchange_code", return_value=fake_tokens): + with TestClient(app) as client: + first = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "one-shot"}, + follow_redirects=False, + ) + assert first.status_code == 200 # happy path + assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_one-shot") == "" + # Replay with the same nonce must now be rejected. + replay = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "one-shot"}, + follow_redirects=False, + ) + assert replay.status_code == 400 + assert "unknown or replayed" in replay.text.lower() + + def test_callback_deletes_state_even_on_exchange_failure(self): + """If exchange_code raises, the state nonce must still have been + consumed — otherwise an attacker could race a valid flow.""" + from datetime import datetime, timezone + from unittest.mock import patch as _patch + + app = self._make_app() + self._seed_credentials(app) + fresh = datetime.now(tz=timezone.utc).isoformat() + app.state.agents.set_setting("GOOGLE_OAUTH_STATE_single-use", fresh) + + with _patch( + "pinky_calendar.oauth.exchange_code", + side_effect=RuntimeError("invalid code"), + ): + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "bad-code", "state": "single-use"}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "oauth error" in resp.text.lower() + assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_single-use") == "" + + def test_direct_auth_url_persists_state(self): + """The direct-auth-url endpoint must persist a single-use state nonce + that the callback can later validate against.""" + from unittest.mock import patch as _patch + + app = self._make_app() + self._seed_credentials(app) + with _patch( + "pinky_calendar.oauth.get_auth_url", + return_value=("https://accounts.google.com/o/oauth2/auth?state=xyz", "xyz"), + ): + with TestClient(app) as client: + resp = client.get("/calendar/google/direct-auth-url") + assert resp.status_code == 200 + body = resp.json() + assert body["state"] == "xyz" + assert "accounts.google.com" in body["auth_url"] + # State key must be present and non-empty (timestamp). + stored = app.state.agents.get_setting("GOOGLE_OAUTH_STATE_xyz") + assert stored != "" + + def test_direct_auth_url_requires_credentials(self): + """Without stored client credentials, direct-auth must 400 — there's + nothing to build a direct-Google auth URL for.""" + app = self._make_app() + # Intentionally skip _seed_credentials(). + with TestClient(app) as client: + resp = client.get("/calendar/google/direct-auth-url") + assert resp.status_code == 400 + + def test_consume_fails_closed_when_delete_returns_false(self): + """Regression for Murzik's review: if delete_setting() returns False + (because a concurrent consumer already deleted the row), we must NOT + proceed to token exchange. Treat the loser of the race as replayed.""" + from datetime import datetime, timezone + from unittest.mock import patch as _patch + + app = self._make_app() + self._seed_credentials(app) + fresh = datetime.now(tz=timezone.utc).isoformat() + app.state.agents.set_setting("GOOGLE_OAUTH_STATE_race-nonce", fresh) + + # Simulate the race: get_setting sees the value, but by the time we + # try to delete it, a concurrent consumer has beaten us to it. + real_delete = app.state.agents.delete_setting + + def fake_delete(key): + if key == "GOOGLE_OAUTH_STATE_race-nonce": + return False # Another consumer won the race. + return real_delete(key) + + exchange_called = {"count": 0} + + def fake_exchange(*args, **kwargs): + exchange_called["count"] += 1 + return {"access_token": "a", "refresh_token": "r", "expiry": None} + + with _patch.object(app.state.agents, "delete_setting", fake_delete), \ + _patch("pinky_calendar.oauth.exchange_code", fake_exchange): + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "race-nonce"}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "unknown or replayed" in resp.text.lower() + # Critical: exchange must not have been invoked. + assert exchange_called["count"] == 0 + + def test_consume_fails_closed_when_delete_raises(self): + """Regression for Murzik's review: if delete_setting() raises (DB + error, disk full, etc.), fail closed — don't exchange the code.""" + from datetime import datetime, timezone + from unittest.mock import patch as _patch + + app = self._make_app() + self._seed_credentials(app) + fresh = datetime.now(tz=timezone.utc).isoformat() + app.state.agents.set_setting("GOOGLE_OAUTH_STATE_boom", fresh) + + def raising_delete(key): + raise sqlite3.OperationalError("database is locked") + + exchange_called = {"count": 0} + + def fake_exchange(*args, **kwargs): + exchange_called["count"] += 1 + return {"access_token": "a", "refresh_token": "r", "expiry": None} + + with _patch.object(app.state.agents, "delete_setting", raising_delete), \ + _patch("pinky_calendar.oauth.exchange_code", fake_exchange): + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "boom"}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "could not consume state" in resp.text.lower() + assert exchange_called["count"] == 0 + + def test_callback_rejects_naive_timestamp_state(self): + """Regression for Murzik's review: datetime.fromisoformat() can return + a naive datetime for valid ISO strings without a tz suffix, and + subtracting naive from aware raises TypeError → 500. Reject naive + records as corrupt so the callback still returns a clean 400.""" + app = self._make_app() + self._seed_credentials(app) + # No tz suffix — fromisoformat() will parse this as naive. + app.state.agents.set_setting( + "GOOGLE_OAUTH_STATE_naive-nonce", "2026-04-21T10:00:00", + ) + with TestClient(app) as client: + resp = client.get( + "/calendar/google/callback", + params={"code": "auth-code", "state": "naive-nonce"}, + follow_redirects=False, + ) + assert resp.status_code == 400 + assert "corrupt state" in resp.text.lower() + # Still purged, even though it was malformed — can't be retried. + assert app.state.agents.get_setting("GOOGLE_OAUTH_STATE_naive-nonce") == "" From d187cb2b3ba575fa1f153dcd6ad316a6a14dcd11 Mon Sep 17 00:00:00 2001 From: olegbrok Date: Wed, 22 Apr 2026 02:56:42 -0700 Subject: [PATCH 19/21] docs: update Deploy section in CLAUDE.md (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync.sh/status.sh are no longer present in the repo. Replace with the current deploy path: agents call update_and_restart() from pinky-self, which honors PINKYBOT_CHANNEL to pick main vs beta. Also refresh the Mac Mini LAN IP (.32 → .209) after the DHCP lease change on 2026-04-17. Co-authored-by: Oleg Co-authored-by: Claude Opus 4 --- CLAUDE.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7b4cc10e..405f4ea2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ pytest ## Deploy -- Production on Mac Mini (`oleg@10.0.0.32`) -- `sync.sh` sources `.env`, SSH deploys, `status.sh` to verify +- Production on Mac Mini (`oleg@10.0.0.209`) +- Self-update: agents call `update_and_restart()` (pinky-self MCP) — pulls the branch matching `PINKYBOT_CHANNEL` env (`stable`→`main`, `beta`→`beta`) and restarts the daemon +- CI auto-runs on push (`ci.yml`); `main` is branch-protected and requires passing CI + PR review - Agent data in `data/agents/{name}/` (gitignored) From d11623bf1ff2d37dd8af835a1a29b0658eec7dbb Mon Sep 17 00:00:00 2001 From: Oleg Date: Fri, 24 Apr 2026 01:43:55 -0700 Subject: [PATCH 20/21] registry: add gpt-5.5 to OpenAI/Codex seed models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAI shipped GPT-5.5 on 2026-04-23, available in Codex CLI via ChatGPT sign-in auth today (API access coming "very soon"). Murzik was already bumped to gpt-5.5 live via the API; this mirrors the change in the seed list so fresh installs inherit it. supports_thinking=0 to match existing OpenAI entries (reasoning effort is driven by the agent-level thinking_effort field, which Codex maps to model_reasoning_effort at spawn). 🤖 Opened by Barsik --- src/pinky_daemon/agent_registry.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pinky_daemon/agent_registry.py b/src/pinky_daemon/agent_registry.py index bd9e823b..95a44880 100644 --- a/src/pinky_daemon/agent_registry.py +++ b/src/pinky_daemon/agent_registry.py @@ -2377,6 +2377,7 @@ def delete_bot_token(self, token_id: str) -> bool: ("anthropic", "claude-opus-4-5", "Claude Opus 4.5", "Previous-gen Opus.", "opus", 200_000, 0, 15.0, 75.0, 1.5, 1, 40), ("anthropic", "claude-sonnet-4-5", "Claude Sonnet 4.5", "Previous-gen Sonnet.", "sonnet", 200_000, 0, 3.0, 15.0, 0.3, 1, 50), # OpenAI / Codex CLI + ("openai", "gpt-5.5", "GPT-5.5", "Newest frontier. Coding + reasoning. Codex sign-in auth only (API pending).", "flagship", 200_000, 0, 1.75, 14.0, 0.175, 0, 55), ("openai", "gpt-5.4", "GPT-5.4", "Flagship. Complex reasoning & coding.", "flagship", 200_000, 0, 1.75, 14.0, 0.175, 0, 60), ("openai", "gpt-5.4-mini", "GPT-5.4 Mini", "Fast + capable. Daily driver.", "mid", 200_000, 0, 0.25, 2.0, 0.025, 0, 70), ("openai", "gpt-5.4-nano", "GPT-5.4 Nano", "Cheapest. High-volume tasks.", "low", 200_000, 0, 0.05, 0.4, 0.005, 0, 80), From 4206933dff74d894fcac070c786ff03e7338d899 Mon Sep 17 00:00:00 2001 From: Oleg Date: Sat, 25 Apr 2026 03:23:36 -0700 Subject: [PATCH 21/21] chore: bump claude-agent-sdk to >=0.1.68 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks up: - v0.1.68 (CLI v2.1.119) - v0.1.67: trio compatibility fix (RuntimeError: no running event loop regression from v0.1.51 — uses sniffio-based dispatch). Adds explicit sniffio runtime dep. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- pyproject.toml | 2 +- uv.lock | 17 +++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 355e8a12..ff06773c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "httpx>=0.28", "fastapi>=0.135", "uvicorn>=0.44", - "claude-agent-sdk>=0.1.66", + "claude-agent-sdk>=0.1.68", "Pillow>=10.0", # Federation crypto (sealed_box_v1): X25519, Ed25519, HKDF, XChaCha20-Poly1305. "cryptography>=41.0", diff --git a/uv.lock b/uv.lock index 68947ab6..d1b3c51e 100644 --- a/uv.lock +++ b/uv.lock @@ -508,19 +508,20 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.66" +version = "0.1.68" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "mcp" }, + { name = "sniffio" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/81/74/ff47c188637e16a781afcc8419da487a4860ba6d8e4ad16ea29f40e99038/claude_agent_sdk-0.1.66.tar.gz", hash = "sha256:b8fb14198456f536f71733a4da84ec79c44d7f91b06ff34a4786087a1b043c48", size = 233308, upload-time = "2026-04-23T23:35:23.209Z" } +sdist = { url = "https://files.pythonhosted.org/packages/27/cb/a3e2250bba1d6e7a41e34f832dbb1427ec1391d59897189d429b2875952a/claude_agent_sdk-0.1.68.tar.gz", hash = "sha256:5f8c9e29f08852878ed8ba9f91cff0287d069002b9522497c3229a5bd3e483ac", size = 239251, upload-time = "2026-04-25T02:06:35.599Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/e0/a1f0e510b506dc3dd6da47eeaf1e750882d2ade11cda8f301a353290b671/claude_agent_sdk-0.1.66-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18c35bdf52ba3c2a0a694fa3981484e5f5432e7c96cfbd76d70d73aef2a8b87e", size = 63218880, upload-time = "2026-04-23T23:35:27.679Z" }, - { url = "https://files.pythonhosted.org/packages/9a/c1/f451c5168d98027665b2356b7baebbb898d5ab3bded41788e5c823790ba7/claude_agent_sdk-0.1.66-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:c8281b0af9733bdcd4672dcc334006af6e5bf83098be38e66222feb9e32a0f41", size = 65184888, upload-time = "2026-04-23T23:35:31.932Z" }, - { url = "https://files.pythonhosted.org/packages/59/40/49b81d04cc579e207676626eb777d7c99e566777fd696643db2c550c86ee/claude_agent_sdk-0.1.66-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:32536a6033dd6ea0fd63191389e77b4208f7ebafea8f8cb4d0befeff52e60a11", size = 76632992, upload-time = "2026-04-23T23:35:36.851Z" }, - { url = "https://files.pythonhosted.org/packages/c9/17/5cd8da44188b115cd5b836d6bd293e242d7533c15aa9e4ad634024f9c182/claude_agent_sdk-0.1.66-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:10abd64e5f8dc65eb2f592c1d4db5607129ef983ecabdfd5ecf4b2692ee02293", size = 76610323, upload-time = "2026-04-23T23:35:42.639Z" }, - { url = "https://files.pythonhosted.org/packages/90/fc/205a918b36e118370942e464578bb6637ffb2c749c63d8d1c634d4e3aefd/claude_agent_sdk-0.1.66-py3-none-win_amd64.whl", hash = "sha256:729984e3198bb62c96387b6c4bbe433eca2eb439abe5663c1bf1532e9e340d2b", size = 78346312, upload-time = "2026-04-23T23:35:48.219Z" }, + { url = "https://files.pythonhosted.org/packages/cf/84/347e9ecc4e293b6a0a80557a5527d0e0b28bfdd6c4ce9fac907436a606b9/claude_agent_sdk-0.1.68-py3-none-macosx_11_0_arm64.whl", hash = "sha256:4e5228ffeae2bfa195e2526c446b5a926a1f4015da35e3efd142f817cf2c6dfb", size = 63221614, upload-time = "2026-04-25T02:06:38.805Z" }, + { url = "https://files.pythonhosted.org/packages/d0/18/e83cdf6c1e7025e735b546b83ff88fcb9762082e61b068a9e52c280caee6/claude_agent_sdk-0.1.68-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4aeb266002b7ca97167072cf04bc3098db5bc8d2daa08a6f84ed29f2d48e2545", size = 65187622, upload-time = "2026-04-25T02:06:42.245Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6c/70769392db3f8717afd48d0c2a5f1b8524f030ef42f203bac4f07f2ab443/claude_agent_sdk-0.1.68-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:2053151067347dd2b980f59632478f14b5323b627efb97fea41233e8bf891831", size = 76635725, upload-time = "2026-04-25T02:06:46.448Z" }, + { url = "https://files.pythonhosted.org/packages/b9/61/4b6f08a5d92a8077e5a29af1ba69f04c9465082781061b9271e981092287/claude_agent_sdk-0.1.68-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:59c7873e39ac7aa572fae25466abc5c34abb3da64eaf60790ed9ddd6dd4759b7", size = 76613056, upload-time = "2026-04-25T02:06:50.427Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c1/3bd1f00529aa1b43f6f57d18ebe5272c3f4cec4bc25f543d78562639a932/claude_agent_sdk-0.1.68-py3-none-win_amd64.whl", hash = "sha256:6f06b744bf20a82d937a3ac705b26807f14936ec1b0c79349208e11a2e89413b", size = 78349055, upload-time = "2026-04-25T02:06:53.919Z" }, ] [[package]] @@ -2257,7 +2258,7 @@ requires-dist = [ { name = "beautifulsoup4", marker = "extra == 'web'", specifier = ">=4.12" }, { name = "caldav", marker = "extra == 'calendar'", specifier = ">=1.3" }, { name = "camoufox", marker = "extra == 'web'", specifier = ">=0.4" }, - { name = "claude-agent-sdk", specifier = ">=0.1.66" }, + { name = "claude-agent-sdk", specifier = ">=0.1.68" }, { name = "cryptography", specifier = ">=41.0" }, { name = "cryptography", marker = "extra == 'calendar'", specifier = ">=41.0" }, { name = "discord-py", marker = "extra == 'discord'", specifier = ">=2.3" },