diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35a1a3ac..c0cc16b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,10 +15,10 @@ 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 + uses: actions/setup-python@v6 with: python-version: "3.11" cache: pip @@ -37,10 +37,10 @@ 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 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} cache: pip @@ -58,10 +58,10 @@ 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 + uses: actions/setup-node@v6 with: node-version: "20" cache: npm 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 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" } } 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_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/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..4337276a 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) @@ -2942,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(): @@ -4949,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.""" @@ -4984,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: @@ -5173,19 +5274,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(): @@ -6940,13 +7057,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") @@ -7000,13 +7118,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/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..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 @@ -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 @@ -295,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: " @@ -548,6 +554,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 +670,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 +969,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/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/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, diff --git a/src/pinky_memory/server.py b/src/pinky_memory/server.py index c37498a1..658d39a3 100644 --- a/src/pinky_memory/server.py +++ b/src/pinky_memory/server.py @@ -1,12 +1,15 @@ 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, IntrospectInput, @@ -18,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 @@ -27,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, @@ -89,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( @@ -121,6 +180,7 @@ def reflect( "type": ref.type.value, "salience": ref.salience, "stored": True, + "embedded": bool(embedding), }) @mcp.tool() @@ -150,18 +210,28 @@ 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: - 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..a8cea281 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 @@ -114,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 @@ -144,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") @@ -164,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() @@ -236,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() @@ -256,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 @@ -274,16 +315,36 @@ 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 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 @@ -294,15 +355,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) @@ -312,11 +384,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: @@ -381,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 @@ -538,7 +622,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 @@ -581,12 +684,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: @@ -719,7 +833,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() @@ -933,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, ) @@ -1121,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: @@ -1251,6 +1375,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 @@ -1360,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) @@ -1446,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": @@ -1503,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 = [] @@ -2126,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;" @@ -2181,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") @@ -2241,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(""" 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/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/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/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_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..9b4d431c 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"}) @@ -2961,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") == "" 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_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"] 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.""" 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() 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 ──────────────────────────────────────────────────────────────── diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index 293811f2..6587ec94 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,146 @@ 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([]) + + 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 + + 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 ─────────────────────────────────────────────────── 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() 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.""" 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="", + ) + ) 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 ─────────────────────────────────────────────────────────────────────