From 4dda5d2aab9135ed22e8f91b540fea65ab27304a Mon Sep 17 00:00:00 2001 From: Noah Lindner Date: Thu, 13 Aug 2026 23:24:20 -0400 Subject: [PATCH] Put ledger reads and writes through drizzle so row types come from the schema. Keep the v14 SQL ladder for existing databases; drop the hand-written row/report types that just restated what the queries already return. Co-authored-by: Cursor --- bun.lock | 180 +++++++++ package.json | 4 +- src/adapter/router.ts | 29 +- src/ledger/attention.ts | 122 +++--- src/ledger/audit.ts | 69 +--- src/ledger/conversations.ts | 725 +++++++++++++++++++----------------- src/ledger/db.ts | 15 + src/ledger/inbox.ts | 58 +-- src/ledger/memory.ts | 108 ++---- src/ledger/scheduler.ts | 75 ++-- src/ledger/schema.ts | 276 ++++++++++++++ src/ledger/search.ts | 143 ++++--- src/ledger/tasks.ts | 280 +++++++------- src/ledger/timers.ts | 63 ++-- src/ledger/turns.ts | 127 +++---- src/policy/budget.ts | 42 +-- src/replay/incident.ts | 184 ++++----- src/replay/run.ts | 53 +-- src/service.ts | 44 ++- src/status.ts | 77 ++-- src/turn-runner/toolset.ts | 49 ++- 21 files changed, 1568 insertions(+), 1155 deletions(-) create mode 100644 src/ledger/schema.ts diff --git a/bun.lock b/bun.lock index 63a080d..76d9c15 100644 --- a/bun.lock +++ b/bun.lock @@ -6,10 +6,12 @@ "name": "tag", "dependencies": { "@bevyl-ai/agent-tools": "^0.5.0", + "drizzle-orm": "^0.45.2", }, "devDependencies": { "@types/bun": "^1.2.0", "@typescript/native-preview": "^7.0.0-dev.20260707.2", + "drizzle-kit": "^0.31.10", "oxlint": "^1.73.0", "oxlint-tsgolint": "^7.0.2001", }, @@ -18,6 +20,64 @@ "packages": { "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.5.0", "", {}, "sha512-wVuzGbmT8na0VOI+JGqlbL75TGNQKGSDhCcVhm92GY9I/YfspicXUfnFYCCex6fM2Av1zEv5kXe8Q6aL5klXzQ=="], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], @@ -88,12 +148,132 @@ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], + + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-tsconfig": ["get-tsconfig@4.14.2", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw=="], + "oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="], "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "tsx": ["tsx@4.23.12", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], + + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], + + "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], } } diff --git a/package.json b/package.json index 92ce52c..e60be20 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "check": "bun run typecheck && bun run lint && bun test" }, "dependencies": { - "@bevyl-ai/agent-tools": "^0.5.0" + "@bevyl-ai/agent-tools": "^0.5.0", + "drizzle-orm": "^0.45.2" }, "devDependencies": { "@types/bun": "^1.2.0", "@typescript/native-preview": "^7.0.0-dev.20260707.2", + "drizzle-kit": "^0.31.10", "oxlint": "^1.73.0", "oxlint-tsgolint": "^7.0.2001" } diff --git a/src/adapter/router.ts b/src/adapter/router.ts index 619cfb8..328c7bb 100644 --- a/src/adapter/router.ts +++ b/src/adapter/router.ts @@ -4,10 +4,12 @@ import type { Database } from "bun:sqlite"; import type { Clock } from "../ledger/clock"; import { writeAudit } from "../ledger/audit"; import { engage, stanceOf, rehomeThreadRoot } from "../ledger/conversations"; +import { orm } from "../ledger/db"; +import { events } from "../ledger/schema"; import type { Policy } from "../policy/schema"; import type { MessageFile, RawMessage, VenueKind } from "@bevyl-ai/agent-tools"; -export type EventKind = "addressed_message" | "observed_message"; +export type EventKind = Extract<(typeof events.$inferSelect)["kind"], "addressed_message" | "observed_message">; // How an addressed message reached the agent (SPEC §5.1/§5.2): a direct address (mention/DM) // carries the acknowledgment duty and the §14.2 failure fallback; a thread_follow message is @@ -91,10 +93,27 @@ export function routeMessage(db: Database, clock: Clock, msg: RawMessage, opts: const now = clock(); try { - db.query( - `INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run(eventId, dedupKey, eventKind, identityId, msg.venueId, msg.threadRootTs, msg.principalId, JSON.stringify({ text: msg.text, ts: msg.ts, isBot: msg.isBot, ...(msg.principalName ? { principalName: msg.principalName } : {}), ...(addressMode ? { addressMode } : {}), ...(msg.files?.length ? { files: msg.files } : {}) }), now); + orm(db) + .insert(events) + .values({ + id: eventId, + dedupKey, + kind: eventKind, + identityId, + venueId: msg.venueId, + threadRootId: msg.threadRootTs, + principalId: msg.principalId, + payload: { + text: msg.text, + ts: msg.ts, + isBot: msg.isBot, + ...(msg.principalName ? { principalName: msg.principalName } : {}), + ...(addressMode ? { addressMode } : {}), + ...(msg.files?.length ? { files: msg.files } : {}), + }, + receivedAt: now, + }) + .run(); } catch { return { kind: "duplicate" }; } diff --git a/src/ledger/attention.ts b/src/ledger/attention.ts index a2e4d25..8411e50 100644 --- a/src/ledger/attention.ts +++ b/src/ledger/attention.ts @@ -4,17 +4,15 @@ // reopened only by ear verdicts. Open items ride the wake prompt, capped; the oldest past max-age // is flagged to the mind's own judgment rather than trusted to the ear's closure call forever. import type { Database } from "bun:sqlite"; +import { and, asc, eq, isNull, or, sql } from "drizzle-orm"; import type { Clock } from "./clock"; -import { many, one } from "./db"; +import { orm } from "./db"; +import { attentionItems, type AttentionItem } from "./schema"; -export interface AttentionItem { - id: string; - identityId: string; - venueId: string; - threadRootId: string | null; - askTs: string | null; - what: string; - openedAt: string; +export type { AttentionItem }; + +function sameNullable(column: typeof attentionItems.threadRootId | typeof attentionItems.askTs, value: string | null) { + return value === null ? isNull(column) : eq(column, value); } export function openAttentionItem( @@ -23,71 +21,79 @@ export function openAttentionItem( item: { id: string; identityId: string; venueId: string; threadRootId: string | null; askTs: string | null; what: string }, ): void { // One open item per ask: same thread + ask ts while open is a duplicate verdict, not a new debt. - const dup = db - .query("SELECT 1 FROM attention_items WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS ? AND ask_ts IS ? AND closed_at IS NULL") - .get(item.identityId, item.venueId, item.threadRootId, item.askTs); + const dup = orm(db) + .select({ one: sql`1` }) + .from(attentionItems) + .where( + and( + eq(attentionItems.identityId, item.identityId), + eq(attentionItems.venueId, item.venueId), + sameNullable(attentionItems.threadRootId, item.threadRootId), + sameNullable(attentionItems.askTs, item.askTs), + isNull(attentionItems.closedAt), + ), + ) + .get(); if (dup) return; - db.query("INSERT INTO attention_items (id, identity_id, venue_id, thread_root_id, ask_ts, what, opened_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run( - item.id, - item.identityId, - item.venueId, - item.threadRootId, - item.askTs, - item.what, - clock(), - ); + orm(db) + .insert(attentionItems) + .values({ + id: item.id, + identityId: item.identityId, + venueId: item.venueId, + threadRootId: item.threadRootId, + askTs: item.askTs, + what: item.what, + openedAt: clock(), + }) + .run(); } // Optimistic close: she answered in that thread. Returns how many items this settled. export function closeAttentionItemsForThread(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null, cause: string): number { - const result = db - .query("UPDATE attention_items SET closed_at = ?, closed_cause = ? WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS ? AND closed_at IS NULL") - .run(clock(), cause, identityId, venueId, threadRootId); - return result.changes; + return orm(db) + .update(attentionItems) + .set({ closedAt: clock(), closedCause: cause }) + .where( + and( + eq(attentionItems.identityId, identityId), + eq(attentionItems.venueId, venueId), + sameNullable(attentionItems.threadRootId, threadRootId), + isNull(attentionItems.closedAt), + ), + ) + .returning({ id: attentionItems.id }) + .all().length; } // Identity-scoped: another identity's item does not exist for this call (SPEC §7.1 as // reachability — same rule as requireTaskFor). export function closeAttentionItem(db: Database, clock: Clock, identityId: string, id: string, cause: string): boolean { - return db.query("UPDATE attention_items SET closed_at = ?, closed_cause = ? WHERE id = ? AND identity_id = ? AND closed_at IS NULL").run(clock(), cause, id, identityId).changes > 0; + return orm(db) + .update(attentionItems) + .set({ closedAt: clock(), closedCause: cause }) + .where(and(eq(attentionItems.id, id), eq(attentionItems.identityId, identityId), isNull(attentionItems.closedAt))) + .returning({ id: attentionItems.id }) + .get() != null; } export function reopenAttentionItem(db: Database, identityId: string, id: string): boolean { // "The ear MAY reopen one that truly was hers" (SPEC §13) covers its own closes and even a // step_back's — but never an operator's close: that judgment outranks the ear's. - return db - .query("UPDATE attention_items SET closed_at = NULL, closed_cause = NULL WHERE id = ? AND identity_id = ? AND (closed_cause IS NULL OR closed_cause NOT LIKE 'operator:%')") - .run(id, identityId).changes > 0; + return orm(db) + .update(attentionItems) + .set({ closedAt: null, closedCause: null }) + .where(and(eq(attentionItems.id, id), eq(attentionItems.identityId, identityId), or(isNull(attentionItems.closedCause), sql`${attentionItems.closedCause} NOT LIKE 'operator:%'`))) + .returning({ id: attentionItems.id }) + .get() != null; } export function openItems(db: Database, identityId: string, limit = 50): AttentionItem[] { - const rows = many<{ - id: string; - identity_id: string; - venue_id: string; - thread_root_id: string | null; - ask_ts: string | null; - what: string; - opened_at: string; - }>( - db, - "SELECT id, identity_id, venue_id, thread_root_id, ask_ts, what, opened_at FROM attention_items WHERE identity_id = ? AND closed_at IS NULL ORDER BY opened_at LIMIT ?", - identityId, - limit, - ); - return rows.map((r) => ({ id: r.id, identityId: r.identity_id, venueId: r.venue_id, threadRootId: r.thread_root_id, askTs: r.ask_ts, what: r.what, openedAt: r.opened_at })); -} - -// --- the ear's own watermark (never the mind's resident_cursor) --- - -export function earCursor(db: Database, identityId: string): number { - return one<{ judged_rowid: number }>(db, "SELECT judged_rowid FROM ear_cursor WHERE identity_id = ?", identityId)?.judged_rowid ?? 0; -} - -export function advanceEarCursor(db: Database, identityId: string, judgedRowid: number): void { - db.query( - `INSERT INTO ear_cursor (identity_id, judged_rowid) VALUES (?, ?) - ON CONFLICT(identity_id) DO UPDATE SET judged_rowid = excluded.judged_rowid - WHERE excluded.judged_rowid > ear_cursor.judged_rowid`, - ).run(identityId, judgedRowid); + return orm(db) + .select() + .from(attentionItems) + .where(and(eq(attentionItems.identityId, identityId), isNull(attentionItems.closedAt))) + .orderBy(asc(attentionItems.openedAt)) + .limit(limit) + .all(); } diff --git a/src/ledger/audit.ts b/src/ledger/audit.ts index 40a8ada..cf73b1e 100644 --- a/src/ledger/audit.ts +++ b/src/ledger/audit.ts @@ -1,39 +1,15 @@ // SPEC §4.1.12 — the append-only audit log. One shared writer so every module logs through the // same choke point (the table itself also enforces append-only via triggers, SPEC schema v1). import type { Database } from "bun:sqlite"; -import { many } from "./db"; -import { isRecord, parseJson } from "../guard"; +import { and, asc, eq, gte, lte, type SQL } from "drizzle-orm"; +import { isRecord } from "../guard"; +import { orm } from "./db"; +import { audit, type Audit, type AuditKind } from "./schema"; -export type AuditKind = - | "event_received" - | "turn_started" - | "turn_ended" - | "task_created" - | "task_transitioned" - | "tool_invoked" - | "confirmation_requested" - | "confirmation_resolved" - | "ambient_posted" - | "budget_denied" - | "memory_written" - | "memory_retracted" - | "memory_tier_changed"; +export type { Audit as AuditRecord, AuditKind }; export function writeAudit(db: Database, at: string, identityId: string, kind: AuditKind, payload: unknown): void { - db.query("INSERT INTO audit (at, identity_id, kind, payload) VALUES (?, ?, ?, ?)").run( - at, - identityId, - kind, - JSON.stringify(payload), - ); -} - -export interface AuditRecord { - id: number; - at: string; - identityId: string; - kind: AuditKind; - payload: unknown; + orm(db).insert(audit).values({ at, identityId, kind, payload }).run(); } export interface AuditQueryFilter { @@ -46,28 +22,17 @@ export interface AuditQueryFilter { // SPEC §15: "queryable by the operator, at minimum: by identity, by task, by time range, by kind" // — and per §15, an identity's own audit-query tool is scoped to that identity, same as every // other ledger query in this codebase (§7.1). -export function queryAudit(db: Database, identityId: string, filter: AuditQueryFilter = {}): AuditRecord[] { - const clauses = ["identity_id = ?"]; - const params: string[] = [identityId]; - if (filter.sinceIso) { - clauses.push("at >= ?"); - params.push(filter.sinceIso); - } - if (filter.untilIso) { - clauses.push("at <= ?"); - params.push(filter.untilIso); - } - if (filter.kind) { - clauses.push("kind = ?"); - params.push(filter.kind); - } - const rows = many<{ id: number; at: string; identity_id: string; kind: AuditKind; payload: string }>( - db, - `SELECT id, at, identity_id, kind, payload FROM audit WHERE ${clauses.join(" AND ")} ORDER BY at, id`, - ...params, - ); - - const records = rows.map((r) => ({ id: r.id, at: r.at, identityId: r.identity_id, kind: r.kind, payload: parseJson(r.payload) })); +export function queryAudit(db: Database, identityId: string, filter: AuditQueryFilter = {}): Audit[] { + const conds: SQL[] = [eq(audit.identityId, identityId)]; + if (filter.sinceIso) conds.push(gte(audit.at, filter.sinceIso)); + if (filter.untilIso) conds.push(lte(audit.at, filter.untilIso)); + if (filter.kind) conds.push(eq(audit.kind, filter.kind)); + const records = orm(db) + .select() + .from(audit) + .where(and(...conds)) + .orderBy(asc(audit.at), asc(audit.id)) + .all(); return filter.taskId ? records.filter((r) => isRecord(r.payload) && r.payload.taskId === filter.taskId) : records; diff --git a/src/ledger/conversations.ts b/src/ledger/conversations.ts index 68ad2c7..df6a14a 100644 --- a/src/ledger/conversations.ts +++ b/src/ledger/conversations.ts @@ -14,16 +14,18 @@ // see her own words" is unrepresentable // A null thread root (top-level channel surface) normalizes to '' for the primary key. import type { Database } from "bun:sqlite"; +import { and, asc, desc, eq, inArray, isNotNull, isNull, max, or, sql } from "drizzle-orm"; import { asString, isRecord, parseJson } from "../guard"; import type { Clock } from "./clock"; -import { many, one } from "./db"; +import { orm } from "./db"; +import { acts, conversations, drafts, events, type Stance } from "./schema"; import type { InboxMessage } from "./inbox"; +export type { Stance }; + const HOLD_WHY_KEEP = 4; // bounded history — never a single latest-wins why (a stale one would render as live fact) const TAIL_LIMIT = 8; -export type Stance = "none" | "engaged" | "out"; - export interface ConversationKey { venueId: string; threadRootId: string | null; @@ -54,15 +56,23 @@ export function convoKey(venueId: string, threadRootId: string | null): string { return `${venueId}|${rootKey(threadRootId)}`; } -interface EventRow { - rowid: number; - id: string; - kind: string; - venue_id: string | null; - thread_root_id: string | null; - principal_id: string | null; - payload: string; - received_at: string; +const eventCols = { + rowid: sql`${events}.rowid`.as("rowid"), + id: events.id, + kind: events.kind, + venueId: events.venueId, + threadRootId: events.threadRootId, + principalId: events.principalId, + payload: events.payload, + receivedAt: events.receivedAt, +}; + +function convoEq(identityId: string, venueId: string, threadRootId: string | null) { + return and(eq(conversations.identityId, identityId), eq(conversations.venueId, venueId), eq(conversations.threadRootId, rootKey(threadRootId))); +} + +function sameNullable(column: typeof events.threadRootId | typeof acts.threadRootId, value: string | null) { + return value === null ? isNull(column) : eq(column, value); } function stringList(v: unknown): string[] { @@ -92,14 +102,14 @@ function parseFiles(v: unknown): InboxMessage["files"] { return files.length ? files : undefined; } -function payloadOf(text: string): { +function payloadOf(raw: unknown): { text: string; ts: string | null; principalName?: string; addressMode?: InboxMessage["addressMode"]; files?: InboxMessage["files"]; } { - const parsed = parseJson(text); + const parsed = typeof raw === "string" ? parseJson(raw) : raw; const p = isRecord(parsed) ? parsed : {}; const addressMode = asAddressMode(p.addressMode); const files = parseFiles(p.files); @@ -117,9 +127,24 @@ function asStance(v: string): Stance { } export function ensureConversation(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null): void { - db.query( - "INSERT INTO conversations (identity_id, venue_id, thread_root_id, first_at) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING", - ).run(identityId, venueId, rootKey(threadRootId), clock()); + orm(db) + .insert(conversations) + .values({ + identityId, + venueId, + threadRootId: rootKey(threadRootId), + firstAt: clock(), + deliveredRowid: 0, + judgedRowid: 0, + holds: 0, + holdWhys: [], + wakeWhy: null, + stance: "none", + stanceWhy: null, + stanceAt: null, + }) + .onConflictDoNothing() + .run(); } // SPEC §5.1 both halves — a mention/addressed message or her own outbound post — engage the @@ -127,53 +152,48 @@ export function ensureConversation(db: Database, clock: Clock, identityId: strin // the reply gate's card is her informed re-entry). export function engage(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null): void { ensureConversation(db, clock, identityId, venueId, threadRootId); - db.query("UPDATE conversations SET stance = 'engaged', stance_why = NULL, stance_at = ? WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( - clock(), - identityId, - venueId, - rootKey(threadRootId), - ); + orm(db) + .update(conversations) + .set({ stance: "engaged", stanceWhy: null, stanceAt: clock() }) + .where(convoEq(identityId, venueId, threadRootId)) + .run(); } // Her judgment to leave. Replies there stop delivering (and stop classifying thread_follow) // until re-engaged; the why is durable and renders whenever the conversation next reaches her. export function stepBack(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null, why: string): void { ensureConversation(db, clock, identityId, venueId, threadRootId); - db.query("UPDATE conversations SET stance = 'out', stance_why = ?, stance_at = ? WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( - why, - clock(), - identityId, - venueId, - rootKey(threadRootId), - ); + orm(db) + .update(conversations) + .set({ stance: "out", stanceWhy: why, stanceAt: clock() }) + .where(convoEq(identityId, venueId, threadRootId)) + .run(); } export function stanceOf(db: Database, identityId: string, venueId: string, threadRootId: string | null): StanceState { - const row = one<{ stance: string; stance_why: string | null; stance_at: string | null }>( - db, - "SELECT stance, stance_why, stance_at FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", - identityId, - venueId, - rootKey(threadRootId), - ); - return row ? { stance: asStance(row.stance), why: row.stance_why, at: row.stance_at } : { stance: "none", why: null, at: null }; + const row = orm(db) + .select({ stance: conversations.stance, stanceWhy: conversations.stanceWhy, stanceAt: conversations.stanceAt }) + .from(conversations) + .where(convoEq(identityId, venueId, threadRootId)) + .get(); + return row ? { stance: asStance(row.stance), why: row.stanceWhy, at: row.stanceAt } : { stance: "none", why: null, at: null }; } // Every venue the ledger knows a thread root by — heard messages plus her own established // conversations. A thread root ts is only meaningful within its venue; callers use this to // catch a threadRootId paired with the wrong venue before posting. export function venuesForThread(db: Database, threadRootId: string): string[] { - const rows = many<{ venue_id: string }>( - db, - `SELECT venue_id FROM events - WHERE venue_id IS NOT NULL AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) - UNION - SELECT venue_id FROM conversations WHERE thread_root_id = ?`, - threadRootId, - threadRootId, - threadRootId, - ); - return rows.map((r) => r.venue_id); + const heard = orm(db) + .select({ venueId: events.venueId }) + .from(events) + .where(and(isNotNull(events.venueId), or(eq(events.threadRootId, threadRootId), sql`json_extract(${events.payload}, '$.ts') = ${threadRootId}`))) + .all(); + const known = orm(db) + .select({ venueId: conversations.venueId }) + .from(conversations) + .where(eq(conversations.threadRootId, threadRootId)) + .all(); + return [...new Set([...heard, ...known].map((r) => r.venueId).filter((v): v is string => v !== null))]; } // A reply's arrival re-homes its root: the top-level message that started the thread stops @@ -182,70 +202,77 @@ export function venuesForThread(db: Database, threadRootId: string): string[] { // already delivered must not re-deliver as fresh traffic under its new home (it would arrive as // a stale mention and flip a later wake's addressed duties — observed in test as a broken §5.5). export function rehomeThreadRoot(db: Database, clock: Clock, identityId: string, venueId: string, rootTs: string): void { - const root = one<{ rowid: number }>( - db, - "SELECT rowid FROM events WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS NULL AND json_extract(payload, '$.ts') = ?", - identityId, - venueId, - rootTs, - ); + const root = orm(db) + .select({ rowid: sql`${events}.rowid` }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + eq(events.venueId, venueId), + isNull(events.threadRootId), + sql`json_extract(${events.payload}, '$.ts') = ${rootTs}`, + ), + ) + .get(); if (!root) return; db.transaction(() => { - db.query("UPDATE events SET thread_root_id = ? WHERE rowid = ?").run(rootTs, root.rowid); - const surface = one<{ delivered_rowid: number; judged_rowid: number }>( - db, - "SELECT delivered_rowid, judged_rowid FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''", - identityId, - venueId, - ); + orm(db).update(events).set({ threadRootId: rootTs }).where(sql`${events}.rowid = ${root.rowid}`).run(); + const surface = orm(db) + .select({ deliveredRowid: conversations.deliveredRowid, judgedRowid: conversations.judgedRowid }) + .from(conversations) + .where(convoEq(identityId, venueId, "")) + .get(); if (!surface) return; ensureConversation(db, clock, identityId, venueId, rootTs); // Judgment the ear pinned to the surface while the root lived there moves with it — but // only when the root was the surface's sole undelivered message (then the reads // demonstrably described it; otherwise they stay, describing the rest). - const otherUndelivered = db - .query( - `SELECT 1 FROM events WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS NULL - AND kind IN ${DELIVERABLE_KINDS} AND rowid > ? LIMIT 1`, + const otherUndelivered = orm(db) + .select({ one: sql`1` }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + eq(events.venueId, venueId), + isNull(events.threadRootId), + inArray(events.kind, ["addressed_message", "observed_message", "external_signal"]), + sql`${events}.rowid > ${surface.deliveredRowid}`, + ), ) - .get(identityId, venueId, surface.delivered_rowid); - if (surface.delivered_rowid < root.rowid && !otherUndelivered) { - const j = one<{ holds: number; hold_whys: string; wake_why: string | null }>( - db, - "SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''", - identityId, - venueId, - ) ?? { holds: 0, hold_whys: "[]", wake_why: null }; - if (j.holds > 0 || j.wake_why) { - db.query("UPDATE conversations SET holds = ?, hold_whys = ?, wake_why = ? WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( - j.holds, - j.hold_whys, - j.wake_why, - identityId, - venueId, - rootTs, - ); - db.query("UPDATE conversations SET holds = 0, hold_whys = '[]', wake_why = NULL WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ''").run( - identityId, - venueId, - ); + .limit(1) + .get(); + if (surface.deliveredRowid < root.rowid && !otherUndelivered) { + const j = orm(db) + .select({ holds: conversations.holds, holdWhys: conversations.holdWhys, wakeWhy: conversations.wakeWhy }) + .from(conversations) + .where(convoEq(identityId, venueId, "")) + .get() ?? { holds: 0, holdWhys: [] as string[], wakeWhy: null }; + if (j.holds > 0 || j.wakeWhy) { + orm(db) + .update(conversations) + .set({ holds: j.holds, holdWhys: j.holdWhys, wakeWhy: j.wakeWhy }) + .where(convoEq(identityId, venueId, rootTs)) + .run(); + orm(db) + .update(conversations) + .set({ holds: 0, holdWhys: [], wakeWhy: null }) + .where(convoEq(identityId, venueId, "")) + .run(); } } - if (surface.delivered_rowid >= root.rowid) { - db.query("UPDATE conversations SET delivered_rowid = max(delivered_rowid, ?) WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( - root.rowid, - identityId, - venueId, - rootTs, - ); + if (surface.deliveredRowid >= root.rowid) { + orm(db) + .update(conversations) + .set({ deliveredRowid: sql`max(${conversations.deliveredRowid}, ${root.rowid})` }) + .where(convoEq(identityId, venueId, rootTs)) + .run(); } - if (surface.judged_rowid >= root.rowid) { - db.query("UPDATE conversations SET judged_rowid = max(judged_rowid, ?) WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( - root.rowid, - identityId, - venueId, - rootTs, - ); + if (surface.judgedRowid >= root.rowid) { + orm(db) + .update(conversations) + .set({ judgedRowid: sql`max(${conversations.judgedRowid}, ${root.rowid})` }) + .where(convoEq(identityId, venueId, rootTs)) + .run(); } })(); } @@ -256,22 +283,20 @@ export function rehomeThreadRoot(db: Database, clock: Clock, identityId: string, // conversation held four times renders four reads, not one stale latest. export function recordHold(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null, why: string): void { ensureConversation(db, clock, identityId, venueId, threadRootId); - db.query( - `UPDATE conversations SET holds = holds + 1, - hold_whys = json_insert(CASE WHEN json_array_length(hold_whys) >= ?2 THEN json_remove(hold_whys, '$[0]') ELSE hold_whys END, '$[#]', ?1) - WHERE identity_id = ?3 AND venue_id = ?4 AND thread_root_id = ?5`, - ).run(why, HOLD_WHY_KEEP, identityId, venueId, rootKey(threadRootId)); + orm(db) + .update(conversations) + .set({ + holds: sql`${conversations.holds} + 1`, + holdWhys: sql`json_insert(CASE WHEN json_array_length(${conversations.holdWhys}) >= ${HOLD_WHY_KEEP} THEN json_remove(${conversations.holdWhys}, '$[0]') ELSE ${conversations.holdWhys} END, '$[#]', ${why})`, + }) + .where(convoEq(identityId, venueId, threadRootId)) + .run(); } // An ear wake verdict's why — her own first read of the conversation, durable. export function recordWakeWhy(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null, why: string): void { ensureConversation(db, clock, identityId, venueId, threadRootId); - db.query("UPDATE conversations SET wake_why = ? WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?").run( - why, - identityId, - venueId, - rootKey(threadRootId), - ); + orm(db).update(conversations).set({ wakeWhy: why }).where(convoEq(identityId, venueId, threadRootId)).run(); } // Delivery reads the judgment WITH the messages and settles it: rendering a conversation into a @@ -281,55 +306,66 @@ export function consumeJudgment(db: Database, clock: Clock, identityId: string, let out: ConversationJudgment; db.transaction(() => { ensureConversation(db, clock, identityId, key.venueId, key.threadRootId); - const row = one<{ holds: number; hold_whys: string; wake_why: string | null }>( - db, - "SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", - identityId, - key.venueId, - rootKey(key.threadRootId), - ) ?? { holds: 0, hold_whys: "[]", wake_why: null }; - out = { ...key, holds: row.holds, holdWhys: stringList(parseJson(row.hold_whys)), wakeWhy: row.wake_why }; + const row = orm(db) + .select({ holds: conversations.holds, holdWhys: conversations.holdWhys, wakeWhy: conversations.wakeWhy }) + .from(conversations) + .where(convoEq(identityId, key.venueId, key.threadRootId)) + .get() ?? { holds: 0, holdWhys: [] as string[], wakeWhy: null }; + out = { ...key, holds: row.holds, holdWhys: stringList(row.holdWhys), wakeWhy: row.wakeWhy }; // Delivery advances ONLY its own watermark: the ear's judged cursor may trail so it can // still bookkeep addressed traffic after the fact (debts on asks she was woken for). - db.query( - `UPDATE conversations SET holds = 0, hold_whys = '[]', wake_why = NULL, - delivered_rowid = max(delivered_rowid, ?) - WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?`, - ).run(deliveredRowid, identityId, key.venueId, rootKey(key.threadRootId)); + orm(db) + .update(conversations) + .set({ + holds: 0, + holdWhys: [], + wakeWhy: null, + deliveredRowid: sql`max(${conversations.deliveredRowid}, ${deliveredRowid})`, + }) + .where(convoEq(identityId, key.venueId, key.threadRootId)) + .run(); })(); return out!; } export function getConversationJudgment(db: Database, identityId: string, venueId: string, threadRootId: string | null): ConversationJudgment | null { - const row = one<{ holds: number; hold_whys: string; wake_why: string | null }>( - db, - "SELECT holds, hold_whys, wake_why FROM conversations WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", - identityId, - venueId, - rootKey(threadRootId), - ); - return row - ? { venueId, threadRootId, holds: row.holds, holdWhys: stringList(parseJson(row.hold_whys)), wakeWhy: row.wake_why } - : null; + const row = orm(db) + .select({ holds: conversations.holds, holdWhys: conversations.holdWhys, wakeWhy: conversations.wakeWhy }) + .from(conversations) + .where(convoEq(identityId, venueId, threadRootId)) + .get(); + return row ? { venueId, threadRootId, holds: row.holds, holdWhys: stringList(row.holdWhys), wakeWhy: row.wakeWhy } : null; } // --- delivery ------------------------------------------------------------------------------- -const DELIVERABLE_KINDS = "('addressed_message','observed_message','external_signal')"; +const DELIVERABLE_KINDS = ["addressed_message", "observed_message", "external_signal"] as const; + +function convoJoin() { + return and( + eq(conversations.identityId, events.identityId), + eq(conversations.venueId, events.venueId), + eq(conversations.threadRootId, sql`ifnull(${events.threadRootId}, '')`), + ); +} + +function outStanceExceptions() { + return or(sql`ifnull(${conversations.stance}, 'none') != 'out'`, eq(events.kind, "external_signal"), isNotNull(conversations.wakeWhy)); +} -function messagesOf(rows: EventRow[]): InboxMessage[] { +function messagesOf(rows: Array<{ rowid: number } & Pick>): InboxMessage[] { return rows.map((r) => { const p = payloadOf(r.payload); return { rowid: r.rowid, id: r.id, kind: asInboxKind(r.kind), - venueId: r.venue_id, - threadRootId: r.thread_root_id, - principalId: r.principal_id, + venueId: r.venueId, + threadRootId: r.threadRootId, + principalId: r.principalId, text: p.text, ts: p.ts, - receivedAt: r.received_at, + receivedAt: r.receivedAt, ...(p.principalName ? { principalName: p.principalName } : {}), ...(p.addressMode ? { addressMode: p.addressMode } : {}), ...(p.files?.length ? { files: p.files } : {}), @@ -359,32 +395,38 @@ function groupByConversation(db: Database, identityId: string, messages: InboxMe return [...grouped.values()]; } -const OUT_STANCE_EXCEPTIONS = "(ifnull(c.stance, 'none') != 'out' OR e.kind = 'external_signal' OR c.wake_why IS NOT NULL)"; - export function pendingConversations(db: Database, identityId: string, limit = 200): PendingConversation[] { - const rows = many( - db, - `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at - FROM events e - LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') - WHERE e.identity_id = ? AND e.kind IN ${DELIVERABLE_KINDS} AND e.venue_id IS NOT NULL - AND e.rowid > ifnull(c.delivered_rowid, 0) - AND ${OUT_STANCE_EXCEPTIONS} - ORDER BY e.rowid LIMIT ?`, - identityId, - limit, - ); - const direct = many( - db, - `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at - FROM events e - LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') - WHERE e.identity_id = ? AND e.kind = 'addressed_message' AND e.venue_id IS NOT NULL - AND e.rowid > ifnull(c.delivered_rowid, 0) - AND json_extract(e.payload, '$.addressMode') IN ('mention', 'dm') - ORDER BY e.rowid`, - identityId, - ); + const rows = orm(db) + .select(eventCols) + .from(events) + .leftJoin(conversations, convoJoin()) + .where( + and( + eq(events.identityId, identityId), + inArray(events.kind, DELIVERABLE_KINDS), + isNotNull(events.venueId), + sql`${events}.rowid > ifnull(${conversations.deliveredRowid}, 0)`, + outStanceExceptions(), + ), + ) + .orderBy(asc(sql`${events}.rowid`)) + .limit(limit) + .all(); + const direct = orm(db) + .select(eventCols) + .from(events) + .leftJoin(conversations, convoJoin()) + .where( + and( + eq(events.identityId, identityId), + eq(events.kind, "addressed_message"), + isNotNull(events.venueId), + sql`${events}.rowid > ifnull(${conversations.deliveredRowid}, 0)`, + sql`json_extract(${events.payload}, '$.addressMode') IN ('mention', 'dm')`, + ), + ) + .orderBy(asc(sql`${events}.rowid`)) + .all(); const seen = new Set(rows.map((r) => r.rowid)); const merged = [...rows, ...direct.filter((r) => !seen.has(r.rowid))].toSorted((a, b) => a.rowid - b.rowid); return groupByConversation(db, identityId, messagesOf(merged)); @@ -392,16 +434,21 @@ export function pendingConversations(db: Database, identityId: string, limit = 2 export function hasUndelivered(db: Database, identityId: string): boolean { return ( - db - .query( - `SELECT 1 FROM events e - LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') - WHERE e.identity_id = ? AND e.kind IN ${DELIVERABLE_KINDS} AND e.venue_id IS NOT NULL - AND e.rowid > ifnull(c.delivered_rowid, 0) - AND ${OUT_STANCE_EXCEPTIONS} - LIMIT 1`, + orm(db) + .select({ one: sql`1` }) + .from(events) + .leftJoin(conversations, convoJoin()) + .where( + and( + eq(events.identityId, identityId), + inArray(events.kind, DELIVERABLE_KINDS), + isNotNull(events.venueId), + sql`${events}.rowid > ifnull(${conversations.deliveredRowid}, 0)`, + outStanceExceptions(), + ), ) - .get(identityId) !== null + .limit(1) + .get() != null ); } @@ -409,31 +456,40 @@ export function hasUndelivered(db: Database, identityId: string): boolean { // the ear listens to rooms she has left too (an emergency there should still wake her; her // stance gates delivery, never the listening). export function unjudgedConversations(db: Database, identityId: string, limit = 200): PendingConversation[] { - const rows = many( - db, - `SELECT e.rowid, e.id, e.kind, e.venue_id, e.thread_root_id, e.principal_id, e.payload, e.received_at - FROM events e - LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') - WHERE e.identity_id = ? AND e.kind IN ${DELIVERABLE_KINDS} AND e.venue_id IS NOT NULL - AND e.rowid > ifnull(c.judged_rowid, 0) - ORDER BY e.rowid LIMIT ?`, - identityId, - limit, - ); + const rows = orm(db) + .select(eventCols) + .from(events) + .leftJoin(conversations, convoJoin()) + .where( + and( + eq(events.identityId, identityId), + inArray(events.kind, DELIVERABLE_KINDS), + isNotNull(events.venueId), + sql`${events}.rowid > ifnull(${conversations.judgedRowid}, 0)`, + ), + ) + .orderBy(asc(sql`${events}.rowid`)) + .limit(limit) + .all(); return groupByConversation(db, identityId, messagesOf(rows)); } export function hasUnjudged(db: Database, identityId: string): boolean { return ( - db - .query( - `SELECT 1 FROM events e - LEFT JOIN conversations c ON c.identity_id = e.identity_id AND c.venue_id = e.venue_id AND c.thread_root_id = ifnull(e.thread_root_id, '') - WHERE e.identity_id = ? AND e.kind IN ${DELIVERABLE_KINDS} AND e.venue_id IS NOT NULL - AND e.rowid > ifnull(c.judged_rowid, 0) - LIMIT 1`, + orm(db) + .select({ one: sql`1` }) + .from(events) + .leftJoin(conversations, convoJoin()) + .where( + and( + eq(events.identityId, identityId), + inArray(events.kind, DELIVERABLE_KINDS), + isNotNull(events.venueId), + sql`${events}.rowid > ifnull(${conversations.judgedRowid}, 0)`, + ), ) - .get(identityId) !== null + .limit(1) + .get() != null ); } @@ -441,9 +497,11 @@ export function hasUnjudged(db: Database, identityId: string): boolean { // delivered — the ear bookkeeps addressed traffic after the fact. export function advanceJudged(db: Database, clock: Clock, identityId: string, key: ConversationKey, judgedRowid: number): void { ensureConversation(db, clock, identityId, key.venueId, key.threadRootId); - db.query( - "UPDATE conversations SET judged_rowid = max(judged_rowid, ?) WHERE identity_id = ? AND venue_id = ? AND thread_root_id = ?", - ).run(judgedRowid, identityId, key.venueId, rootKey(key.threadRootId)); + orm(db) + .update(conversations) + .set({ judgedRowid: sql`max(${conversations.judgedRowid}, ${judgedRowid})` }) + .where(convoEq(identityId, key.venueId, key.threadRootId)) + .run(); } // --- her own voice --------------------------------------------------------------------------- @@ -470,23 +528,34 @@ export function recordAct( act: { kind: "posted" | "reacted"; venueId: string; threadRootId: string | null; ts: string | null; text: string | null }, ): { inserted: boolean; actKey: string } { const actKey = `${act.kind}:${act.venueId}:${rootKey(act.threadRootId)}:${act.text ?? ""}:${act.kind === "reacted" ? act.ts : ""}`; - const result = db - .query( - `INSERT INTO acts (wake_id, act_key, identity_id, kind, venue_id, thread_root_id, ts, text, at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`, - ) - .run(wakeId, actKey, identityId, act.kind, act.venueId, act.threadRootId, act.ts, act.text, clock()); - return { inserted: result.changes > 0, actKey }; + const result = orm(db) + .insert(acts) + .values({ + wakeId, + actKey, + identityId, + kind: act.kind, + venueId: act.venueId, + threadRootId: act.threadRootId, + ts: act.ts, + text: act.text, + at: clock(), + }) + .onConflictDoNothing() + .returning({ id: acts.id }) + .get(); + return { inserted: result != null, actKey }; } // Fills the surface ts once the adapter call returns — and for a TOP-LEVEL post, homes the act // into the thread that post just rooted (the conversation engage() keys on the message id): her // own opening message must render in the thread it started, not on the venue surface. export function setActTs(db: Database, wakeId: string, actKey: string, ts: string, threadRootId?: string | null): void { + const where = and(eq(acts.wakeId, wakeId), eq(acts.actKey, actKey)); if (threadRootId !== undefined) { - db.query("UPDATE acts SET ts = ?, thread_root_id = ? WHERE wake_id = ? AND act_key = ?").run(ts, threadRootId, wakeId, actKey); + orm(db).update(acts).set({ ts, threadRootId }).where(where).run(); } else { - db.query("UPDATE acts SET ts = ? WHERE wake_id = ? AND act_key = ?").run(ts, wakeId, actKey); + orm(db).update(acts).set({ ts }).where(where).run(); } } @@ -495,19 +564,16 @@ export function setActTs(db: Database, wakeId: string, actKey: string, ts: strin // second attempt would report success for a reaction that never landed) or to render in her // tail as something she said. export function deleteAct(db: Database, wakeId: string, actKey: string): void { - db.query("DELETE FROM acts WHERE wake_id = ? AND act_key = ?").run(wakeId, actKey); + orm(db).delete(acts).where(and(eq(acts.wakeId, wakeId), eq(acts.actKey, actKey))).run(); } // --- withheld drafts (§5.5), durable -------------------------------------------------------- export function saveDraft(db: Database, clock: Clock, identityId: string, venueId: string, threadRootId: string | null, text: string): void { - db.query("INSERT INTO drafts (identity_id, venue_id, thread_root_id, text, drafted_at) VALUES (?, ?, ?, ?, ?)").run( - identityId, - venueId, - threadRootId, - text, - clock(), - ); + orm(db) + .insert(drafts) + .values({ identityId, venueId, threadRootId, text, draftedAt: clock(), consumedAt: null }) + .run(); } // §5.5: a withheld reply surfaces to the immediately following wake. Reading is a PEEK — @@ -516,39 +582,40 @@ export function saveDraft(db: Database, clock: Clock, identityId: string, venueI // withholds before any wake rendered them — review finding, 2026-08-11) and only for a // SUCCEEDED turn (a failed wake returns its drafts to the next one instead of eating them). export function peekDrafts(db: Database, identityId: string): { id: number; venueId: string; threadRootId: string | null; text: string }[] { - const rows = many<{ id: number; venue_id: string; thread_root_id: string | null; text: string }>( - db, - "SELECT id, venue_id, thread_root_id, text FROM drafts WHERE identity_id = ? AND consumed_at IS NULL ORDER BY id", - identityId, - ); - return rows.map((r) => ({ id: r.id, venueId: r.venue_id, threadRootId: r.thread_root_id, text: r.text })); + return orm(db) + .select({ id: drafts.id, venueId: drafts.venueId, threadRootId: drafts.threadRootId, text: drafts.text }) + .from(drafts) + .where(and(eq(drafts.identityId, identityId), isNull(drafts.consumedAt))) + .orderBy(asc(drafts.id)) + .all(); } export function markDraftsConsumed(db: Database, clock: Clock, identityId: string, ids: number[]): void { if (ids.length === 0) return; - const marks = ids.map(() => "?").join(","); - db.query(`UPDATE drafts SET consumed_at = ? WHERE identity_id = ? AND id IN (${marks})`).run(clock(), identityId, ...ids); + orm(db) + .update(drafts) + .set({ consumedAt: clock() }) + .where(and(eq(drafts.identityId, identityId), inArray(drafts.id, ids))) + .run(); } // The newest deliverable event a conversation has — the bounce card's "delivered through here". export function maxEventRowid(db: Database, identityId: string, venueId: string, threadRootId: string | null): number { - const row = threadRootId - ? one<{ r: number | null }>( - db, - `SELECT max(rowid) AS r FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ${DELIVERABLE_KINDS} - AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?)`, - identityId, - venueId, - threadRootId, - threadRootId, - ) - : one<{ r: number | null }>( - db, - `SELECT max(rowid) AS r FROM events WHERE identity_id = ? AND venue_id = ? AND kind IN ${DELIVERABLE_KINDS} AND thread_root_id IS NULL`, - identityId, - venueId, - ); - return row?.r ?? 0; + const row = orm(db) + .select({ r: max(sql`${events}.rowid`) }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + eq(events.venueId, venueId), + inArray(events.kind, DELIVERABLE_KINDS), + threadRootId + ? or(eq(events.threadRootId, threadRootId), sql`json_extract(${events.payload}, '$.ts') = ${threadRootId}`) + : isNull(events.threadRootId), + ), + ) + .get(); + return Number(row?.r ?? 0); } // --- refs: addressing as capability ----------------------------------------------------------- @@ -605,70 +672,56 @@ export function conversationOf(t: RefTarget): ConversationKey { export function provenanceOfRef(db: Database, identityId: string, t: RefTarget): { eventId: string; principalId: string | null } | null { if (t.eventId) return { eventId: t.eventId, principalId: t.principalId ?? null }; if (t.ts) { - const exact = one<{ id: string; principal_id: string | null }>( - db, - `SELECT id, principal_id FROM events - WHERE identity_id = ? AND venue_id = ? AND json_extract(payload, '$.ts') = ? - ORDER BY rowid DESC LIMIT 1`, - identityId, - t.venueId, - t.ts, - ); - if (exact) return { eventId: exact.id, principalId: exact.principal_id }; + const exact = orm(db) + .select({ id: events.id, principalId: events.principalId }) + .from(events) + .where(and(eq(events.identityId, identityId), eq(events.venueId, t.venueId), sql`json_extract(${events.payload}, '$.ts') = ${t.ts}`)) + .orderBy(desc(sql`${events}.rowid`)) + .limit(1) + .get(); + if (exact) return { eventId: exact.id, principalId: exact.principalId }; } const key = conversationOf(t); - const row = key.threadRootId - ? one<{ id: string; principal_id: string | null }>( - db, - `SELECT id, principal_id FROM events - WHERE identity_id = ? AND venue_id = ? - AND kind IN ('addressed_message','observed_message','external_signal') - AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) - ORDER BY rowid DESC LIMIT 1`, - identityId, - key.venueId, - key.threadRootId, - key.threadRootId, - ) - : one<{ id: string; principal_id: string | null }>( - db, - `SELECT id, principal_id FROM events - WHERE identity_id = ? AND venue_id = ? - AND kind IN ('addressed_message','observed_message','external_signal') - AND thread_root_id IS NULL - ORDER BY rowid DESC LIMIT 1`, - identityId, - key.venueId, - ); - return row ? { eventId: row.id, principalId: row.principal_id } : null; + const row = orm(db) + .select({ id: events.id, principalId: events.principalId }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + eq(events.venueId, key.venueId), + inArray(events.kind, DELIVERABLE_KINDS), + key.threadRootId + ? or(eq(events.threadRootId, key.threadRootId), sql`json_extract(${events.payload}, '$.ts') = ${key.threadRootId}`) + : isNull(events.threadRootId), + ), + ) + .orderBy(desc(sql`${events}.rowid`)) + .limit(1) + .get(); + return row ? { eventId: row.id, principalId: row.principalId } : null; } // The newest HUMAN speaker in a conversation — the sponsor fallback when a ref's own line is // machine-authored (a worker report has no principal). Scoped to the conversation the model // chose; never a batch-level pick. export function lastSpeakerIn(db: Database, identityId: string, key: ConversationKey): string | null { - const row = key.threadRootId - ? one<{ principal_id: string }>( - db, - `SELECT principal_id FROM events - WHERE identity_id = ? AND venue_id = ? AND principal_id IS NOT NULL - AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) - ORDER BY rowid DESC LIMIT 1`, - identityId, - key.venueId, - key.threadRootId, - key.threadRootId, - ) - : one<{ principal_id: string }>( - db, - `SELECT principal_id FROM events - WHERE identity_id = ? AND venue_id = ? AND principal_id IS NOT NULL - AND thread_root_id IS NULL - ORDER BY rowid DESC LIMIT 1`, - identityId, - key.venueId, - ); - return row?.principal_id ?? null; + const row = orm(db) + .select({ principalId: events.principalId }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + eq(events.venueId, key.venueId), + isNotNull(events.principalId), + key.threadRootId + ? or(eq(events.threadRootId, key.threadRootId), sql`json_extract(${events.payload}, '$.ts') = ${key.threadRootId}`) + : isNull(events.threadRootId), + ), + ) + .orderBy(desc(sql`${events}.rowid`)) + .limit(1) + .get(); + return row?.principalId ?? null; } // --- the one renderer ------------------------------------------------------------------------ @@ -701,56 +754,44 @@ function tailOf(db: Database, identityId: string, key: ConversationKey, beforeRo // A thread's tail is its replies plus its root message (a reply carries thread_root_id, the // root is its own ts — same OR-match the router uses). The venue surface's tail is its recent // top-level messages. - type TailEvent = { id: string; principal_id: string | null; text: string | null; name: string | null; ts: string | null }; - const events = key.threadRootId - ? many( - db, - `SELECT id, principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name, - json_extract(payload, '$.ts') AS ts - FROM events - WHERE identity_id = ? AND venue_id = ? AND rowid <= ? - AND kind IN ('addressed_message','observed_message') - AND (thread_root_id = ? OR json_extract(payload, '$.ts') = ?) - ORDER BY rowid DESC LIMIT ?`, - identityId, - key.venueId, - beforeRowid, - key.threadRootId, - key.threadRootId, - TAIL_LIMIT, - ) - : many( - db, - `SELECT id, principal_id, json_extract(payload, '$.text') AS text, json_extract(payload, '$.principalName') AS name, - json_extract(payload, '$.ts') AS ts - FROM events - WHERE identity_id = ? AND venue_id = ? AND rowid <= ? - AND kind IN ('addressed_message','observed_message') - AND thread_root_id IS NULL - ORDER BY rowid DESC LIMIT ?`, - identityId, - key.venueId, - beforeRowid, - TAIL_LIMIT, - ); - const theirs: TailLine[] = events.toReversed().map((r) => ({ + const theirsEvents = orm(db) + .select({ + id: events.id, + principalId: events.principalId, + text: sql`json_extract(${events.payload}, '$.text')`, + name: sql`json_extract(${events.payload}, '$.principalName')`, + ts: sql`json_extract(${events.payload}, '$.ts')`, + }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + eq(events.venueId, key.venueId), + sql`${events}.rowid <= ${beforeRowid}`, + inArray(events.kind, ["addressed_message", "observed_message"]), + key.threadRootId + ? or(eq(events.threadRootId, key.threadRootId), sql`json_extract(${events.payload}, '$.ts') = ${key.threadRootId}`) + : isNull(events.threadRootId), + ), + ) + .orderBy(desc(sql`${events}.rowid`)) + .limit(TAIL_LIMIT) + .all(); + const theirs: TailLine[] = theirsEvents.toReversed().map((r) => ({ sortTs: r.ts ? Number(r.ts) : 0, surfaceTs: r.ts, eventId: r.id, - principalId: r.principal_id, - line: `${who({ principalId: r.principal_id, ...(r.name ? { principalName: r.name } : {}) })}: ${(r.text ?? "").slice(0, 300)}`, + principalId: r.principalId, + line: `${who({ principalId: r.principalId, ...(r.name ? { principalName: r.name } : {}) })}: ${(r.text ?? "").slice(0, 300)}`, })); - const acts = many<{ kind: string; ts: string | null; text: string | null; at: string }>( - db, - `SELECT kind, ts, text, at FROM acts - WHERE identity_id = ? AND venue_id = ? AND thread_root_id IS ? - ORDER BY id DESC LIMIT ?`, - identityId, - key.venueId, - key.threadRootId, - TAIL_LIMIT, - ); - const hers: TailLine[] = acts.toReversed().map((a) => ({ + const hersActs = orm(db) + .select({ kind: acts.kind, ts: acts.ts, text: acts.text, at: acts.at }) + .from(acts) + .where(and(eq(acts.identityId, identityId), eq(acts.venueId, key.venueId), sameNullable(acts.threadRootId, key.threadRootId))) + .orderBy(desc(acts.id)) + .limit(TAIL_LIMIT) + .all(); + const hers: TailLine[] = hersActs.toReversed().map((a) => ({ sortTs: a.ts ? Number(a.ts) : Date.parse(a.at) / 1000, surfaceTs: null, line: a.kind === "posted" ? `${selfLabel}: ${(a.text ?? "").slice(0, 300)}` : `${selfLabel} reacted :${a.text}: to ts=${a.ts}`, diff --git a/src/ledger/db.ts b/src/ledger/db.ts index 0ec0473..efdb3bc 100644 --- a/src/ledger/db.ts +++ b/src/ledger/db.ts @@ -1,4 +1,19 @@ import { Database, type SQLQueryBindings } from "bun:sqlite"; +import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; +import * as schema from "./schema"; + +export type Ledger = BunSQLiteDatabase; + +const orms = new WeakMap(); + +export function orm(db: Database): Ledger { + let cached = orms.get(db); + if (!cached) { + cached = drizzle(db, { schema }); + orms.set(db, cached); + } + return cached; +} // T is the row shape — bun:sqlite cannot infer it from the SQL string. /* oxlint-disable typescript/no-unnecessary-type-parameters */ diff --git a/src/ledger/inbox.ts b/src/ledger/inbox.ts index 307024a..fc612a9 100644 --- a/src/ledger/inbox.ts +++ b/src/ledger/inbox.ts @@ -2,8 +2,10 @@ // per-conversation (ledger/conversations.ts owns the watermarks); this module keeps the row // shape and the raw after-rowid read that §5.5's moved-check uses. import type { Database } from "bun:sqlite"; -import { asString, isRecord, parseJson } from "../guard"; -import { many } from "./db"; +import { and, asc, eq, gt, inArray, sql } from "drizzle-orm"; +import { asString, isRecord } from "../guard"; +import { orm } from "./db"; +import { events } from "./schema"; export interface InboxMessage { rowid: number; @@ -50,40 +52,42 @@ function parseFiles(v: unknown): InboxMessage["files"] { } export function messagesAfter(db: Database, identityId: string, afterRowid: number, limit = 200): InboxMessage[] { - const cursor = afterRowid; - const rows = many<{ - rowid: number; - id: string; - kind: string; - venue_id: string | null; - thread_root_id: string | null; - principal_id: string | null; - payload: string; - received_at: string; - }>( - db, - `SELECT rowid, id, kind, venue_id, thread_root_id, principal_id, payload, received_at FROM events - WHERE identity_id = ? AND rowid > ? AND kind IN ('addressed_message','observed_message','external_signal') - ORDER BY rowid LIMIT ?`, - identityId, - cursor, - limit, - ); + const rows = orm(db) + .select({ + rowid: sql`${events}.rowid`, + id: events.id, + kind: events.kind, + venueId: events.venueId, + threadRootId: events.threadRootId, + principalId: events.principalId, + payload: events.payload, + receivedAt: events.receivedAt, + }) + .from(events) + .where( + and( + eq(events.identityId, identityId), + gt(sql`${events}.rowid`, afterRowid), + inArray(events.kind, ["addressed_message", "observed_message", "external_signal"]), + ), + ) + .orderBy(asc(sql`${events}.rowid`)) + .limit(limit) + .all(); return rows.map((r) => { - const parsed = parseJson(r.payload); - const p = isRecord(parsed) ? parsed : {}; + const p = isRecord(r.payload) ? r.payload : {}; const addressMode = asAddressMode(p.addressMode); const files = parseFiles(p.files); return { rowid: r.rowid, id: r.id, kind: asInboxKind(r.kind), - venueId: r.venue_id, - threadRootId: r.thread_root_id, - principalId: r.principal_id, + venueId: r.venueId, + threadRootId: r.threadRootId, + principalId: r.principalId, text: asString(p.text), ts: typeof p.ts === "string" ? p.ts : null, - receivedAt: r.received_at, + receivedAt: r.receivedAt, ...(typeof p.principalName === "string" ? { principalName: p.principalName } : {}), ...(addressMode ? { addressMode } : {}), ...(files?.length ? { files } : {}), diff --git a/src/ledger/memory.ts b/src/ledger/memory.ts index 7573d09..8f8359d 100644 --- a/src/ledger/memory.ts +++ b/src/ledger/memory.ts @@ -2,62 +2,16 @@ // isolation (§7.1) is enforced structurally: queryMemory always takes an explicit identityId and // only ever returns that identity's rows — there is no "query all identities" shape to misuse. import type { Database } from "bun:sqlite"; +import { and, asc, eq, type SQL } from "drizzle-orm"; import type { Clock } from "./clock"; import { writeAudit } from "./audit"; -import { many, one } from "./db"; -import { parseJson } from "../guard"; +import { orm } from "./db"; +import { memoryItems, type MemoryItem, type MemoryStatus, type MemoryTier } from "./schema"; -export type MemoryStatus = "active" | "retracted"; -// SPEC §8.6 — core is injected into turn context (budget-bounded); recent is internalized-but- -// unvetted (ambient writes land here, injected under a smaller budget, decays to archive); -// archive is reachable only via search. Demotion moves an item down without losing it. -export type MemoryTier = "core" | "recent" | "archive"; - -export interface MemoryItem { - id: string; - identityId: string; - content: string; - provenance: unknown[]; - tier: MemoryTier; - status: MemoryStatus; - supersededBy: string | null; - createdAt: string; - updatedAt: string; - lastConfirmedAt: string; -} - -interface Row { - id: string; - identity_id: string; - content: string; - provenance: string; - tier: MemoryTier; - status: MemoryStatus; - superseded_by: string | null; - created_at: string; - updated_at: string; - last_confirmed_at: string; -} - -function rowToItem(row: Row): MemoryItem { - const provenance = parseJson(row.provenance); - return { - id: row.id, - identityId: row.identity_id, - content: row.content, - provenance: Array.isArray(provenance) ? provenance : [], - tier: row.tier, - status: row.status, - supersededBy: row.superseded_by, - createdAt: row.created_at, - updatedAt: row.updated_at, - lastConfirmedAt: row.last_confirmed_at, - }; -} +export type { MemoryItem, MemoryStatus, MemoryTier }; function getItem(db: Database, id: string): MemoryItem | null { - const row = one(db, "SELECT * FROM memory_items WHERE id = ?", id); - return row ? rowToItem(row) : null; + return orm(db).select().from(memoryItems).where(eq(memoryItems.id, id)).get() ?? null; } function requireItem(db: Database, id: string): MemoryItem { @@ -93,10 +47,21 @@ export function writeMemory(db: Database, clock: Clock, params: WriteMemoryParam throw new Error("memory refuses credential-shaped content — reference where a secret lives, never its value"); } const now = clock(); - db.query( - `INSERT INTO memory_items (id, identity_id, content, provenance, tier, status, superseded_by, created_at, updated_at, last_confirmed_at) - VALUES (?, ?, ?, ?, ?, 'active', NULL, ?, ?, ?)`, - ).run(params.id, params.identityId, params.content, JSON.stringify(params.provenance ?? []), params.tier ?? "core", now, now, now); + orm(db) + .insert(memoryItems) + .values({ + id: params.id, + identityId: params.identityId, + content: params.content, + provenance: params.provenance ?? [], + tier: params.tier ?? "core", + status: "active", + supersededBy: null, + createdAt: now, + updatedAt: now, + lastConfirmedAt: now, + }) + .run(); writeAudit(db, now, params.identityId, "memory_written", { memoryId: params.id }); return requireItem(db, params.id); } @@ -111,11 +76,11 @@ export interface RetractMemoryParams { export function retractMemory(db: Database, clock: Clock, params: RetractMemoryParams): MemoryItem { const item = requireItem(db, params.id); const now = clock(); - db.query("UPDATE memory_items SET status = 'retracted', superseded_by = ?, updated_at = ? WHERE id = ?").run( - params.supersededBy ?? null, - now, - params.id, - ); + orm(db) + .update(memoryItems) + .set({ status: "retracted", supersededBy: params.supersededBy ?? null, updatedAt: now }) + .where(eq(memoryItems.id, params.id)) + .run(); writeAudit(db, now, item.identityId, "memory_retracted", { memoryId: params.id, supersededBy: params.supersededBy ?? null }); return requireItem(db, params.id); } @@ -140,7 +105,7 @@ export function correctMemory(db: Database, clock: Clock, params: CorrectMemoryP // changing content (contrast with correctMemory, which is for a contradiction). export function confirmMemory(db: Database, clock: Clock, id: string): MemoryItem { const now = clock(); - db.query("UPDATE memory_items SET last_confirmed_at = ?, updated_at = ? WHERE id = ?").run(now, now, id); + orm(db).update(memoryItems).set({ lastConfirmedAt: now, updatedAt: now }).where(eq(memoryItems.id, id)).run(); return requireItem(db, id); } @@ -149,7 +114,7 @@ export function confirmMemory(db: Database, clock: Clock, id: string): MemoryIte export function setMemoryTier(db: Database, clock: Clock, id: string, tier: MemoryTier): MemoryItem { const item = requireItem(db, id); const now = clock(); - db.query("UPDATE memory_items SET tier = ?, updated_at = ? WHERE id = ?").run(tier, now, id); + orm(db).update(memoryItems).set({ tier, updatedAt: now }).where(eq(memoryItems.id, id)).run(); writeAudit(db, now, item.identityId, "memory_tier_changed", { memoryId: id, tier }); return requireItem(db, id); } @@ -161,15 +126,10 @@ export interface QueryMemoryOpts { // SPEC §8.4 inspection + §7.1 isolation: always identity-scoped, active-only by default. export function queryMemory(db: Database, identityId: string, opts: QueryMemoryOpts = {}): MemoryItem[] { - const where = ["identity_id = ?"]; - const params: string[] = [identityId]; - if (!opts.includeRetracted) where.push("status = 'active'"); - if (opts.tier) { - where.push("tier = ?"); - params.push(opts.tier); - } - const rows = many(db, `SELECT * FROM memory_items WHERE ${where.join(" AND ")} ORDER BY created_at`, ...params); - return rows.map(rowToItem); + const conds: SQL[] = [eq(memoryItems.identityId, identityId)]; + if (!opts.includeRetracted) conds.push(eq(memoryItems.status, "active")); + if (opts.tier) conds.push(eq(memoryItems.tier, opts.tier)); + return orm(db).select().from(memoryItems).where(and(...conds)).orderBy(asc(memoryItems.createdAt)).all(); } // SPEC §8.6: recent items unconfirmed past maxAgeMs demote to archive — decay is demotion, @@ -186,13 +146,9 @@ export interface DecayStaleMemoryOpts { maxItems?: number; } -export interface DecayResult { - decayed: string[]; -} - // SPEC §8.5 hygiene (SHOULD, not MUST): retire old/stale items, then — if still over the // per-identity size cap — evict the stalest remaining items first. -export function decayStaleMemory(db: Database, clock: Clock, identityId: string, opts: DecayStaleMemoryOpts): DecayResult { +export function decayStaleMemory(db: Database, clock: Clock, identityId: string, opts: DecayStaleMemoryOpts) { const now = clock(); const active = queryMemory(db, identityId).toSorted((a, b) => a.lastConfirmedAt.localeCompare(b.lastConfirmedAt)); const decayed: string[] = []; diff --git a/src/ledger/scheduler.ts b/src/ledger/scheduler.ts index 685cf67..b3f09af 100644 --- a/src/ledger/scheduler.ts +++ b/src/ledger/scheduler.ts @@ -4,14 +4,9 @@ import type { Database } from "bun:sqlite"; import type { Clock } from "./clock"; import { listDueTimers, markTimerFired, scheduleTimer, type TimerRow, type TimerKind } from "./timers"; import { getTask, transition, type Task, type WaitingOn } from "./tasks"; -import { many, one } from "./db"; - -export interface FiredTimerResult { - timerId: string; - kind: TimerKind; - subjectId: string | null; - applied: boolean; -} +import { asc, count, eq, isNull, min } from "drizzle-orm"; +import { orm } from "./db"; +import { executions, tasks, timers } from "./schema"; export interface FireDueTimersOpts { parkAfterMs: number; @@ -119,9 +114,9 @@ function applyTimer(db: Database, clock: Clock, timer: TimerRow, opts: FireDueTi } } -export function fireDueTimers(db: Database, clock: Clock, opts: FireDueTimersOpts): FiredTimerResult[] { +export function fireDueTimers(db: Database, clock: Clock, opts: FireDueTimersOpts) { const due = listDueTimers(db, clock); - const results: FiredTimerResult[] = []; + const results: Array<{ timerId: string; kind: TimerKind; subjectId: string | null; applied: boolean }> = []; for (const timer of due) { const applied = applyTimer(db, clock, timer, opts); markTimerFired(db, clock, timer.id); @@ -135,7 +130,7 @@ export function fireDueTimers(db: Database, clock: Clock, opts: FireDueTimersOpt // waking on a fixed short interval all night — while `maxMs` bounds the wait so a newly-dispatched // task or a policy reload is still picked up promptly. export function msUntilNextTimer(db: Database, clock: Clock, maxMs: number): number { - const row = one<{ next: string | null }>(db, "SELECT MIN(due_at) as next FROM timers WHERE fired_at IS NULL"); + const row = orm(db).select({ next: min(timers.dueAt) }).from(timers).where(isNull(timers.firedAt)).get(); if (!row?.next) return maxMs; const delta = new Date(row.next).getTime() - new Date(clock()).getTime(); return Math.max(0, Math.min(delta, maxMs)); @@ -148,28 +143,26 @@ export interface DispatchOpts { newExecutionId: () => string; } -export interface DispatchResult { - dispatched: string[]; - deferredBudget: string[]; - deferredConcurrency: string[]; -} - // SPEC §6.2, §17.3: runnable = open tasks, oldest-opened-first, bounded by per-identity/global // concurrency, budget headroom checked before launch. waiting(timer) tasks whose wake_at has // passed are already promoted to open by fireDueTimers before this runs. -export function dispatchRunnable(db: Database, clock: Clock, opts: DispatchOpts): DispatchResult { - const openTasks = many<{ id: string; identity_id: string }>( - db, - "SELECT id, identity_id FROM tasks WHERE status = 'open' ORDER BY opened_at ASC, id ASC", - ); +export function dispatchRunnable(db: Database, clock: Clock, opts: DispatchOpts) { + const openTasks = orm(db) + .select({ id: tasks.id, identityId: tasks.identityId }) + .from(tasks) + .where(eq(tasks.status, "open")) + .orderBy(asc(tasks.openedAt), asc(tasks.id)) + .all(); const runningByIdentity = new Map(); - const runningRows = many<{ identity_id: string; c: number }>( - db, - `SELECT t.identity_id as identity_id, COUNT(*) as c FROM executions e - JOIN tasks t ON t.id = e.task_id WHERE e.status = 'running' GROUP BY t.identity_id`, - ); - for (const row of runningRows) runningByIdentity.set(row.identity_id, row.c); + const runningRows = orm(db) + .select({ identityId: tasks.identityId, c: count() }) + .from(executions) + .innerJoin(tasks, eq(tasks.id, executions.taskId)) + .where(eq(executions.status, "running")) + .groupBy(tasks.identityId) + .all(); + for (const row of runningRows) runningByIdentity.set(row.identityId, row.c); let globalRunning = runningRows.reduce((sum, row) => sum + row.c, 0); const dispatched: string[] = []; @@ -181,29 +174,24 @@ export function dispatchRunnable(db: Database, clock: Clock, opts: DispatchOpts) deferredConcurrency.push(row.id); continue; } - const identityRunning = runningByIdentity.get(row.identity_id) ?? 0; + const identityRunning = runningByIdentity.get(row.identityId) ?? 0; if (identityRunning >= opts.maxConcurrentPerIdentity) { deferredConcurrency.push(row.id); continue; } - if (opts.hasBudgetHeadroom && !opts.hasBudgetHeadroom(row.identity_id)) { + if (opts.hasBudgetHeadroom && !opts.hasBudgetHeadroom(row.identityId)) { deferredBudget.push(row.id); continue; } transition(db, clock, row.id, "active", { type: "dispatch", executionId: opts.newExecutionId() }); dispatched.push(row.id); - runningByIdentity.set(row.identity_id, identityRunning + 1); + runningByIdentity.set(row.identityId, identityRunning + 1); globalRunning += 1; } return { dispatched, deferredBudget, deferredConcurrency }; } -export interface RestartRecoveryResult { - reopened: string[]; - parked: string[]; -} - // SPEC §14.2's "interrupted, redispatch, or park past the bound" logic — shared by restart // recovery below AND by the execution loop's reaction to a same-process turn crash/stall (both // are "this execution died unexpectedly"; the crash-loop protection should apply identically). @@ -231,16 +219,17 @@ export function recoverFromRestart( db: Database, clock: Clock, opts: { maxConsecutiveInterruptions: number }, -): RestartRecoveryResult { - const orphaned = many<{ id: string; consecutive_interruptions: number }>( - db, - "SELECT id, consecutive_interruptions FROM tasks WHERE status = 'active'", - ); +) { + const orphaned = orm(db) + .select({ id: tasks.id, consecutiveInterruptions: tasks.consecutiveInterruptions }) + .from(tasks) + .where(eq(tasks.status, "active")) + .all(); const reopened: string[] = []; const parked: string[] = []; - for (const { id, consecutive_interruptions } of orphaned) { - const outcome = interruptOrPark(db, clock, id, consecutive_interruptions, opts.maxConsecutiveInterruptions); + for (const { id, consecutiveInterruptions } of orphaned) { + const outcome = interruptOrPark(db, clock, id, consecutiveInterruptions, opts.maxConsecutiveInterruptions); (outcome === "parked" ? parked : reopened).push(id); } diff --git a/src/ledger/schema.ts b/src/ledger/schema.ts new file mode 100644 index 0000000..52a38e5 --- /dev/null +++ b/src/ledger/schema.ts @@ -0,0 +1,276 @@ +// Typed ledger tables. DDL still lives in schema.sql + the version ladder; this file is the +// query-time contract so selects/inserts/updates carry row types instead of one guesses. +import { index, integer, primaryKey, real, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +export const schemaVersion = sqliteTable("schema_version", { + version: integer("version").notNull(), +}); + +export const events = sqliteTable( + "events", + { + id: text("id").primaryKey(), + dedupKey: text("dedup_key").notNull().unique(), + kind: text("kind", { + enum: ["addressed_message", "observed_message", "timer_fired", "external_signal", "operator_action"], + }).notNull(), + identityId: text("identity_id").notNull(), + venueId: text("venue_id"), + threadRootId: text("thread_root_id"), + principalId: text("principal_id"), + payload: text("payload", { mode: "json" }).$type>().notNull(), + receivedAt: text("received_at").notNull(), + }, + (t) => [ + index("events_conversation").on(t.identityId, t.venueId, t.threadRootId), + index("events_root_ts").on(t.venueId).where(sql`thread_root_id IS NULL`), + ], +); + +export const tasks = sqliteTable( + "tasks", + { + id: text("id").primaryKey(), + identityId: text("identity_id").notNull(), + title: text("title").notNull(), + spec: text("spec").notNull(), + status: text("status", { + enum: ["open", "active", "waiting", "parked", "done", "failed", "cancelled"], + }).notNull(), + waitingOn: text("waiting_on", { enum: ["human", "timer", "external"] }), + sponsorId: text("sponsor_id").notNull(), + homeVenueId: text("home_venue_id").notNull(), + homeThreadRootId: text("home_thread_root_id"), + originEventId: text("origin_event_id").notNull(), + wakeAt: text("wake_at"), + pendingConfirmation: text("pending_confirmation", { mode: "json" }).$type>(), + recurrence: text("recurrence"), + tier: text("tier", { enum: ["low", "medium", "high"] }).notNull(), + artifacts: text("artifacts", { mode: "json" }).$type().notNull(), + terminalReport: text("terminal_report"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + openedAt: text("opened_at").notNull(), + consecutiveInterruptions: integer("consecutive_interruptions").notNull(), + }, + (t) => [index("tasks_dispatch").on(t.identityId, t.status, t.openedAt)], +); + +export const executions = sqliteTable( + "executions", + { + id: text("id").primaryKey(), + taskId: text("task_id").notNull(), + attempt: integer("attempt").notNull(), + status: text("status", { + enum: ["running", "yielded", "succeeded", "failed", "cancelled", "interrupted"], + }).notNull(), + startedAt: text("started_at").notNull(), + endedAt: text("ended_at"), + }, + (t) => [uniqueIndex("one_live_execution_per_task").on(t.taskId).where(sql`status = 'running'`)], +); + +export const steering = sqliteTable("steering", { + id: text("id").primaryKey(), + taskId: text("task_id").notNull(), + kind: text("kind", { enum: ["guidance", "cancel", "pause", "resume", "confirm"] }).notNull(), + payload: text("payload", { mode: "json" }).$type>().notNull(), + sourceEventId: text("source_event_id").notNull(), + createdAt: text("created_at").notNull(), + consumedAt: text("consumed_at"), +}); + +export const turns = sqliteTable( + "turns", + { + id: text("id").primaryKey(), + identityId: text("identity_id").notNull(), + kind: text("kind", { + enum: ["interactive", "execution_step", "ambient", "distillation", "resident", "attention"], + }).notNull(), + executionId: text("execution_id"), + venueId: text("venue_id"), + threadRootId: text("thread_root_id"), + status: text("status", { enum: ["succeeded", "failed", "timed_out", "budget_denied"] }).notNull(), + effects: text("effects", { mode: "json" }).$type().notNull(), + spendAmount: real("spend_amount").notNull(), + startedAt: text("started_at").notNull(), + endedAt: text("ended_at"), + }, + (t) => [index("turns_spend").on(t.identityId, t.startedAt)], +); + +export const memoryItems = sqliteTable( + "memory_items", + { + id: text("id").primaryKey(), + identityId: text("identity_id").notNull(), + content: text("content").notNull(), + provenance: text("provenance", { mode: "json" }).$type().notNull(), + tier: text("tier", { enum: ["core", "recent", "archive"] }).notNull(), + status: text("status", { enum: ["active", "retracted"] }).notNull(), + supersededBy: text("superseded_by"), + createdAt: text("created_at").notNull(), + updatedAt: text("updated_at").notNull(), + lastConfirmedAt: text("last_confirmed_at").notNull(), + }, + (t) => [index("memory_active").on(t.identityId, t.status)], +); + +export const eventsFts = sqliteTable("events_fts", { + rowid: integer("rowid"), + text: text("text"), +}); + +export const memoryFts = sqliteTable("memory_fts", { + rowid: integer("rowid"), + content: text("content"), +}); + +export const timers = sqliteTable( + "timers", + { + id: text("id").primaryKey(), + kind: text("kind", { + enum: ["task_wake", "nudge", "park", "ambient_tick", "distillation", "recurrence"], + }).notNull(), + identityId: text("identity_id").notNull(), + subjectId: text("subject_id"), + dueAt: text("due_at").notNull(), + firedAt: text("fired_at"), + }, + (t) => [ + index("timers_due").on(t.dueAt).where(sql`fired_at IS NULL`), + uniqueIndex("timers_singleton_pending").on(t.kind, t.identityId).where(sql`fired_at IS NULL AND kind IN ('ambient_tick','distillation')`), + ], +); + +export const audit = sqliteTable("audit", { + id: integer("id").primaryKey({ autoIncrement: true }), + at: text("at").notNull(), + identityId: text("identity_id").notNull(), + kind: text("kind", { + enum: [ + "event_received", + "turn_started", + "turn_ended", + "task_created", + "task_transitioned", + "tool_invoked", + "confirmation_requested", + "confirmation_resolved", + "ambient_posted", + "budget_denied", + "memory_written", + "memory_retracted", + "memory_tier_changed", + ], + }).notNull(), + payload: text("payload", { mode: "json" }).$type().notNull(), +}); + +export const attentionItems = sqliteTable( + "attention_items", + { + id: text("id").primaryKey(), + identityId: text("identity_id").notNull(), + venueId: text("venue_id").notNull(), + threadRootId: text("thread_root_id"), + askTs: text("ask_ts"), + what: text("what").notNull(), + openedAt: text("opened_at").notNull(), + closedAt: text("closed_at"), + closedCause: text("closed_cause"), + }, + (t) => [index("attention_open").on(t.identityId, t.closedAt)], +); + +export const conversations = sqliteTable( + "conversations", + { + identityId: text("identity_id").notNull(), + venueId: text("venue_id").notNull(), + threadRootId: text("thread_root_id").notNull(), + firstAt: text("first_at").notNull(), + deliveredRowid: integer("delivered_rowid").notNull(), + judgedRowid: integer("judged_rowid").notNull(), + holds: integer("holds").notNull(), + holdWhys: text("hold_whys", { mode: "json" }).$type().notNull(), + wakeWhy: text("wake_why"), + stance: text("stance", { enum: ["none", "engaged", "out"] }).notNull(), + stanceWhy: text("stance_why"), + stanceAt: text("stance_at"), + }, + (t) => [primaryKey({ columns: [t.identityId, t.venueId, t.threadRootId] })], +); + +export const acts = sqliteTable( + "acts", + { + id: integer("id").primaryKey({ autoIncrement: true }), + wakeId: text("wake_id").notNull(), + actKey: text("act_key").notNull(), + identityId: text("identity_id").notNull(), + kind: text("kind", { enum: ["posted", "reacted"] }).notNull(), + venueId: text("venue_id").notNull(), + threadRootId: text("thread_root_id"), + ts: text("ts"), + text: text("text"), + at: text("at").notNull(), + }, + (t) => [ + uniqueIndex("acts_wake_key").on(t.wakeId, t.actKey), + index("acts_conversation").on(t.identityId, t.venueId, t.threadRootId, t.at), + ], +); + +export const drafts = sqliteTable("drafts", { + id: integer("id").primaryKey({ autoIncrement: true }), + identityId: text("identity_id").notNull(), + venueId: text("venue_id").notNull(), + threadRootId: text("thread_root_id"), + text: text("text").notNull(), + draftedAt: text("drafted_at").notNull(), + consumedAt: text("consumed_at"), +}); + +export const outwardCalls = sqliteTable( + "outward_calls", + { + id: integer("id").primaryKey({ autoIncrement: true }), + identityId: text("identity_id").notNull(), + scopeId: text("scope_id").notNull(), + tool: text("tool").notNull(), + argsHash: text("args_hash").notNull(), + at: text("at").notNull(), + confirmed: integer("confirmed").notNull(), + }, + (t) => [uniqueIndex("outward_calls_scope").on(t.scopeId, t.tool, t.argsHash)], +); + +export type Event = typeof events.$inferSelect; +export type TaskRow = typeof tasks.$inferSelect; +export type TaskStatus = TaskRow["status"]; +export type WaitingOn = NonNullable; +export type TaskTier = TaskRow["tier"]; +export type Execution = typeof executions.$inferSelect; +export type Steering = typeof steering.$inferSelect; +export type SteeringKind = Steering["kind"]; +export type TurnRow = typeof turns.$inferSelect; +export type TurnKind = TurnRow["kind"]; +export type TurnStatus = TurnRow["status"]; +export type MemoryItem = typeof memoryItems.$inferSelect; +export type MemoryTier = MemoryItem["tier"]; +export type MemoryStatus = MemoryItem["status"]; +export type Timer = typeof timers.$inferSelect; +export type TimerKind = Timer["kind"]; +export type Audit = typeof audit.$inferSelect; +export type AuditKind = Audit["kind"]; +export type AttentionItem = typeof attentionItems.$inferSelect; +export type Conversation = typeof conversations.$inferSelect; +export type Stance = Conversation["stance"]; +export type Act = typeof acts.$inferSelect; +export type Draft = typeof drafts.$inferSelect; +export type OutwardCall = typeof outwardCalls.$inferSelect; diff --git a/src/ledger/search.ts b/src/ledger/search.ts index 0dadfe9..fbbe287 100644 --- a/src/ledger/search.ts +++ b/src/ledger/search.ts @@ -3,8 +3,10 @@ // schema triggers; this module only queries. Identity isolation (§7.1) is structural: every query // takes an explicit identityId and filters on it in SQL. import type { Database } from "bun:sqlite"; +import { and, eq, sql, type SQL } from "drizzle-orm"; import type { MemoryTier } from "./memory"; -import { many } from "./db"; +import { orm } from "./db"; +import { events, eventsFts, memoryFts, memoryItems } from "./schema"; export interface SearchOpts { query: string; @@ -53,53 +55,39 @@ export function searchArchive(db: Database, identityId: string, opts: SearchOpts const limit = Math.min(opts.limit ?? 10, 25); const messages = ftsMatch((match) => { - const where = ["events_fts MATCH ?", "e.identity_id = ?"]; - const params: string[] = [match, identityId]; - if (opts.venueId) { - where.push("e.venue_id = ?"); - params.push(opts.venueId); - } - if (opts.principalId) { - where.push("e.principal_id = ?"); - params.push(opts.principalId); - } - if (opts.after) { - where.push("e.received_at >= ?"); - params.push(opts.after); - } - if (opts.before) { - where.push("e.received_at <= ?"); - params.push(opts.before); - } - const rows = many<{ - text: string | null; - rank: number; - at: string; - venue_id: string | null; - thread_root_id: string | null; - principal_id: string | null; - ts: string | null; - }>( - db, - `SELECT json_extract(e.payload, '$.text') AS text, bm25(events_fts) AS rank, e.received_at AS at, - e.venue_id, e.thread_root_id, e.principal_id, json_extract(e.payload, '$.ts') AS ts - FROM events_fts JOIN events e ON e.rowid = events_fts.rowid - WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`, - ...params, - limit, - ); - return rows.map((r) => ({ - kind: "message" as const, - text: r.text ?? "", - rank: r.rank, - at: r.at, - venueId: r.venue_id, - threadRootId: r.thread_root_id, - principalId: r.principal_id, - ts: r.ts, - memoryId: null, - tier: null, - })); + const conds: SQL[] = [sql`events_fts MATCH ${match}`, eq(events.identityId, identityId)]; + if (opts.venueId) conds.push(eq(events.venueId, opts.venueId)); + if (opts.principalId) conds.push(eq(events.principalId, opts.principalId)); + if (opts.after) conds.push(sql`${events.receivedAt} >= ${opts.after}`); + if (opts.before) conds.push(sql`${events.receivedAt} <= ${opts.before}`); + return orm(db) + .select({ + text: sql`json_extract(${events.payload}, '$.text')`, + rank: sql`bm25(events_fts)`, + at: events.receivedAt, + venueId: events.venueId, + threadRootId: events.threadRootId, + principalId: events.principalId, + ts: sql`json_extract(${events.payload}, '$.ts')`, + }) + .from(eventsFts) + .innerJoin(events, eq(sql`${events}.rowid`, eventsFts.rowid)) + .where(and(...conds)) + .orderBy(sql`rank`) + .limit(limit) + .all() + .map((r) => ({ + kind: "message" as const, + text: r.text ?? "", + rank: r.rank, + at: r.at, + venueId: r.venueId, + threadRootId: r.threadRootId, + principalId: r.principalId, + ts: r.ts, + memoryId: null, + tier: null, + })); }, opts.query); // venue/principal filters name message properties — memories have neither, so they only join @@ -108,36 +96,35 @@ export function searchArchive(db: Database, identityId: string, opts: SearchOpts opts.venueId || opts.principalId ? [] : ftsMatch((match) => { - const where = ["memory_fts MATCH ?", "m.identity_id = ?", "m.status = 'active'"]; - const params: string[] = [match, identityId]; - if (opts.after) { - where.push("m.created_at >= ?"); - params.push(opts.after); - } - if (opts.before) { - where.push("m.created_at <= ?"); - params.push(opts.before); - } - const rows = many<{ text: string; rank: number; at: string; id: string; tier: MemoryTier }>( - db, - `SELECT m.content AS text, bm25(memory_fts) AS rank, m.created_at AS at, m.id, m.tier - FROM memory_fts JOIN memory_items m ON m.rowid = memory_fts.rowid - WHERE ${where.join(" AND ")} ORDER BY rank LIMIT ?`, - ...params, - limit, - ); - return rows.map((r) => ({ - kind: "memory" as const, - text: r.text, - rank: r.rank, - at: r.at, - venueId: null, - threadRootId: null, - principalId: null, - ts: null, - memoryId: r.id, - tier: r.tier, - })); + const conds: SQL[] = [sql`memory_fts MATCH ${match}`, eq(memoryItems.identityId, identityId), eq(memoryItems.status, "active")]; + if (opts.after) conds.push(sql`${memoryItems.createdAt} >= ${opts.after}`); + if (opts.before) conds.push(sql`${memoryItems.createdAt} <= ${opts.before}`); + return orm(db) + .select({ + text: memoryItems.content, + rank: sql`bm25(memory_fts)`, + at: memoryItems.createdAt, + id: memoryItems.id, + tier: memoryItems.tier, + }) + .from(memoryFts) + .innerJoin(memoryItems, eq(sql`${memoryItems}.rowid`, memoryFts.rowid)) + .where(and(...conds)) + .orderBy(sql`rank`) + .limit(limit) + .all() + .map((r) => ({ + kind: "memory" as const, + text: r.text, + rank: r.rank, + at: r.at, + venueId: null, + threadRootId: null, + principalId: null, + ts: null, + memoryId: r.id, + tier: r.tier, + })); }, opts.query); return [...messages, ...memories].toSorted((a, b) => a.rank - b.rank).slice(0, limit); diff --git a/src/ledger/tasks.ts b/src/ledger/tasks.ts index 4fee61b..c9b223a 100644 --- a/src/ledger/tasks.ts +++ b/src/ledger/tasks.ts @@ -1,15 +1,16 @@ // SPEC §6 — Task Ledger. This module is the single choke point for task state changes: // every status change and every executions-row change for a task goes through transition(). import type { Database } from "bun:sqlite"; +import { and, asc, eq, isNull, max, notInArray, inArray, desc, sql } from "drizzle-orm"; import { asString, isRecord, parseJson } from "../guard"; import type { Clock } from "./clock"; -import { many, one } from "./db"; +import { orm } from "./db"; +import { executions, steering, tasks, type Steering, type SteeringKind, type TaskRow, type TaskStatus, type WaitingOn } from "./schema"; import { scheduleTimer, type TimerKind } from "./timers"; import { writeAudit, type AuditKind } from "./audit"; -export type TaskStatus = "open" | "active" | "waiting" | "parked" | "done" | "failed" | "cancelled"; -export type WaitingOn = "human" | "timer" | "external"; -export type SteeringKind = "guidance" | "cancel" | "pause" | "resume" | "confirm"; +export type SteeringRow = Steering; +export type { SteeringKind, TaskStatus, WaitingOn }; export interface Anchor { venueId: string; @@ -52,16 +53,6 @@ export interface Task { consecutiveInterruptions: number; } -export interface SteeringRow { - id: string; - taskId: string; - kind: SteeringKind; - payload: Record; - sourceEventId: string; - createdAt: string; - consumedAt: string | null; -} - export class IllegalTransitionError extends Error { constructor(taskId: string, from: TaskStatus, to: TaskStatus, causeType: string) { super(`T-illegal: cannot transition ${taskId} from ${from} to ${to} via ${causeType}`); @@ -104,36 +95,9 @@ export type TransitionCause = | { type: "recurrence_rearm"; wakeAt: string } | { type: "recurrence_failed"; wakeAt: string }; -interface Row { - id: string; - identity_id: string; - title: string; - spec: string; - status: TaskStatus; - waiting_on: WaitingOn | null; - sponsor_id: string; - home_venue_id: string; - home_thread_root_id: string | null; - origin_event_id: string; - wake_at: string | null; - pending_confirmation: string | null; - recurrence: string | null; - tier: string; - artifacts: string; - terminal_report: string | null; - created_at: string; - updated_at: string; - opened_at: string; - consecutive_interruptions: number; -} - -function asTier(v: string): Task["tier"] { - return v === "low" || v === "medium" ? v : "high"; -} - -function parsePending(text: string | null): PendingConfirmation | null { - if (!text) return null; - const v = parseJson(text); +function parsePending(raw: unknown): PendingConfirmation | null { + if (!raw) return null; + const v = typeof raw === "string" ? parseJson(raw) : raw; if (!isRecord(v)) return null; const pending: PendingConfirmation = { actionRef: asString(v.actionRef), @@ -151,60 +115,66 @@ function parsePending(text: string | null): PendingConfirmation | null { return pending; } -function parseArtifacts(text: string): string[] { - const v = parseJson(text); +function parseArtifacts(raw: unknown): string[] { + const v = typeof raw === "string" ? parseJson(raw) : raw; return Array.isArray(v) ? v.map((x) => asString(x)) : []; } -function rowToTask(row: Row): Task { +function rowToTask(row: TaskRow): Task { return { id: row.id, - identityId: row.identity_id, + identityId: row.identityId, title: row.title, spec: row.spec, status: row.status, - waitingOn: row.waiting_on, - sponsorId: row.sponsor_id, - homeAnchor: { venueId: row.home_venue_id, threadRootId: row.home_thread_root_id }, - originEventId: row.origin_event_id, - wakeAt: row.wake_at, - pendingConfirmation: parsePending(row.pending_confirmation), + waitingOn: row.waitingOn, + sponsorId: row.sponsorId, + homeAnchor: { venueId: row.homeVenueId, threadRootId: row.homeThreadRootId }, + originEventId: row.originEventId, + wakeAt: row.wakeAt, + pendingConfirmation: parsePending(row.pendingConfirmation), recurrence: row.recurrence, - tier: asTier(row.tier), + tier: row.tier, artifacts: parseArtifacts(row.artifacts), - terminalReport: row.terminal_report, - createdAt: row.created_at, - updatedAt: row.updated_at, - openedAt: row.opened_at, - consecutiveInterruptions: row.consecutive_interruptions, + terminalReport: row.terminalReport, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + openedAt: row.openedAt, + consecutiveInterruptions: row.consecutiveInterruptions, }; } export function getTask(db: Database, taskId: string): Task | null { - const row = one(db, "SELECT * FROM tasks WHERE id = ?", taskId); + const row = orm(db).select().from(tasks).where(eq(tasks.id, taskId)).get(); return row ? rowToTask(row) : null; } // SPEC §4.2 — "short, human-readable, unique per service instance, and usable in chat." T-1, T-2, ... export function nextTaskId(db: Database): string { - const row = one<{ n: number | null }>(db, "SELECT MAX(CAST(SUBSTR(id, 3) AS INTEGER)) as n FROM tasks WHERE id LIKE 'T-%'"); + const row = orm(db) + .select({ n: sql`MAX(CAST(SUBSTR(${tasks.id}, 3) AS INTEGER))` }) + .from(tasks) + .where(sql`${tasks.id} LIKE 'T-%'`) + .get(); return `T-${(row?.n ?? 0) + 1}`; } // SPEC §11 — the ledger view a turn's context is built from: open tasks + recent terminals, for // one identity (never cross-identity, per §7.1). export function ledgerView(db: Database, identityId: string, recentTerminalsLimit = 10): { open: Task[]; recentTerminals: Task[] } { - const openRows = many( - db, - "SELECT * FROM tasks WHERE identity_id = ? AND status NOT IN ('done','failed','cancelled') ORDER BY opened_at ASC", - identityId, - ); - const terminalRows = many( - db, - "SELECT * FROM tasks WHERE identity_id = ? AND status IN ('done','failed','cancelled') ORDER BY updated_at DESC LIMIT ?", - identityId, - recentTerminalsLimit, - ); + const openRows = orm(db) + .select() + .from(tasks) + .where(and(eq(tasks.identityId, identityId), notInArray(tasks.status, ["done", "failed", "cancelled"]))) + .orderBy(asc(tasks.openedAt)) + .all(); + const terminalRows = orm(db) + .select() + .from(tasks) + .where(and(eq(tasks.identityId, identityId), inArray(tasks.status, ["done", "failed", "cancelled"]))) + .orderBy(desc(tasks.updatedAt)) + .limit(recentTerminalsLimit) + .all(); return { open: openRows.map(rowToTask), recentTerminals: terminalRows.map(rowToTask) }; } @@ -227,14 +197,18 @@ export function requireTaskFor(db: Database, identityId: string, taskId: string) // there's at most one). Exported so the service's dispatch driver can find the execution id // dispatchRunnable just created, to hand to runExecution. export function liveExecutionId(db: Database, taskId: string): string | null { - const row = one<{ id: string }>(db, "SELECT id FROM executions WHERE task_id = ? AND status = 'running'", taskId); + const row = orm(db) + .select({ id: executions.id }) + .from(executions) + .where(and(eq(executions.taskId, taskId), eq(executions.status, "running"))) + .get(); return row?.id ?? null; } -function endExecution(db: Database, taskId: string, at: string, status: string) { +function endExecution(db: Database, taskId: string, at: string, status: typeof executions.$inferSelect["status"]) { const execId = liveExecutionId(db, taskId); if (!execId) return; - db.query("UPDATE executions SET status = ?, ended_at = ? WHERE id = ?").run(status, at, execId); + orm(db).update(executions).set({ status, endedAt: at }).where(eq(executions.id, execId)).run(); } // The durable counterpart to a task's wake_at: lets the scheduler tell nudge/park/task_wake @@ -261,27 +235,31 @@ export function createTask(db: Database, clock: Clock, params: CreateTaskParams) throw new RecurrenceRequiresOperatorError(); } const now = clock(); - db.query( - `INSERT INTO tasks - (id, identity_id, title, spec, status, waiting_on, sponsor_id, home_venue_id, home_thread_root_id, - origin_event_id, wake_at, pending_confirmation, recurrence, tier, artifacts, terminal_report, - created_at, updated_at, opened_at) - VALUES (?, ?, ?, ?, 'open', NULL, ?, ?, ?, ?, NULL, NULL, ?, ?, '[]', NULL, ?, ?, ?)`, - ).run( - params.id, - params.identityId, - params.title, - params.spec, - params.sponsorId, - params.homeAnchor.venueId, - params.homeAnchor.threadRootId, - params.originEventId, - params.recurrence ?? null, - params.tier ?? "high", - now, - now, - now, - ); + orm(db) + .insert(tasks) + .values({ + id: params.id, + identityId: params.identityId, + title: params.title, + spec: params.spec, + status: "open", + waitingOn: null, + sponsorId: params.sponsorId, + homeVenueId: params.homeAnchor.venueId, + homeThreadRootId: params.homeAnchor.threadRootId, + originEventId: params.originEventId, + wakeAt: null, + pendingConfirmation: null, + recurrence: params.recurrence ?? null, + tier: params.tier ?? "high", + artifacts: [], + terminalReport: null, + createdAt: now, + updatedAt: now, + openedAt: now, + consecutiveInterruptions: 0, + }) + .run(); writeAudit(db, now, params.identityId, "task_created", { taskId: params.id, title: params.title }); return requireTask(db, params.id); } @@ -351,13 +329,11 @@ function applyTransition( switch (cause.type) { case "dispatch": { - const attempt = (one<{ m: number | null }>(db, "SELECT MAX(attempt) as m FROM executions WHERE task_id = ?", taskId)?.m ?? 0) + 1; - db.query("INSERT INTO executions (id, task_id, attempt, status, started_at) VALUES (?, ?, ?, 'running', ?)").run( - cause.executionId, - taskId, - attempt, - now, - ); + const attempt = (orm(db).select({ m: max(executions.attempt) }).from(executions).where(eq(executions.taskId, taskId)).get()?.m ?? 0) + 1; + orm(db) + .insert(executions) + .values({ id: cause.executionId, taskId, attempt, status: "running", startedAt: now, endedAt: null }) + .run(); waitingOn = null; wakeAt = null; break; @@ -446,21 +422,21 @@ function applyTransition( } } - db.query( - `UPDATE tasks SET status = ?, waiting_on = ?, wake_at = ?, terminal_report = ?, pending_confirmation = ?, - recurrence = ?, opened_at = ?, consecutive_interruptions = ?, updated_at = ? WHERE id = ?`, - ).run( - to, - waitingOn, - wakeAt, - terminalReport, - pendingConfirmation ? JSON.stringify(pendingConfirmation) : null, - recurrence, - openedAt, - consecutiveInterruptions, - now, - taskId, - ); + orm(db) + .update(tasks) + .set({ + status: to, + waitingOn, + wakeAt, + terminalReport, + pendingConfirmation: pendingConfirmation ? { ...pendingConfirmation } : null, + recurrence, + openedAt, + consecutiveInterruptions, + updatedAt: now, + }) + .where(eq(tasks.id, taskId)) + .run(); writeAudit(db, now, task.identityId, "task_transitioned", { taskId, from: task.status, to, cause: cause.type }); return requireTask(db, taskId); @@ -502,10 +478,18 @@ function insertSteeringRow( consumed: boolean, ): void { const now = clock(); - db.query( - `INSERT INTO steering (id, task_id, kind, payload, source_event_id, created_at, consumed_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ).run(`${taskId}-steer-${now}-${Math.random().toString(36).slice(2, 8)}`, taskId, kind, JSON.stringify(payload), sourceEventId, now, consumed ? now : null); + orm(db) + .insert(steering) + .values({ + id: `${taskId}-steer-${now}-${Math.random().toString(36).slice(2, 8)}`, + taskId, + kind, + payload, + sourceEventId, + createdAt: now, + consumedAt: consumed ? now : null, + }) + .run(); } export interface SteerParams { @@ -550,7 +534,11 @@ export function steerTask(db: Database, clock: Clock, params: SteerParams): Stee function appendSpec(db: Database, clock: Clock, task: Task, addition: string): void { const now = clock(); - db.query("UPDATE tasks SET spec = spec || ? , updated_at = ? WHERE id = ?").run(`\n\n${addition}`, now, task.id); + orm(db) + .update(tasks) + .set({ spec: sql`${tasks.spec} || ${`\n\n${addition}`}`, updatedAt: now }) + .where(eq(tasks.id, task.id)) + .run(); } function steerGuidance(db: Database, clock: Clock, task: Task, params: SteerParams): SteerResult { @@ -608,31 +596,25 @@ function steerConfirm(db: Database, clock: Clock, task: Task, params: SteerParam } export function consumeSteering(db: Database, clock: Clock, taskId: string): SteeringRow[] { - const rows = many<{ - id: string; - task_id: string; - kind: SteeringKind; - payload: string; - source_event_id: string; - created_at: string; - consumed_at: string | null; - }>(db, "SELECT * FROM steering WHERE task_id = ? AND consumed_at IS NULL ORDER BY created_at", taskId); + const rows = orm(db) + .select() + .from(steering) + .where(and(eq(steering.taskId, taskId), isNull(steering.consumedAt))) + .orderBy(asc(steering.createdAt)) + .all(); const now = clock(); for (const row of rows) { - db.query("UPDATE steering SET consumed_at = ? WHERE id = ?").run(now, row.id); + orm(db).update(steering).set({ consumedAt: now }).where(eq(steering.id, row.id)).run(); } - return rows.map((row) => { - const parsed = parseJson(row.payload); - return { - id: row.id, - taskId: row.task_id, - kind: row.kind, - payload: isRecord(parsed) ? parsed : {}, - sourceEventId: row.source_event_id, - createdAt: row.created_at, - consumedAt: now, - }; - }); + return rows.map((row) => ({ + id: row.id, + taskId: row.taskId, + kind: row.kind, + payload: isRecord(row.payload) ? row.payload : {}, + sourceEventId: row.sourceEventId, + createdAt: row.createdAt, + consumedAt: now, + })); } export interface RequestConfirmationParams { @@ -717,5 +699,9 @@ export function consumeConfirmation(db: Database, clock: Clock, taskId: string): const task = requireTask(db, taskId); if (!task.pendingConfirmation) return; const pendingConfirmation: PendingConfirmation = { ...task.pendingConfirmation, consumedAt: clock() }; - db.query("UPDATE tasks SET pending_confirmation = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(pendingConfirmation), clock(), taskId); + orm(db) + .update(tasks) + .set({ pendingConfirmation: { ...pendingConfirmation }, updatedAt: clock() }) + .where(eq(tasks.id, taskId)) + .run(); } diff --git a/src/ledger/timers.ts b/src/ledger/timers.ts index 50ff533..4a10110 100644 --- a/src/ledger/timers.ts +++ b/src/ledger/timers.ts @@ -1,19 +1,12 @@ // SPEC §13 — durable timers. This module owns only the timers table; it has no knowledge of the // task state machine (that lives in tasks.ts, which schedules timers through this module). import type { Database } from "bun:sqlite"; +import { and, asc, eq, isNull, lte } from "drizzle-orm"; import type { Clock } from "./clock"; -import { many } from "./db"; +import { orm } from "./db"; +import { timers, type Timer, type TimerKind } from "./schema"; -export type TimerKind = "task_wake" | "nudge" | "park" | "ambient_tick" | "distillation" | "recurrence"; - -export interface TimerRow { - id: string; - kind: TimerKind; - identityId: string; - subjectId: string | null; - dueAt: string; - firedAt: string | null; -} +export type { Timer as TimerRow, TimerKind }; export interface ScheduleTimerParams { id: string; @@ -26,39 +19,31 @@ export interface ScheduleTimerParams { // Idempotent: scheduling the same timer id twice (e.g. a redelivered event) is a no-op, matching // SPEC §13's "handlers MUST be idempotent." export function scheduleTimer(db: Database, params: ScheduleTimerParams): void { - db.query( - "INSERT OR IGNORE INTO timers (id, kind, identity_id, subject_id, due_at, fired_at) VALUES (?, ?, ?, ?, ?, NULL)", - ).run(params.id, params.kind, params.identityId, params.subjectId ?? null, params.dueAt); -} - -interface Row { - id: string; - kind: TimerKind; - identity_id: string; - subject_id: string | null; - due_at: string; - fired_at: string | null; -} - -function rowToTimer(row: Row): TimerRow { - return { - id: row.id, - kind: row.kind, - identityId: row.identity_id, - subjectId: row.subject_id, - dueAt: row.due_at, - firedAt: row.fired_at, - }; + orm(db) + .insert(timers) + .values({ + id: params.id, + kind: params.kind, + identityId: params.identityId, + subjectId: params.subjectId ?? null, + dueAt: params.dueAt, + firedAt: null, + }) + .onConflictDoNothing() + .run(); } // Due-time order, overdue-safe: whatever "now" is (including well past due_at after a long // restart), every unfired timer at or before it comes back in due_at order (SPEC §13). -export function listDueTimers(db: Database, clock: Clock): TimerRow[] { - const now = clock(); - const rows = many(db, "SELECT * FROM timers WHERE fired_at IS NULL AND due_at <= ? ORDER BY due_at ASC, id ASC", now); - return rows.map(rowToTimer); +export function listDueTimers(db: Database, clock: Clock): Timer[] { + return orm(db) + .select() + .from(timers) + .where(and(isNull(timers.firedAt), lte(timers.dueAt, clock()))) + .orderBy(asc(timers.dueAt), asc(timers.id)) + .all(); } export function markTimerFired(db: Database, clock: Clock, timerId: string): void { - db.query("UPDATE timers SET fired_at = ? WHERE id = ?").run(clock(), timerId); + orm(db).update(timers).set({ firedAt: clock() }).where(eq(timers.id, timerId)).run(); } diff --git a/src/ledger/turns.ts b/src/ledger/turns.ts index 82ee42a..ce1a204 100644 --- a/src/ledger/turns.ts +++ b/src/ledger/turns.ts @@ -2,16 +2,15 @@ // "running" turn row — a live turn's existence lives in the caller's process, not the ledger); // audit carries both the start and end events regardless. import type { Database } from "bun:sqlite"; -import { asString, isRecord, parseJson } from "../guard"; +import { and, desc, eq, gte, max } from "drizzle-orm"; +import { asString, isRecord } from "../guard"; import type { Clock } from "./clock"; import { writeAudit } from "./audit"; -import { many, one } from "./db"; +import { orm } from "./db"; +import { executions, turns, type TurnKind, type TurnRow, type TurnStatus } from "./schema"; import type { Anchor } from "./tasks"; -// The ledger accepts the live kinds (resident/execution_step/attention) plus the pre-collapse -// kinds, which survive as historical rows (turns.kind CHECK, schema v11). -export type TurnKind = "resident" | "execution_step" | "attention" | "interactive" | "ambient" | "distillation"; -export type TurnStatus = "succeeded" | "failed" | "timed_out" | "budget_denied"; +export type { TurnKind, TurnStatus }; export interface Turn { id: string; @@ -38,65 +37,45 @@ export interface RecordTurnParams { startedAt: string; } -interface Row { - id: string; - identity_id: string; - kind: TurnKind; - execution_id: string | null; - venue_id: string | null; - thread_root_id: string | null; - status: TurnStatus; - effects: string; - spend_amount: number; - started_at: string; - ended_at: string | null; -} - -function asUnknownArray(text: string): unknown[] { - const v = parseJson(text); - return Array.isArray(v) ? v : []; -} - -function rowToTurn(row: Row): Turn { +function rowToTurn(row: TurnRow): Turn { return { id: row.id, - identityId: row.identity_id, + identityId: row.identityId, kind: row.kind, - executionId: row.execution_id, - anchor: row.venue_id ? { venueId: row.venue_id, threadRootId: row.thread_root_id } : null, + executionId: row.executionId, + anchor: row.venueId ? { venueId: row.venueId, threadRootId: row.threadRootId } : null, status: row.status, - effects: asUnknownArray(row.effects), - spendAmount: row.spend_amount, - startedAt: row.started_at, - endedAt: row.ended_at, + effects: Array.isArray(row.effects) ? row.effects : [], + spendAmount: row.spendAmount, + startedAt: row.startedAt, + endedAt: row.endedAt, }; } export function getTurn(db: Database, turnId: string): Turn | null { - const row = one(db, "SELECT * FROM turns WHERE id = ?", turnId); + const row = orm(db).select().from(turns).where(eq(turns.id, turnId)).get(); return row ? rowToTurn(row) : null; } export function recordTurn(db: Database, clock: Clock, params: RecordTurnParams): Turn { const now = clock(); writeAudit(db, params.startedAt, params.identityId, "turn_started", { turnId: params.id, kind: params.kind }); - db.query( - `INSERT INTO turns (id, identity_id, kind, execution_id, venue_id, thread_root_id, status, effects, - spend_amount, started_at, ended_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - params.id, - params.identityId, - params.kind, - params.executionId ?? null, - params.anchor?.venueId ?? null, - params.anchor?.threadRootId ?? null, - params.status, - JSON.stringify(params.effects), - params.spendAmount, - params.startedAt, - now, - ); + orm(db) + .insert(turns) + .values({ + id: params.id, + identityId: params.identityId, + kind: params.kind, + executionId: params.executionId ?? null, + venueId: params.anchor?.venueId ?? null, + threadRootId: params.anchor?.threadRootId ?? null, + status: params.status, + effects: params.effects, + spendAmount: params.spendAmount, + startedAt: params.startedAt, + endedAt: now, + }) + .run(); writeAudit(db, now, params.identityId, "turn_ended", { turnId: params.id, status: params.status, spendAmount: params.spendAmount }); return getTurn(db, params.id)!; } @@ -118,21 +97,24 @@ export interface OutboundEffect { } export function lastTurnStartedAt(db: Database, identityId: string, kind: TurnKind): string | null { - const row = one<{ at: string | null }>(db, "SELECT MAX(started_at) AS at FROM turns WHERE identity_id = ? AND kind = ?", identityId, kind); + const row = orm(db) + .select({ at: max(turns.startedAt) }) + .from(turns) + .where(and(eq(turns.identityId, identityId), eq(turns.kind, kind))) + .get(); return row?.at ?? null; } export function outboundEffectsSince(db: Database, identityId: string, sinceIso: string): OutboundEffect[] { - const rows = many<{ effects: string }>( - db, - "SELECT effects FROM turns WHERE identity_id = ? AND kind = 'resident' AND started_at >= ? ORDER BY started_at", - identityId, - sinceIso, - ); + const rows = orm(db) + .select({ effects: turns.effects }) + .from(turns) + .where(and(eq(turns.identityId, identityId), eq(turns.kind, "resident"), gte(turns.startedAt, sinceIso))) + .orderBy(turns.startedAt) + .all(); const out: OutboundEffect[] = []; for (const row of rows) { - const effects = parseJson(row.effects); - if (!Array.isArray(effects)) continue; + const effects = Array.isArray(row.effects) ? row.effects : []; for (const item of effects) { if (!isRecord(item)) continue; const anchor = isRecord(item.anchor) ? item.anchor : {}; @@ -175,21 +157,18 @@ export function outboundEffectsSince(db: Database, identityId: string, sinceIso: // The worker's task_ask question, recovered from its turn effects so the resident mind // can put the actual question to the room (the ask itself posts nothing). export function lastAskQuestion(db: Database, taskId: string): string | null { - const rows = many<{ effects: string }>( - db, - `SELECT t.effects FROM turns t JOIN executions e ON t.execution_id = e.id - WHERE e.task_id = ? ORDER BY t.started_at DESC LIMIT 10`, - taskId, - ); + const rows = orm(db) + .select({ effects: turns.effects }) + .from(turns) + .innerJoin(executions, eq(turns.executionId, executions.id)) + .where(eq(executions.taskId, taskId)) + .orderBy(desc(turns.startedAt)) + .limit(10) + .all(); for (const row of rows) { - try { - const effects = parseJson(row.effects); - if (!Array.isArray(effects)) continue; - const ask = effects.toReversed().find((e) => isRecord(e) && e.kind === "task_asked" && typeof e.question === "string"); - if (isRecord(ask) && typeof ask.question === "string") return ask.question; - } catch { - // a malformed effects row is a recording bug, not a reason to fail delivery - } + const effects = Array.isArray(row.effects) ? row.effects : []; + const ask = effects.toReversed().find((e) => isRecord(e) && e.kind === "task_asked" && typeof e.question === "string"); + if (isRecord(ask) && typeof ask.question === "string") return ask.question; } return null; } diff --git a/src/policy/budget.ts b/src/policy/budget.ts index 577baab..9c11571 100644 --- a/src/policy/budget.ts +++ b/src/policy/budget.ts @@ -1,8 +1,10 @@ // SPEC §10.3 — spend metering and budget caps. Spend is metered per turn (turns.spend_amount, // already recorded by turns.ts) and aggregated here, calendar-monthly in the configured timezone. import type { Database } from "bun:sqlite"; +import { and, eq, gte, sql } from "drizzle-orm"; import type { Clock } from "../ledger/clock"; -import { many, one } from "../ledger/db"; +import { orm } from "../ledger/db"; +import { executions, turns } from "../ledger/schema"; // A calendar month never exceeds 31 days and timezone skew is at most ~14h, so scanning 35 days // back from "now" always covers the current calendar month in any timezone, without needing @@ -22,14 +24,13 @@ function sumSpendThisMonth(db: Database, now: string, timezone: string, identity const key = monthKey(now, timezone); const since = new Date(new Date(now).getTime() - SCAN_WINDOW_MS).toISOString(); const rows = identityId - ? many<{ spend_amount: number; started_at: string }>( - db, - "SELECT spend_amount, started_at FROM turns WHERE identity_id = ? AND started_at >= ?", - identityId, - since, - ) - : many<{ spend_amount: number; started_at: string }>(db, "SELECT spend_amount, started_at FROM turns WHERE started_at >= ?", since); - return rows.filter((r) => monthKey(r.started_at, timezone) === key).reduce((sum, r) => sum + r.spend_amount, 0); + ? orm(db) + .select({ spendAmount: turns.spendAmount, startedAt: turns.startedAt }) + .from(turns) + .where(and(eq(turns.identityId, identityId), gte(turns.startedAt, since))) + .all() + : orm(db).select({ spendAmount: turns.spendAmount, startedAt: turns.startedAt }).from(turns).where(gte(turns.startedAt, since)).all(); + return rows.filter((r) => monthKey(r.startedAt, timezone) === key).reduce((sum, r) => sum + r.spendAmount, 0); } export function identitySpendThisMonth(db: Database, clock: Clock, identityId: string, timezone: string): number { @@ -44,24 +45,15 @@ export function globalSpendThisMonth(db: Database, clock: Clock, timezone: strin // monthly allowance (SPEC §4.1.11 declares it alongside monthly caps but without the "calendar // month" qualifier those get). export function taskSpend(db: Database, taskId: string): number { - const row = one<{ total: number }>( - db, - `SELECT COALESCE(SUM(t.spend_amount), 0) as total FROM turns t - JOIN executions e ON e.id = t.execution_id WHERE e.task_id = ?`, - taskId, - ); + const row = orm(db) + .select({ total: sql`coalesce(sum(${turns.spendAmount}), 0)` }) + .from(turns) + .innerJoin(executions, eq(executions.id, turns.executionId)) + .where(eq(executions.taskId, taskId)) + .get(); return row?.total ?? 0; } -export interface BudgetStatus { - identitySpend: number; - identityCap: number; - globalSpend: number; - globalCap: number; - hasHeadroom: boolean; - hasReserveHeadroom: boolean; -} - export interface BudgetStatusPolicy { timezone: string; identityMonthlyCap: number; @@ -71,7 +63,7 @@ export interface BudgetStatusPolicy { // SPEC §10.3: reaching the identity OR global cap denies headroom; a small reserve stays usable // (by restricted interactive turns only — steer/cancel/confirm/reply) until it too is exhausted. -export function budgetStatus(db: Database, clock: Clock, policy: BudgetStatusPolicy, identityId: string): BudgetStatus { +export function budgetStatus(db: Database, clock: Clock, policy: BudgetStatusPolicy, identityId: string) { const identitySpend = identitySpendThisMonth(db, clock, identityId, policy.timezone); const globalSpend = globalSpendThisMonth(db, clock, policy.timezone); return { diff --git a/src/replay/incident.ts b/src/replay/incident.ts index dc7b3fb..627f375 100644 --- a/src/replay/incident.ts +++ b/src/replay/incident.ts @@ -4,8 +4,10 @@ // run it on a COPY of the ledger, never the live file (the CLI copies before opening). import type { Database } from "bun:sqlite"; import type { RawMessage, MessageFile } from "@bevyl-ai/agent-tools"; -import { asString, isRecord, parseJson } from "../guard"; -import { many, one } from "../ledger/db"; +import { and, asc, count, eq, gte, inArray, lt, sql } from "drizzle-orm"; +import { asString, isRecord } from "../guard"; +import { orm } from "../ledger/db"; +import { acts, attentionItems, conversations, drafts, events, executions, memoryItems, steering, tasks, timers, turns } from "../ledger/schema"; export function messageFiles(v: unknown): MessageFile[] | undefined { if (!Array.isArray(v)) return undefined; @@ -32,93 +34,70 @@ export function messageFiles(v: unknown): MessageFile[] | undefined { return files.length ? files : undefined; } -export interface IncidentEvent { - rowid: number; - receivedAt: string; - message: RawMessage; -} - export interface IncidentWindow { fromIso: string; toIso: string; venueId?: string; // omit to replay every venue active in the window } -interface EventRow { - rowid: number; - venue_id: string | null; - thread_root_id: string | null; - principal_id: string | null; - payload: string; - received_at: string; -} - // Surface messages in the window, reconstructed into the RawMessage the adapter originally // delivered. addressMode (the router's output, stored in the payload) round-trips to the inbound // flags: a mention is the only source of mentionsBotId, and dm is the only non-channel venueKind // the router ever records. external_signal rows are excluded — those are the system's own // productions (worker outcomes, timers) and the replay's service re-derives them itself. -export function loadIncident(db: Database, w: IncidentWindow): IncidentEvent[] { - const rows = many( - db, - `SELECT rowid, venue_id, thread_root_id, principal_id, payload, received_at FROM events - WHERE kind IN ('addressed_message','observed_message') AND received_at >= ? AND received_at < ? - ${w.venueId ? "AND venue_id = ?" : ""} ORDER BY rowid`, - ...(w.venueId ? [w.fromIso, w.toIso, w.venueId] : [w.fromIso, w.toIso]), - ); +export function loadIncident(db: Database, w: IncidentWindow) { + const rows = orm(db) + .select({ + rowid: sql`${events}.rowid`, + venueId: events.venueId, + threadRootId: events.threadRootId, + principalId: events.principalId, + payload: events.payload, + receivedAt: events.receivedAt, + }) + .from(events) + .where( + and( + inArray(events.kind, ["addressed_message", "observed_message"]), + gte(events.receivedAt, w.fromIso), + lt(events.receivedAt, w.toIso), + w.venueId ? eq(events.venueId, w.venueId) : undefined, + ), + ) + .orderBy(asc(sql`${events}.rowid`)) + .all(); return rows.map((r) => { - const parsed = parseJson(r.payload); - const p = isRecord(parsed) ? parsed : {}; + const p = isRecord(r.payload) ? r.payload : {}; const ts = asString(p.ts); const files = messageFiles(p.files); - return { - rowid: r.rowid, - receivedAt: r.received_at, - message: { - venueId: r.venue_id ?? "", - venueKind: p.addressMode === "dm" ? "dm" : "channel", - principalId: r.principal_id, - isBot: p.isBot === true, - text: asString(p.text), - ts, - // A root the router re-homed into its own thread (thread_root_id = its own ts) was - // delivered top-level — reconstruct it that way so the replay's own router re-homes it. - threadRootTs: r.thread_root_id === ts ? null : r.thread_root_id, - mentionsBotId: p.addressMode === "mention", - ...(files ? { files } : {}), - }, + const message: RawMessage = { + venueId: r.venueId ?? "", + venueKind: p.addressMode === "dm" ? "dm" : "channel", + principalId: r.principalId, + isBot: p.isBot === true, + text: asString(p.text), + ts, + // A root the router re-homed into its own thread (thread_root_id = its own ts) was + // delivered top-level — reconstruct it that way so the replay's own router re-homes it. + threadRootTs: r.threadRootId === ts ? null : r.threadRootId, + mentionsBotId: p.addressMode === "mention", + ...(files ? { files } : {}), }; + return { rowid: r.rowid, receivedAt: r.receivedAt, message }; }); } -export interface OriginalTurn { - startedAt: string; - kind: string; - effects: unknown[]; -} +export type IncidentEvent = ReturnType[number]; // What she actually did in the window — read BEFORE rewindLedger, which deletes these rows. -export function originalActions(db: Database, fromIso: string, toIso: string): OriginalTurn[] { - const rows = many<{ started_at: string; kind: string; effects: string }>( - db, - "SELECT started_at, kind, effects FROM turns WHERE started_at >= ? AND started_at < ? AND kind IN ('resident','attention') ORDER BY started_at", - fromIso, - toIso, - ); - return rows.map((r) => { - const effects = parseJson(r.effects); - return { startedAt: r.started_at, kind: r.kind, effects: Array.isArray(effects) ? effects : [] }; - }); -} - -export interface RewindReport { - events: number; - turns: number; - itemsDeleted: number; - itemsReopened: number; - tasks: number; - timers: number; - memoriesInWindow: number; // NOT rewound (no edit history) — reported so the caveat is visible +export function originalActions(db: Database, fromIso: string, toIso: string) { + const rows = orm(db) + .select({ startedAt: turns.startedAt, kind: turns.kind, effects: turns.effects }) + .from(turns) + .where(and(gte(turns.startedAt, fromIso), lt(turns.startedAt, toIso), inArray(turns.kind, ["resident", "attention"]))) + .orderBy(asc(turns.startedAt)) + .all(); + return rows.map((r) => ({ startedAt: r.startedAt, kind: r.kind, effects: Array.isArray(r.effects) ? r.effects : [] })); } // Point-in-time rewind: everything the service wrote at or after the window start is unwound so @@ -127,35 +106,56 @@ export interface RewindReport { // Tasks, executions, steering, and timers are cleared outright: a replay relives conversations, // and a snapshot's scheduler state firing mid-replay is noise, not fidelity. Memory edits cannot // be rewound (items carry no edit history); the count is reported instead. -export function rewindLedger(db: Database, cutoffRowid: number, fromIso: string): RewindReport { +export function rewindLedger(db: Database, cutoffRowid: number, fromIso: string) { const tx = db.transaction(() => { + const dbx = orm(db); // events_fts is contentless (content='') with an insert-only trigger, so doomed docs must be // removed explicitly — an fts5 'delete' needs the original text back. - const doomed = many<{ rowid: number; text: string }>( - db, - "SELECT rowid, coalesce(json_extract(payload,'$.text'),'') AS text FROM events WHERE rowid >= ?", - cutoffRowid, - ); - for (const d of doomed) db.query("INSERT INTO events_fts (events_fts, rowid, text) VALUES ('delete', ?, ?)").run(d.rowid, d.text); - const events = db.query("DELETE FROM events WHERE rowid >= ?").run(cutoffRowid).changes; - const turns = db.query("DELETE FROM turns WHERE started_at >= ?").run(fromIso).changes; - const itemsDeleted = db.query("DELETE FROM attention_items WHERE opened_at >= ?").run(fromIso).changes; - const itemsReopened = db.query("UPDATE attention_items SET closed_at = NULL, closed_cause = NULL WHERE closed_at >= ?").run(fromIso).changes; + const doomed = dbx + .select({ + rowid: sql`${events}.rowid`, + text: sql`coalesce(json_extract(${events.payload}, '$.text'), '')`, + }) + .from(events) + .where(sql`${events}.rowid >= ${cutoffRowid}`) + .all(); + for (const d of doomed) dbx.run(sql`INSERT INTO events_fts (events_fts, rowid, text) VALUES ('delete', ${d.rowid}, ${d.text})`); + const eventsDeleted = doomed.length; + dbx.delete(events).where(sql`${events}.rowid >= ${cutoffRowid}`).run(); + const turnsDeleted = dbx.delete(turns).where(gte(turns.startedAt, fromIso)).returning({ id: turns.id }).all().length; + const itemsDeleted = dbx.delete(attentionItems).where(gte(attentionItems.openedAt, fromIso)).returning({ id: attentionItems.id }).all().length; + const itemsReopened = dbx + .update(attentionItems) + .set({ closedAt: null, closedCause: null }) + .where(gte(attentionItems.closedAt, fromIso)) + .returning({ id: attentionItems.id }) + .all().length; // One room, one row: rewind each conversation's watermarks and judgment; a step-out taken // during the window had not happened yet. Her acts and withheld drafts in the window are // the service's own productions — the replay re-derives them. - db.query("UPDATE conversations SET stance = 'none', stance_why = NULL WHERE stance = 'out' AND stance_at >= ?").run(fromIso); - db.query( - "UPDATE conversations SET delivered_rowid = min(delivered_rowid, ?), judged_rowid = min(judged_rowid, ?), holds = 0, hold_whys = '[]', wake_why = NULL", - ).run(cutoffRowid - 1, cutoffRowid - 1); - db.query("DELETE FROM acts WHERE at >= ?").run(fromIso); - db.query("DELETE FROM drafts WHERE drafted_at >= ?").run(fromIso); - const timers = db.query("DELETE FROM timers").run().changes; - db.query("DELETE FROM steering").run(); - db.query("DELETE FROM executions").run(); - const tasks = db.query("DELETE FROM tasks").run().changes; - const memoriesInWindow = one<{ n: number }>(db, "SELECT count(*) AS n FROM memory_items WHERE created_at >= ?", fromIso)?.n ?? 0; - return { events, turns, itemsDeleted, itemsReopened, tasks, timers, memoriesInWindow }; + dbx + .update(conversations) + .set({ stance: "none", stanceWhy: null }) + .where(and(eq(conversations.stance, "out"), gte(conversations.stanceAt, fromIso))) + .run(); + dbx + .update(conversations) + .set({ + deliveredRowid: sql`min(${conversations.deliveredRowid}, ${cutoffRowid - 1})`, + judgedRowid: sql`min(${conversations.judgedRowid}, ${cutoffRowid - 1})`, + holds: 0, + holdWhys: [], + wakeWhy: null, + }) + .run(); + dbx.delete(acts).where(gte(acts.at, fromIso)).run(); + dbx.delete(drafts).where(gte(drafts.draftedAt, fromIso)).run(); + const timersDeleted = dbx.delete(timers).returning({ id: timers.id }).all().length; + dbx.delete(steering).run(); + dbx.delete(executions).run(); + const tasksDeleted = dbx.delete(tasks).returning({ id: tasks.id }).all().length; + const memoriesInWindow = dbx.select({ n: count() }).from(memoryItems).where(gte(memoryItems.createdAt, fromIso)).get()?.n ?? 0; + return { events: eventsDeleted, turns: turnsDeleted, itemsDeleted, itemsReopened, tasks: tasksDeleted, timers: timersDeleted, memoriesInWindow }; }); return tx(); } diff --git a/src/replay/run.ts b/src/replay/run.ts index f0f32ff..68d6728 100644 --- a/src/replay/run.ts +++ b/src/replay/run.ts @@ -9,8 +9,10 @@ import { systemClock, type Clock } from "../ledger/clock"; import type { PolicyStore } from "../policy/load"; import type { Logger } from "../log"; import { messageFiles, type IncidentEvent } from "./incident"; -import { many } from "../ledger/db"; -import { isRecord, parseJson } from "../guard"; +import { and, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm"; +import { orm } from "../ledger/db"; +import { events } from "../ledger/schema"; +import { isRecord } from "../guard"; export interface CapturedAction { at: string; @@ -34,18 +36,24 @@ class CaptureAdapter implements SurfaceAdapter { private clock: Clock, db: Database, ) { - const rows = many<{ venue_id: string | null; thread_root_id: string | null; principal_id: string | null; payload: string }>( - db, - "SELECT venue_id, thread_root_id, principal_id, payload FROM events WHERE kind IN ('addressed_message','observed_message') ORDER BY rowid", - ); + const rows = orm(db) + .select({ + venueId: events.venueId, + threadRootId: events.threadRootId, + principalId: events.principalId, + payload: events.payload, + }) + .from(events) + .where(inArray(events.kind, ["addressed_message", "observed_message"])) + .orderBy(sql`${events}.rowid`) + .all(); for (const r of rows) { - const parsed = parseJson(r.payload); - const p = isRecord(parsed) ? parsed : {}; + const p = isRecord(r.payload) ? r.payload : {}; const ts = typeof p.ts === "string" ? p.ts : ""; if (!ts) continue; const files = messageFiles(p.files); - this.append(r.thread_root_id ?? ts, { - user: r.principal_id, + this.append(r.threadRootId ?? ts, { + user: r.principalId, text: typeof p.text === "string" ? p.text : "", ts, ...(files ? { files } : {}), @@ -116,19 +124,18 @@ export function recordingRegistries(captured: CapturedAction[], clock: Clock): T // read_channel / read_thread served from the snapshot's own events, mirroring main.ts's live // slack registry (same names, so existing grants validate and expose them identically). export function snapshotSlackRegistry(db: Database): ToolRegistry { - const messages = (where: string, params: string[], limit: number) => - many<{ principal_id: string | null; payload: string }>( - db, - `SELECT venue_id, thread_root_id, principal_id, payload FROM events - WHERE kind IN ('addressed_message','observed_message') AND ${where} ORDER BY rowid DESC LIMIT ?`, - ...params, - limit, - ) + const messages = (conds: SQL[], limit: number) => + orm(db) + .select({ principalId: events.principalId, payload: events.payload }) + .from(events) + .where(and(inArray(events.kind, ["addressed_message", "observed_message"]), ...conds)) + .orderBy(desc(sql`${events}.rowid`)) + .limit(limit) + .all() .toReversed() .map((r) => { - const parsed = parseJson(r.payload); - const p = isRecord(parsed) ? parsed : {}; - return { user: r.principal_id, text: typeof p.text === "string" ? p.text : "", ts: typeof p.ts === "string" ? p.ts : "" }; + const p = isRecord(r.payload) ? r.payload : {}; + return { user: r.principalId, text: typeof p.text === "string" ? p.text : "", ts: typeof p.ts === "string" ? p.ts : "" }; }); return { name: "slack", @@ -142,7 +149,7 @@ export function snapshotSlackRegistry(db: Database): ToolRegistry { const channel = typeof a.channel === "string" ? a.channel : ""; const venueId = channel.replace(/^<#|[|>].*$/g, ""); if (!venueId) return { success: false, output: "read_channel needs a { channel }" }; - return { success: true, output: JSON.stringify(messages("venue_id = ? AND thread_root_id IS NULL", [venueId], Math.min(typeof a.limit === "number" ? a.limit : 20, 100))) }; + return { success: true, output: JSON.stringify(messages([eq(events.venueId, venueId), isNull(events.threadRootId)], Math.min(typeof a.limit === "number" ? a.limit : 20, 100))) }; }, }, read_thread: { @@ -153,7 +160,7 @@ export function snapshotSlackRegistry(db: Database): ToolRegistry { const channel = typeof a.channel === "string" ? a.channel : ""; const threadTs = typeof a.thread_ts === "string" ? a.thread_ts : ""; if (!channel || !threadTs) return { success: false, output: "read_thread needs { channel, thread_ts }" }; - return { success: true, output: JSON.stringify(messages("thread_root_id = ?", [threadTs], Math.min(typeof a.limit === "number" ? a.limit : 50, 200))) }; + return { success: true, output: JSON.stringify(messages([eq(events.threadRootId, threadTs)], Math.min(typeof a.limit === "number" ? a.limit : 50, 200))) }; }, }, }, diff --git a/src/service.ts b/src/service.ts index c394d7e..aa33ebc 100644 --- a/src/service.ts +++ b/src/service.ts @@ -45,7 +45,9 @@ import { } from "./ledger/conversations"; import { composeEarInstructions } from "./turn-runner/ear-soul"; import { asString, isRecord } from "./guard"; -import { checkpointWal, one } from "./ledger/db"; +import { desc, sql } from "drizzle-orm"; +import { checkpointWal, orm } from "./ledger/db"; +import { events } from "./ledger/schema"; import { runExecution, type ExecutionOutcome } from "./turn-runner/execution-loop"; import { lastAskQuestion, type TurnStatus } from "./ledger/turns"; import { runTurn } from "./turn-runner/turn"; @@ -1011,25 +1013,27 @@ export class Service { // the same state cannot drag the mind out of bed for it. 2026-08-10 live: a task's // repeated identical "waiting on a human" wake was the one that posted stale into a // settled thread; the workflow measurement put this class as the largest wake driver. - const prev = one<{ text: string | null }>( - this.d.db, - "SELECT json_extract(payload, '$.text') AS text FROM events WHERE dedup_key LIKE ? ORDER BY rowid DESC LIMIT 1", - `worker:${taskId}:%`, - ); - this.d.db - .query( - `INSERT INTO events (id, dedup_key, kind, identity_id, venue_id, thread_root_id, principal_id, payload, received_at) - VALUES (?, ?, 'external_signal', ?, ?, ?, NULL, ?, ?)`, - ) - .run( - this.d.newId(), - `worker:${taskId}:${this.d.newId()}`, - task.identityId, - task.homeAnchor.venueId, - task.homeAnchor.threadRootId, - JSON.stringify({ text }), - this.d.clock(), - ); + const prev = orm(this.d.db) + .select({ text: sql`json_extract(${events.payload}, '$.text')` }) + .from(events) + .where(sql`${events.dedupKey} LIKE ${`worker:${taskId}:%`}`) + .orderBy(desc(sql`${events}.rowid`)) + .limit(1) + .get(); + orm(this.d.db) + .insert(events) + .values({ + id: this.d.newId(), + dedupKey: `worker:${taskId}:${this.d.newId()}`, + kind: "external_signal", + identityId: task.identityId, + venueId: task.homeAnchor.venueId, + threadRootId: task.homeAnchor.threadRootId, + principalId: null, + payload: { text }, + receivedAt: this.d.clock(), + }) + .run(); if (prev?.text !== text) this.scheduleWake(task.identityId, 0); } catch (e) { this.log.error("worker report delivery failed", { taskId, error: String(e) }); diff --git a/src/status.ts b/src/status.ts index 8d9ea18..c2be758 100644 --- a/src/status.ts +++ b/src/status.ts @@ -2,50 +2,55 @@ // derived entirely from the ledger — nothing new is persisted. Answers "what's running / waiting / // what did we spend" per identity, kept minimal per §2.2 (no rich web UI). import type { Database } from "bun:sqlite"; +import { and, count, eq, gt, isNull, lte, type SQL } from "drizzle-orm"; import type { Clock } from "./ledger/clock"; import { identitySpendThisMonth, globalSpendThisMonth } from "./policy/budget"; -import { many, one } from "./ledger/db"; +import { orm } from "./ledger/db"; +import { executions, tasks, timers } from "./ledger/schema"; -export interface IdentityStatus { - identityId: string; - open: number; - active: number; - running: number; // live executions (should equal `active`, but counted independently as a cross-check) - waitingHuman: number; - waitingTimer: number; - parked: number; - spendThisMonth: number; +function taskCount(db: Database, identityId: string, ...conds: SQL[]): number { + return ( + orm(db) + .select({ c: count() }) + .from(tasks) + .where(and(eq(tasks.identityId, identityId), ...conds)) + .get()?.c ?? 0 + ); } -export interface RuntimeSnapshot { - at: string; - identities: IdentityStatus[]; - globalSpendThisMonth: number; - timersDue: number; - timersPending: number; -} - -export function runtimeSnapshot(db: Database, clock: Clock, timezone: string): RuntimeSnapshot { +export function runtimeSnapshot(db: Database, clock: Clock, timezone: string) { const now = clock(); - const idRows = many<{ identity_id: string }>(db, "SELECT DISTINCT identity_id FROM tasks ORDER BY identity_id"); + const idRows = orm(db).selectDistinct({ identityId: tasks.identityId }).from(tasks).orderBy(tasks.identityId).all(); - const identities: IdentityStatus[] = idRows.map(({ identity_id }) => { - const count = (sql: string, ...params: import("bun:sqlite").SQLQueryBindings[]) => - one<{ c: number }>(db, sql, identity_id, ...params)?.c ?? 0; - return { - identityId: identity_id, - open: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'open'"), - active: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'active'"), - running: one<{ c: number }>(db, "SELECT COUNT(*) as c FROM executions e JOIN tasks t ON t.id = e.task_id WHERE e.status = 'running' AND t.identity_id = ?", identity_id)?.c ?? 0, - waitingHuman: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'waiting' AND waiting_on = 'human'"), - waitingTimer: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'waiting' AND waiting_on = 'timer'"), - parked: count("SELECT COUNT(*) as c FROM tasks WHERE identity_id = ? AND status = 'parked'"), - spendThisMonth: identitySpendThisMonth(db, clock, identity_id, timezone), - }; - }); + const identities = idRows.map(({ identityId }) => ({ + identityId, + open: taskCount(db, identityId, eq(tasks.status, "open")), + active: taskCount(db, identityId, eq(tasks.status, "active")), + running: + orm(db) + .select({ c: count() }) + .from(executions) + .innerJoin(tasks, eq(tasks.id, executions.taskId)) + .where(and(eq(executions.status, "running"), eq(tasks.identityId, identityId))) + .get()?.c ?? 0, + waitingHuman: taskCount(db, identityId, eq(tasks.status, "waiting"), eq(tasks.waitingOn, "human")), + waitingTimer: taskCount(db, identityId, eq(tasks.status, "waiting"), eq(tasks.waitingOn, "timer")), + parked: taskCount(db, identityId, eq(tasks.status, "parked")), + spendThisMonth: identitySpendThisMonth(db, clock, identityId, timezone), + })); - const timersDue = one<{ c: number }>(db, "SELECT COUNT(*) as c FROM timers WHERE fired_at IS NULL AND due_at <= ?", now)?.c ?? 0; - const timersPending = one<{ c: number }>(db, "SELECT COUNT(*) as c FROM timers WHERE fired_at IS NULL AND due_at > ?", now)?.c ?? 0; + const timersDue = + orm(db) + .select({ c: count() }) + .from(timers) + .where(and(isNull(timers.firedAt), lte(timers.dueAt, now))) + .get()?.c ?? 0; + const timersPending = + orm(db) + .select({ c: count() }) + .from(timers) + .where(and(isNull(timers.firedAt), gt(timers.dueAt, now))) + .get()?.c ?? 0; return { at: now, diff --git a/src/turn-runner/toolset.ts b/src/turn-runner/toolset.ts index 0e4c25f..d9e21fc 100644 --- a/src/turn-runner/toolset.ts +++ b/src/turn-runner/toolset.ts @@ -25,8 +25,10 @@ import { decide, exposableForKind, actionRefFor, canonicalJson, type ToolCatalog import type { ToolRegistry } from "../tools/catalog"; import type { IdentityConfig } from "../policy/schema"; import type { DynamicTool } from "./types"; +import { and, eq, gt } from "drizzle-orm"; import { asString, isRecord } from "../guard"; -import { one } from "../ledger/db"; +import { orm } from "../ledger/db"; +import { outwardCalls } from "../ledger/schema"; function fields(args: unknown): Record { return isRecord(args) ? args : {}; @@ -927,14 +929,18 @@ function externalTools(ctx: ToolsetContext): ToolFactory[] { // refused; a standing task legitimately repeating tomorrow's identical write passes // (review finding: task-lifetime scope permanently refused legitimate repeats). const cutoff = new Date(Date.parse(ctx.clock()) - 24 * 60 * 60 * 1000).toISOString(); - const prior = one<{ confirmed: number }>( - ctx.db, - "SELECT confirmed FROM outward_calls WHERE scope_id = ? AND tool = ? AND args_hash = ? AND at > ?", - outwardScope, - grant.tool, - argsHash, - cutoff, - ); + const prior = orm(ctx.db) + .select({ confirmed: outwardCalls.confirmed }) + .from(outwardCalls) + .where( + and( + eq(outwardCalls.scopeId, outwardScope), + eq(outwardCalls.tool, grant.tool), + eq(outwardCalls.argsHash, argsHash), + gt(outwardCalls.at, cutoff), + ), + ) + .get(); if (prior?.confirmed) { return { success: false, output: "already done: this exact call already ran for this piece of work and completed. If you meant a different change, change the arguments." }; } @@ -943,20 +949,29 @@ function externalTools(ctx: ToolsetContext): ToolFactory[] { // landed. Never silently redo an ambiguous outward write; verify first. return { success: false, output: "this exact call was attempted earlier and its outcome is unknown — check the target system first (search/read it); if it truly didn't land, make the call distinguishable (e.g. note the retry in its text)." }; } - ctx.db - .query( - `INSERT INTO outward_calls (identity_id, scope_id, tool, args_hash, at) VALUES (?, ?, ?, ?, ?) - ON CONFLICT (scope_id, tool, args_hash) DO UPDATE SET at = excluded.at, confirmed = 0`, - ) - .run(ctx.identity.id, outwardScope, grant.tool, argsHash, ctx.clock()); + orm(ctx.db) + .insert(outwardCalls) + .values({ identityId: ctx.identity.id, scopeId: outwardScope, tool: grant.tool, argsHash, at: ctx.clock(), confirmed: 0 }) + .onConflictDoUpdate({ + target: [outwardCalls.scopeId, outwardCalls.tool, outwardCalls.argsHash], + set: { at: ctx.clock(), confirmed: 0 }, + }) + .run(); // NOTE a thrown impl leaves the row UNCONFIRMED on purpose — thrown ≠ "did not // happen"; the row is the ambiguity record the next identical call trips on. const result = await impl(args); if (result.success) { - ctx.db.query("UPDATE outward_calls SET confirmed = 1 WHERE scope_id = ? AND tool = ? AND args_hash = ?").run(outwardScope, grant.tool, argsHash); + orm(ctx.db) + .update(outwardCalls) + .set({ confirmed: 1 }) + .where(and(eq(outwardCalls.scopeId, outwardScope), eq(outwardCalls.tool, grant.tool), eq(outwardCalls.argsHash, argsHash))) + .run(); } else { // The impl REPORTED failure — nothing landed; a clean retry is safe. - ctx.db.query("DELETE FROM outward_calls WHERE scope_id = ? AND tool = ? AND args_hash = ?").run(outwardScope, grant.tool, argsHash); + orm(ctx.db) + .delete(outwardCalls) + .where(and(eq(outwardCalls.scopeId, outwardScope), eq(outwardCalls.tool, grant.tool), eq(outwardCalls.argsHash, argsHash))) + .run(); } return result; }