diff --git a/.changeset/deepseek-session-persistence.md b/.changeset/deepseek-session-persistence.md new file mode 100644 index 0000000..af6e5f2 --- /dev/null +++ b/.changeset/deepseek-session-persistence.md @@ -0,0 +1,29 @@ +--- +"@upstash/agentkit-deepseek": minor +--- + +Add `@upstash/agentkit-deepseek`: a durable DeepSeek Harness session-persistence backend on Upstash +Redis. + +It registers as `ctx.sessionPersistence` and is a drop-in replacement for the harness's shipped JSONL +and SQLite backends, so a serverless or multi-replica deployment — which has no durable local disk to +write sessions to and no shared one to read them back — can still persist and resume them. + +Like the first-party backends it composes the shared `PersistenceCoordinator` and implements only the +`PersistenceBackend` storage hooks, and it passes the harness's own backend-agnostic conformance +suite against a real Upstash Redis. Sessions are stored as a Redis list indexed by event seq, which +makes it seek-capable (`readFrom` reads only the requested suffix); appends and repairs each run as a +single Lua script, giving the atomic materialize-plus-first-batch the seam requires. + +The package ships a `dsh.bundle` layer, so `dsh plugin add @upstash/agentkit-deepseek` both installs +and activates it. + +Credentials resolve through the harness's `ctx.credentials` seam before falling back to +`Redis.fromEnv()`, so the managed `~/.dsh/.credentials.yaml` store — which is deliberately never +materialized into `process.env` — works. Config names the reference (`urlRef`/`tokenRef`), never the +value. The package also ships an `agentkit-deepseek` command (`credentials set` / `credentials +status`) that writes that store through the harness's own provider, so no file has to be hand-edited: + +```bash +dsh plugin --profile web exec agentkit-deepseek credentials set +``` diff --git a/.gitignore b/.gitignore index e5da4da..2cc6cab 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,8 @@ coverage .env.* !.env.example *.tsbuildinfo +# `pnpm pack` output — packing is how a plugin is installed into a dsh profile +# without publishing, so these land in the tree routinely. +*.tgz .vscode .idea diff --git a/.prettierignore b/.prettierignore index ea1f980..607a3d2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,7 @@ coverage pnpm-lock.yaml *.md examples + +# Vendored verbatim from the DeepSeek Harness so it can be re-copied on upgrade. +# Reformatting it would make that diff unreadable. +packages/deepseek/test/contract.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4e0e24c..4af1a6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ embeddings — keep that in mind when naming/among scoring. | `@upstash/agentkit-ai-sdk` (`packages/ai-sdk`) | Vercel AI SDK adapter. | | `@upstash/agentkit-eve` (`packages/eve`) | Eve framework adapter. Depends on the ai-sdk package. | | `@upstash/agentkit-eve-extension` (`packages/eve-extension`) | AgentKit as a mountable **eve extension** (eve ≥0.24): one `agent/extensions/.ts` file composes memory tools, search tools, a chat-history hook, and an instructions fragment under `__*`. | +| `@upstash/agentkit-deepseek` (`packages/deepseek`) | **DeepSeek Harness** (`dsh`) plugins. A cordis plugin package — nothing to do with the AgentKit primitives above; it shares only Redis. Currently one plugin: a session-persistence backend. | Examples (`examples/`): `ai-sdk-demo` (hand-written Next.js), `eve-demo` (a real `eve` CLI scaffold), and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). @@ -151,6 +152,96 @@ and `eve-extension-demo` (a minimal eve scaffold that mounts the extension). - What an extension **cannot** contribute (stays in `@upstash/agentkit-eve`): sandbox, channels/auth (rate limiting), schedules, agent config. `defineCachedTool` also stays there (wraps user tools). +### deepseek (`packages/deepseek`) — DeepSeek Harness plugins +- **Different world from the rest of the repo.** Not AI SDK, not eve, not the AgentKit primitives: + it's a [**cordis**](https://github.com/deepseek-ai/deepseek-harness) plugin package for the DeepSeek + Harness (`dsh`). Peers `@deepseek-ai/cordis` + `@deepseek-ai/dsh-session` + `@deepseek-ai/dsh-session-persistence` + (pinned to the `next` npm tag, `0.1.0-rc.6` — `latest` is a much older `0.0.1-rc.1`), dep + `@deepseek-ai/schemastery` for the config schema. +- **`@upstash/redis` is a `dependency` here, NOT a peer** — the one place in this repo that diverges + from the peer-everywhere convention, and it's load-bearing. A peer means "the host provides it"; + the *host* is `dsh`, which has never heard of Redis, so nothing in a profile's `node_modules` ever + satisfies it → `ERR_MODULE_NOT_FOUND: Cannot find package '@upstash/redis'` at plugin load. There's + also no single-copy requirement (unlike `ai`/`eve`): the plugin builds its own client via + `Redis.fromEnv()`. The `@deepseek-ai/*` packages stay **peers** — dsh really does provide them, and + a second copy of `dsh-session-persistence` would break service identity (cordis registration, the + `Context` module augmentation, `instanceof`). Runtime imports in the built dist are exactly three: + `@deepseek-ai/schemastery` (dep, matching the first-party backends), `@upstash/redis` (dep), + `@deepseek-ai/dsh-session-persistence` (peer). `cordis`/`dsh-session` are type-only. +- `RedisSessionPersistence` (default export) provides `ctx.sessionPersistence`. It **extends the + abstract `SessionPersistence` service and composes the harness's `PersistenceCoordinator`**, + implementing only the small `PersistenceBackend` hook interface — exactly how the + first-party JSONL/SQLite backends are built. Never reimplement the service methods; delegate them + to the coordinator (the sole exception is `list`, which IS a backend hook — delegating it would + recurse). +- **Key layout:** `:store` (SET-NX store identity), `:ids` (set), `:meta:` + (hash: `meta`/`incarnation`/`revision`), `:events:` (**list**). The list is load-bearing: + a session log is contiguous from seq 0, so **list index === event seq** → `LLEN` is the stored + next-seq, `LRANGE key fromSeq -1` is a real seek (so we implement the optional `loadStoredFrom` + hook), `LTRIM` truncates a tail. +- **All four Redis ops are Lua scripts** (`src/scripts.ts`, run via `createScript` so it's EVALSHA + with an EVAL fallback). Append must be atomic per the seam (materialize + first batch cannot tear); + a script is the equivalent of SQLite's transaction. Consequence: this backend **never produces a + torn tail**, so `tornMarker` is only reachable by damaging a key out-of-band — truncation stays + implemented anyway. The append script also guards contiguous seq via `LLEN`, turning a second + writer into a loud `AGENTKIT_SEQ_MISMATCH` instead of an interleaved log. +- **Reads of one session go through ONE script** (header + events + revision together). Two round + trips could straddle a concurrent append and return a revision describing a different prefix, which + the seam forbids. Listing is a pipeline instead — cross-session atomicity buys nothing there, and a + script looping over every session would block the server. +- **`@upstash/redis` auto-deserializes responses**, so a stored JSON string comes back already parsed. + Every decoder in `src/records.ts` accepts **both** shapes; never assume which. Same reason `smembers` + results are `String()`-ed (an all-digits id would come back as a number). +- **Testing: the conformance suite is vendored.** `test/contract.ts` is a **verbatim copy** of the + harness's `runPersistenceContract` — the same suite JSONL and SQLite pass. It's copied because the + harness ships only `lib/` to npm. Only the `SessionPersistence` type import was repointed. **Don't + reformat or "fix" it** (it's in `.prettierignore` + eslint ignores); re-copy it when bumping the + `@deepseek-ai/dsh-session-persistence` peer. Each `make()` gets a `uniquePrefix()` keyspace. +- **`cordis.patch.yml` + `dsh.bundle`:** the package is a *bundle*, so `dsh plugin add` installs AND + activates it. The layer **disables** `session-persistence-jsonl` and **inserts** a new row. + ⚠️ A patch's `name` is an **assertion about the target row**, not an override — patching + `{id: session-persistence-jsonl, name: '@upstash/agentkit-deepseek'}` warns "name mismatch … + skipping" and silently changes nothing. Swap a provider by disable + insert, always. +- `redis` stays a **runtime-only config seam** (not in the schemastery schema) per repo convention — + a client instance isn't a YAML value, and `Redis.fromEnv()` works because `dsh` loads `.env`. +- **Credentials resolve through `ctx.credentials`, then `Redis.fromEnv()`.** `~/.dsh/.credentials.yaml` + (the managed store the Web Models page writes) is **never materialized into `process.env`** — it's + resolved by name — so an env-only backend cannot see it. `src/credentials.ts` binds the service + **structurally** (`ctx.get('credentials')` returns `any`), so there is no dependency on + `@deepseek-ai/dsh-credentials` and the service stays optional. Config names the *reference* + (`urlRef`/`tokenRef`, the harness's `apiKeyEnv` pattern), never the value. `.env` layers still work + because the launcher materializes those into `process.env`. +- **Why the client lives behind `ready`:** credential resolution is async, so `connect()` resolves the + client, prepares the four scripts, and claims store identity in one promise every hook already + awaited. `ConnectedStore` is **inferred** from the module-level `connectStore()` factory because + `@upstash/redis` does not export its `Script`/`ScriptRO` types. `.redis` is now a *getter returning + a promise*, not a field. +- **`agentkit-deepseek` CLI** (`src/cli.ts`, the package `bin`): `credentials set|status`. Mounts the + harness's own `LocalCredentialProvider` and calls `set()`, so the atomic write / lock / `0600` / + comment-preserving patch are their code. cordis + credentials-local are **dynamically imported and + never runtime deps** — cordis must stay one copy, and both resolve from a profile via the parent + walk to `$DSH_HOME/profiles/node_modules`. Run it as + `dsh plugin --profile web exec agentkit-deepseek credentials set`. Must `fiber.dispose()` or the + provider's watcher keeps the process alive. **`set` rejects a ref the launching shell exports** + (a higher layer would shadow it) — surface that message, don't swallow it. +- **No Web UI is possible for us.** The Models page is LLM-provider-specific, and the generic plugin + settings section gates cards behind a **Host allowlist in `packages/host/apiproxy`** — its own docs + say a plugin distributed outside the harness repo cannot surface config there. Don't plan around it. + (`.env` bootstrap-var rejection covers `DSH_`/`XDG_`/`DYLD_`/`BASH_FUNC_` — `UPSTASH_*` is fine.) +- **`dsh` is the whole CLI**, not a web-only tool: `dsh web` is a *hardcoded alias* for + `--profile web`. A **profile** is one runnable composition at `~/.dsh/profiles//` + (`package.json` with the ordered `bundles` list + a `cordis.patch.yml`); `web`/`headless` + auto-initialize on first use. A plugin installs into **one** profile — installing into `demo` and + then running `dsh web` silently does nothing. `npx @deepseek-ai/dsh …` is the same binary; the + profile dir is durable state, so npx is fine. +- **Installing needs no publishing.** `dsh plugin ` forwards to pnpm in the profile dir, so any + pnpm specifier works: local path (links it — best for dev), `pnpm pack` tarball (ships built `dist/` + — best for handing to someone), git URL, or registry. Only the last is "publishing". A `--patch` + overlay naming `dist/index.js` by absolute path skips installation entirely. `--dump-config` + verifies the swap. **Git installs are the one trap**: pnpm fetches *sources*, so `dist/` never gets + built — that route alone needs a `prepare` script plus the user's `allowBuilds` opt-in. We ship no + `prepare` (install-time builds break in a monorepo: sdk isn't built yet at install). + ## Naming history (so you don't resurrect old names) - ai-sdk caching: `cacheTools` → `cachedTool`+`cachedTools` → now **`cachedTools` only** (singular `cachedTool` removed; toolName = map key, `userId` scopes). - eve `cachedExecute` → **`defineCachedTool`** (cache key field: `cachePrefix` → `namespace` → **`toolName`**); `recall/saveMemoryTool` → **`defineMemoryRecallTool`/`defineMemorySaveTool`**. diff --git a/README.md b/README.md index abf0961..732a33f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr | [`@upstash/agentkit-ai-sdk`](./packages/ai-sdk) | Adapter for the [Vercel AI SDK](https://ai-sdk.dev). | | [`@upstash/agentkit-eve`](./packages/eve) | Adapter for the Vercel Eve framework. | | [`@upstash/agentkit-eve-extension`](./packages/eve-extension) | The same capabilities as a mountable [Eve extension](https://eve.dev/docs/extensions) — one file in `agent/extensions/` adds memory tools, search tools, and durable chat history the agent can search. | +| [`@upstash/agentkit-deepseek`](./packages/deepseek) | Plugins for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). Currently a durable **session-persistence backend** — a drop-in replacement for the shipped JSONL/SQLite backends that keeps session transcripts in Redis, so they survive a restart and can be resumed by a different instance. | ## Core features diff --git a/eslint.config.js b/eslint.config.js index c4697fd..4aefa87 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,7 +6,16 @@ import prettier from "eslint-config-prettier"; export default [ { // `examples/**` has its own (Next.js) eslint toolchain; lint it there, not here. - ignores: ["**/dist/**", "**/node_modules/**", "**/coverage/**", "**/*.d.ts", "examples/**"], + // `packages/deepseek/test/contract.ts` is vendored verbatim from the DeepSeek Harness and is + // re-copied on upgrade, so it is not held to this repo's style. + ignores: [ + "**/dist/**", + "**/node_modules/**", + "**/coverage/**", + "**/*.d.ts", + "examples/**", + "packages/deepseek/test/contract.ts", + ], }, js.configs.recommended, { diff --git a/packages/deepseek/README.md b/packages/deepseek/README.md new file mode 100644 index 0000000..230b55d --- /dev/null +++ b/packages/deepseek/README.md @@ -0,0 +1,306 @@ +# @upstash/agentkit-deepseek + +[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugins backed by +[Upstash Redis](https://upstash.com/). + +Today that means one thing: a **durable session-persistence backend** — a drop-in replacement for the +harness's shipped JSONL and SQLite backends that keeps session transcripts in Redis instead of on the +machine that happened to run the turn. + +| Harness backend | Where sessions live | +| --- | --- | +| `dsh-session-persistence-jsonl` | one `.jsonl.zstd` file per session, on local disk | +| `dsh-session-persistence-sqlite` | rows in a local SQLite database | +| **this package** | **keys in Upstash Redis, reachable over HTTP from anywhere** | + +That difference is the point. A serverless, containerized, or multi-replica deployment has no durable +local disk to write to and no shared one to read back from, so sessions cannot survive a restart or +be resumed by a different instance. Redis is reachable over HTTP from all of them. + +## Install + +```bash +npm install @upstash/agentkit-deepseek +``` + +Peers you already have in a harness deployment: `@deepseek-ai/cordis`, `@deepseek-ai/dsh-session`, +and `@deepseek-ai/dsh-session-persistence`. They are peers because the harness genuinely provides +them, and a second copy of `dsh-session-persistence` would break service identity. + +`@upstash/redis` is a plain **dependency**, not a peer: the host here is `dsh`, which knows nothing +about Redis, so nothing in a profile would ever satisfy that peer — and this plugin builds its own +client via `Redis.fromEnv()` rather than sharing yours. + +## Wire it up + +The package is a **bundle**: it ships a `cordis.patch.yml` layer, so installing it into a profile is +the whole setup. + +```bash +dsh plugin --profile web add @upstash/agentkit-deepseek +dsh web +``` + +> **Pick the profile you actually boot.** A profile is one runnable composition at +> `~/.dsh/profiles//`, and a plugin installs into exactly one of them. `dsh web` is a hardcoded +> alias for `--profile web`, and `dsh --profile headless` boots `headless` — so installing into +> `demo` and then running `dsh web` silently changes nothing. Examples below use `web`; substitute +> your own. Running the CLI through `npx @deepseek-ai/dsh …` works the same way: the CLI is cached, +> but the profile directory is durable on-disk state. + +## Credentials + +The backend needs `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. Nothing goes in a config +file — a config row names the *reference*, never the value. + +The recommended way is this package's own command, which needs no hand-edited file: + +```bash +dsh plugin --profile web exec agentkit-deepseek credentials set +# Upstash REST URL: https://xxx.upstash.io +# Upstash REST token: (not echoed) + +dsh plugin --profile web exec agentkit-deepseek credentials status +# UPSTASH_REDIS_REST_URL: configured (source: file) +# UPSTASH_REDIS_REST_TOKEN: configured (source: file) +``` + +Running it through `dsh plugin … exec` puts it in the profile directory, where both this package and +the harness's credentials provider resolve. It writes `~/.dsh/.credentials.yaml` through the +harness's own provider, so the atomic write, cross-process lock, and `0600` permissions are the +harness's code rather than ours. Restart `dsh` afterwards. + +Prefer the prompt over `--token`: a flag lands in your shell history and process list. + +### Where a credential can come from + +The backend resolves through `ctx.credentials` when a provider is mounted (the shipped bundles mount +one), which searches every layer, then falls back to `Redis.fromEnv()`: + +| Layer | Set it with | Notes | +| --- | --- | --- | +| launching shell / CI / container | `VAR=… dsh web` | Wins over everything; per-run operator intent | +| `~/.dsh/.credentials.yaml` | `agentkit-deepseek credentials set` | Managed store, hot-reloaded, `0600` | +| `/.env` | edit the file | The project you launch from | +| `~/.dsh/.env` | edit the file | Machine-level default | + +`.env` files are also materialized into `process.env`, so they work with or without a credentials +provider — which is why `Redis.fromEnv()` remains the fallback and this package still runs unchanged +outside the harness. + +One consequence of that precedence worth knowing: **`credentials set` refuses to write a reference +your shell already exports**, because a higher layer would shadow it. The error names the fix +(`unset` it in the launching shell). `credentials status` shows which layer currently wins. + +To point at differently named references — a second Upstash database, or a house naming convention — +set `urlRef` / `tokenRef` on the row, and pass the matching `--url-ref` / `--token-ref` to the +command. This is the harness's own `apiKeyEnv` pattern: config names the credential, never holds it. + +⚠️ **Do not inline credentials in `cordis.yml` / `cordis.patch.yml`.** Config files are the layer +people commit, copy between machines, and paste into bug reports. `--dump-config` prints composed +`config` values verbatim while leaving `!!js` expressions unevaluated, so a literal secret leaks +there and a reference does not. + +The layer disables the base bundle's local-disk row and inserts this one: + +```yaml +- id: session-persistence-jsonl + disabled: true + +- insert: + - id: session-persistence-redis + name: '@upstash/agentkit-deepseek' +``` + +⚠️ **Replace a row by disabling it and inserting another — never by patching its `name`.** The +patcher treats a patch's `name` as an *assertion about the target* and skips the entire patch when it +does not match the row's current name. So this looks right and does nothing at all, leaving the JSONL +backend running: + +```yaml +# WRONG — warns "name mismatch ... skipping" and changes nothing. +- id: session-persistence-jsonl + name: '@upstash/agentkit-deepseek' +``` + +To tune the backend, override the inserted row from your profile's `cordis.patch.yml`, which applies +after every bundle layer. A patch replaces a row's whole `config` rather than merging into it, so +restate every key that row needs: + +```yaml +- id: session-persistence-redis + config: + prefix: 'myapp:sessions' + writeBatchMaxDelayMs: 500 +``` + +`dsh web --dump-config` prints the composed tree without booting — the quickest way to +confirm the swap landed. + +Programmatically, it is an ordinary cordis plugin: + +```ts +import { Context } from "@deepseek-ai/cordis"; +import SessionStore from "@deepseek-ai/dsh-session"; +import RedisSessionPersistence from "@upstash/agentkit-deepseek"; + +const ctx = new Context(); +await ctx.plugin(SessionStore); +await ctx.plugin(RedisSessionPersistence, { prefix: "dsh:session" }); + +// ctx.sessionPersistence is now the Redis backend. +``` + +## Config + +| Key | Type | Notes | +| --- | --- | --- | +| `prefix` | `string` (default `dsh:session`) | Base key prefix. Two backends on one database with different prefixes share nothing — including store identity — so their revisions can never compare equal. | +| `ttlSeconds` | positive integer | Expiry refreshed on every write. Omitted (the default) keeps sessions forever, which is what the append-only contract assumes. See the warning below. | +| `preparedSessionCacheSize` | positive integer (default `5`) | Cold `Session` preparations retained for history-to-resume reuse. | +| `writeBatchMaxDelayMs` | positive integer (default `200`) | Fixed coalescing window after an idle live-event queue receives work. Later events do not reset it; flush and teardown bypass it. It does not bound network or backend latency. | +| `urlRef` | `string` (default `UPSTASH_REDIS_REST_URL`) | Credential *reference* holding the REST URL — names the credential, never the value. | +| `tokenRef` | `string` (default `UPSTASH_REDIS_REST_TOKEN`) | Credential reference holding the REST token. | +| `redis` | `Redis` | Runtime-only seam, **not** settable from `cordis.yml` — a client instance is not a config value. Omit it and the backend resolves through `ctx.credentials`, then `Redis.fromEnv()`. | + +⚠️ **`ttlSeconds` is a data-loss knob.** The persistence seam has no deletion or retention API +precisely because a stored session is meant to outlive the process. An expired log is *gone*, not +repairable, and the session it belonged to can no longer be resumed. Use it for ephemeral or preview +deployments; leave it off for anything a user may come back to. + +## Key layout + +``` +:store # string — this store's generated identity (SET NX once) +:ids # set — every materialized session id +:meta: # hash — { meta, incarnation, revision } +:events: # list — one JSON-encoded SessionEvent per element +``` + +The events **list** is the load-bearing choice. A session log is append-only with contiguous `seq` +starting at 0, so **list index === event seq**. That single fact gives the whole backend its +primitives: `LLEN` is the stored next-seq, `LRANGE key fromSeq -1` is a real seek read, and `LTRIM` +is a tail truncation. + +Being able to seek matters for the seam's `readFrom`, the read-model primitive that resumes from a +watermark. This backend implements the optional `loadStoredFrom` hook, so `readFrom` scales with the +suffix it returns — like SQLite's `WHERE seq >= ?`, and unlike JSONL, which must parse the whole +artifact and skip forward. + +## Durability and crash semantics + +Like the two first-party backends, this one composes the shared `PersistenceCoordinator` and +implements only the small `PersistenceBackend` storage-hook interface. Everything correctness-heavy +in the write path — batching, per-id serialization, lazy materialization, crash-repair sequencing, +session adoption, quiescent disposal — is the harness's own code, identical across all three +backends. What is written here is storage primitives. + +- **Atomic append.** Materializing a session's header and writing its first event batch happen in one + `EVAL`. Redis runs a script to completion without interleaving, which is the same boundary SQLite + gets from a transaction: a crash cannot leave a materialized-but-empty session. +- **No torn tails.** Because every mutation is one script, this backend cannot produce a partially + written record. Truncation is still implemented, so a key damaged from outside the backend is + repairable rather than fatal. +- **Contiguous seq, enforced in storage.** `LLEN` *is* the stored next-seq, so the append script + rejects a batch whose first seq disagrees before writing anything. A second writer for the same + session — another process, a stale instance — fails loudly instead of silently interleaving. +- **Consistent reads.** One session's header, events, and revision are read in a single script. Two + round trips could straddle a concurrent append and return a revision describing a different prefix, + which the seam forbids. +- **Store-qualified revisions.** Revisions must not compare equal across independently backed stores, + and a per-session counter cannot promise that — two databases both start at 1. A `SET NX` store id, + written once per prefix, qualifies every revision. +- **Crash recovery.** Unchanged from the seam: `load` preserves a complete interrupted turn and + durably closes it with synthetic `tool/result` / `step/end` / `turn/end {interrupted}` closers, + rather than truncating real work. + +The package's test suite runs the harness's **own** backend-agnostic conformance suite +(`runPersistenceContract`) — the one the JSONL and SQLite backends are held to — against a real +Upstash Redis, plus Redis-specific tests for key layout, seek reads, store identity, the atomic +append guard, out-of-band tail repair, and TTL. + +## Using it without publishing + +You never have to release this to run it in a real harness. `dsh plugin` forwards its arguments to +pnpm inside the profile directory, so **any specifier pnpm accepts works** — a local path, a tarball, +a git URL, or a registry package. Only the last of those involves publishing. + +| Route | Publish? | Notes | +| --- | --- | --- | +| Local path | no | Linked, not copied. Best for developing. | +| `--patch` overlay | no | Not installed at all. Fastest iteration. | +| Tarball (`pnpm pack`) | no | Ships built output. Best for handing to a teammate or CI. | +| Git URL | no | No registry, but see the build-script catch below. | +| npm registry | yes | Ordinary install. | + +**Install it from a local checkout.** `dsh plugin` forwards to pnpm inside the profile directory, so +a path dependency is *linked*, not copied — and because this package declares `dsh.bundle`, its layer +activates exactly as it would from npm: + +```bash +pnpm --filter @upstash/agentkit-deepseek build +dsh plugin --profile web add /absolute/path/to/redis-agentkit/packages/deepseek +dsh web --dump-config # shows a "# == @upstash/agentkit-deepseek" layer +dsh web +``` + +Since pnpm links rather than copies, a later `pnpm build` is picked up on the next boot — no +reinstall. `dsh plugin --profile web remove @upstash/agentkit-deepseek` takes back both the +dependency and the layer. + +**Or skip installation entirely** and drive it from a `--patch` overlay pointing at the built entry +point by absolute path: + +```yaml +# redis-sessions.cordis.yml +- id: session-persistence-jsonl + disabled: true + +- insert: + - id: session-persistence-redis + name: '/absolute/path/to/redis-agentkit/packages/deepseek/dist/index.js' +``` + +```bash +dsh web --patch ./redis-sessions.cordis.yml +``` + +`--patch` overlays apply last, after every bundle layer and the profile's own, so this also works as +a temporary override on top of a normally installed copy. The path is machine-specific, which makes +it a development tool rather than something to commit. + +**Or hand someone a tarball.** For distributing to a teammate or a CI image without a registry: + +```bash +pnpm --filter @upstash/agentkit-deepseek build +pnpm --filter @upstash/agentkit-deepseek pack # → upstash-agentkit-deepseek-0.1.0.tgz +dsh plugin --profile web add ./upstash-agentkit-deepseek-0.1.0.tgz +``` + +The tarball carries `dist/`, `cordis.patch.yml`, and the `dsh.bundle` manifest, so it installs and +activates exactly like a registry copy — and because it contains built output, the recipient needs no +build permission. + +**The one route with a catch is a git install.** `dsh plugin add github:you/repo` also needs no +registry, but pnpm fetches **sources, not build output**, so nothing produces `dist/` and the plugin +fails to load. That route additionally requires a `prepare` script on the author's side and, on the +user's side, an explicit `allowBuilds` entry in the profile's `pnpm-workspace.yaml` — which is +permission to execute the package's code at install time, outside any sandbox. This package ships no +`prepare` script (an install-time build is fragile in a monorepo), so prefer a path, tarball, or +registry install. + +## Known limitations + +- **No deletion or retention API.** That is the seam's stance, not this backend's: pruning stored + sessions is out-of-band maintenance. `ttlSeconds` is the one lever, with the caveat above. +- **`list()` is unpaginated and unfiltered.** It returns every stored session's header, one pipelined + `HMGET` per session. Fine for a normal store; unindexed at scale. +- **No raw artifact.** Redis holds a keyspace, not a file per session, so `locate()` returns + `undefined` and `supportsRawArtifacts` is `false` — the same answer the SQLite backend gives. +- **One live writer per session.** Append and repair are coordinated inside the owning backend + instance. The append script's seq guard turns a second concurrent writer into a loud failure rather + than a corrupted log, but it does not make concurrent writers *work*. + +## License + +MIT diff --git a/packages/deepseek/cordis.patch.yml b/packages/deepseek/cordis.patch.yml new file mode 100644 index 0000000..63babb7 --- /dev/null +++ b/packages/deepseek/cordis.patch.yml @@ -0,0 +1,23 @@ +# The @upstash/agentkit-deepseek bundle layer: move session persistence off the +# local disk and into Upstash Redis. +# +# Applied after @deepseek-ai/dsh-base, so the row it disables already exists. +# +# A row is REPLACED by disabling it and inserting another, never by patching its +# `name`: the patcher treats a patch's `name` as an assertion about the target +# and skips the whole patch on a mismatch, so a rename would silently do nothing +# and leave the JSONL backend running. + +# The base bundle's local-disk backend. A serverless or multi-replica deployment +# has no durable local disk to write it to, and no shared one to read it back. +- id: session-persistence-jsonl + disabled: true + +# Credentials are read from the environment (UPSTASH_REDIS_REST_URL / +# UPSTASH_REDIS_REST_TOKEN), never inlined here. Every other knob takes its +# schema default; override this row's `config` from your profile's +# cordis.patch.yml, restating every key it needs — a patch replaces a row's +# whole config rather than merging into it. +- insert: + - id: session-persistence-redis + name: "@upstash/agentkit-deepseek" diff --git a/packages/deepseek/package.json b/packages/deepseek/package.json new file mode 100644 index 0000000..b3ef63c --- /dev/null +++ b/packages/deepseek/package.json @@ -0,0 +1,84 @@ +{ + "name": "@upstash/agentkit-deepseek", + "version": "0.1.0", + "description": "DeepSeek Harness plugins backed by Upstash Redis: a durable session-persistence backend for ctx.sessionPersistence.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/upstash/agentkit.git", + "directory": "packages/deepseek" + }, + "homepage": "https://github.com/upstash/agentkit/tree/main/packages/deepseek", + "bugs": { + "url": "https://github.com/upstash/agentkit/issues" + }, + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "agentkit-deepseek": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./session-persistence": { + "types": "./dist/session-persistence.d.ts", + "import": "./dist/session-persistence.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./package.json": "./package.json" + }, + "files": [ + "dist", + "cordis.patch.yml", + "README.md" + ], + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "clean": "rm -rf dist", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "upstash", + "redis", + "deepseek", + "deepseek-harness", + "dsh", + "cordis", + "session", + "persistence", + "agent" + ], + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "@upstash/redis": "^1.38.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": ">=4.0.0", + "@deepseek-ai/dsh-credentials-local": ">=0.1.0-rc.6", + "@deepseek-ai/dsh-session": ">=0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": ">=0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-credentials-local": { + "optional": true + } + }, + "devDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-credentials-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "0.1.0-rc.6", + "dotenv": "^16.4.5" + } +} diff --git a/packages/deepseek/src/cli.ts b/packages/deepseek/src/cli.ts new file mode 100644 index 0000000..81cbce7 --- /dev/null +++ b/packages/deepseek/src/cli.ts @@ -0,0 +1,255 @@ +#!/usr/bin/env node +/** + * `agentkit-deepseek` — store the Upstash connection in the harness's own + * credentials document, so nobody has to hand-edit YAML. + * + * The DeepSeek Harness has no CLI for writing credentials (`dsh` has exactly two + * subcommands, `web` and `plugin`), and its Web UI cannot help here either: the + * Models page is LLM-provider-specific, and the generic plugin-settings section + * gates card exposure behind a Host allowlist compiled into the harness, so a + * plugin distributed outside that repository cannot surface its own + * configuration there. + * + * What IS generic is the credentials seam itself — a reference is any POSIX + * identifier. So this command mounts the harness's own `LocalCredentialProvider` + * and calls `set()`, which means the atomic write, the cross-process lock, the + * `0600` mode, and the comment-preserving patch are all the harness's code + * rather than our re-implementation of a file format we do not own. + * + * @module @upstash/agentkit-deepseek/cli + */ + +import { createInterface } from "node:readline/promises"; +import { stdin, stdout } from "node:process"; +import { + DEFAULT_TOKEN_REF, + DEFAULT_URL_REF, + assertCredentialRef, + type CredentialWriter, +} from "./credentials.js"; + +/** Exit code for a usage error, matching the harness's own convention. */ +const USAGE_EXIT = 1; + +/** Parsed command line. */ +interface Args { + command: "set" | "status" | "help"; + url?: string; + token?: string; + home?: string; + urlRef: string; + tokenRef: string; +} + +const HELP = `agentkit-deepseek — manage the Upstash credentials the DeepSeek Harness backend reads + +Usage: + agentkit-deepseek credentials set [--url ] [--token ] + agentkit-deepseek credentials status + +Options: + --url Upstash REST URL; prompted for when omitted + --token Upstash REST token; prompted for when omitted + --home Harness home (default: $DSH_HOME, else ~/.dsh) + --url-ref Credential reference for the URL (default: ${DEFAULT_URL_REF}) + --token-ref Credential reference for the token (default: ${DEFAULT_TOKEN_REF}) + -h, --help Show this help + +Values are written to /.credentials.yaml through the harness's own +credentials provider, which owns the file's atomic write and 0600 permissions. + +Passing --token puts the secret in your shell history and process list; prefer +the interactive prompt, which does not echo.`; + +/** + * Parse argv. + * @param argv - arguments after the node binary and script path. + * @returns the parsed command. + */ +export function parseArgs(argv: readonly string[]): Args { + const args: Args = { command: "help", urlRef: DEFAULT_URL_REF, tokenRef: DEFAULT_TOKEN_REF }; + const rest = [...argv]; + + // `credentials` is accepted as an optional noun so both `credentials set` and + // a bare `set` work; the noun exists to leave room for later command groups. + if (rest[0] === "credentials") rest.shift(); + + const verb = rest.shift(); + if (verb === "set" || verb === "status") args.command = verb; + else if (verb === undefined || verb === "-h" || verb === "--help" || verb === "help") { + return args; + } else { + throw new Error(`unknown command "${verb}"`); + } + + for (let i = 0; i < rest.length; i++) { + const flag = rest[i]; + const value = rest[i + 1]; + const needsValue = () => { + if (value === undefined || value.startsWith("--")) { + throw new Error(`${String(flag)} requires a value`); + } + i++; + return value; + }; + switch (flag) { + case "--url": + args.url = needsValue(); + break; + case "--token": + args.token = needsValue(); + break; + case "--home": + args.home = needsValue(); + break; + case "--url-ref": + args.urlRef = assertCredentialRef(needsValue()); + break; + case "--token-ref": + args.tokenRef = assertCredentialRef(needsValue()); + break; + case "-h": + case "--help": + args.command = "help"; + break; + default: + throw new Error(`unknown option "${String(flag)}"`); + } + } + + return args; +} + +/** + * Load the harness packages this command drives. + * + * Imported dynamically, and deliberately not declared as runtime dependencies: + * `@deepseek-ai/cordis` must stay a single copy (a second one would break + * service identity), and the provider is only needed by this command, not by the + * plugin. Both resolve from a profile directory through Node's parent walk, + * which reaches the harness's maintained `$DSH_HOME/profiles/node_modules` + * fallback. + */ +async function loadHarness() { + try { + const [{ Context }, provider] = await Promise.all([ + import("@deepseek-ai/cordis"), + import("@deepseek-ai/dsh-credentials-local"), + ]); + return { Context, LocalCredentialProvider: provider.default }; + } catch (cause) { + throw new Error( + "cannot load the DeepSeek Harness credentials provider.\n" + + "Run this from a directory where it resolves — a dsh profile is the usual one:\n" + + " dsh plugin --profile web exec agentkit-deepseek credentials set\n" + + "or install it alongside this package:\n" + + " npm install @deepseek-ai/cordis @deepseek-ai/dsh-credentials-local", + { cause }, + ); + } +} + +/** Prompt for a value, hiding input for secrets. */ +async function prompt(question: string, secret: boolean): Promise { + const rl = createInterface({ input: stdin, output: stdout, terminal: true }); + try { + if (!secret) return (await rl.question(question)).trim(); + // readline has no built-in masking: mute the echo callback for the duration + // so the token never lands on screen (or in a scrollback buffer). + const output = rl as unknown as { output?: { write(chunk: string): void } }; + const original = output.output?.write.bind(output.output); + let muted = false; + if (original !== undefined && output.output !== undefined) { + output.output.write = (chunk: string) => { + if (!muted) original(chunk); + }; + } + const answered = rl.question(question); + muted = true; + const value = await answered; + muted = false; + if (original !== undefined && output.output !== undefined) output.output.write = original; + stdout.write("\n"); + return value.trim(); + } finally { + rl.close(); + } +} + +/** + * Run the command. + * @param argv - arguments after the node binary and script path. + * @returns the process exit code. + */ +export async function run(argv: readonly string[]): Promise { + let args: Args; + try { + args = parseArgs(argv); + } catch (error) { + stdout.write(`${(error as Error).message}\n\n${HELP}\n`); + return USAGE_EXIT; + } + + if (args.command === "help") { + stdout.write(`${HELP}\n`); + return 0; + } + + const { Context, LocalCredentialProvider } = await loadHarness(); + const ctx = new Context(); + // The provider watches its document, which keeps the event loop alive — so the + // fiber is disposed before returning or this command would never exit. + const fiber = await ctx.plugin( + LocalCredentialProvider, + args.home === undefined ? {} : { dshHome: args.home }, + ); + + // Bound structurally: this package does not depend on `@deepseek-ai/dsh-credentials`, + // so cordis's `Context` carries no `credentials` augmentation here. + const credentials = ctx.get("credentials") as CredentialWriter; + + try { + if (args.command === "status") { + for (const ref of [args.urlRef, args.tokenRef]) { + const info = await credentials.describe(ref); + const where = info.configured ? `configured (source: ${String(info.source)})` : "not set"; + const writable = info.writable ? "" : " [read-only here]"; + stdout.write(`${ref}: ${where}${writable}\n`); + } + return 0; + } + + const url = args.url ?? (await prompt("Upstash REST URL: ", false)); + const token = args.token ?? (await prompt("Upstash REST token: ", true)); + if (url.length === 0 || token.length === 0) { + stdout.write("both a URL and a token are required; nothing was written\n"); + return USAGE_EXIT; + } + + await credentials.set(args.urlRef, url); + await credentials.set(args.tokenRef, token); + stdout.write(`stored ${args.urlRef} and ${args.tokenRef}\n`); + stdout.write("restart dsh for a running harness to pick them up\n"); + return 0; + } finally { + await fiber.dispose(); + } +} + +const invokedDirectly = + process.argv[1] !== undefined && + import.meta.url.endsWith(new URL(`file://${process.argv[1]}`).pathname); + +if (invokedDirectly) { + run(process.argv.slice(2)) + .then((code) => { + process.exitCode = code; + }) + .catch((error: unknown) => { + // The provider refuses to write a reference the launching shell already + // exports, because the write would be shadowed by a higher layer. That + // message names the fix, so surface it rather than a stack trace. + stdout.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = USAGE_EXIT; + }); +} diff --git a/packages/deepseek/src/credentials.ts b/packages/deepseek/src/credentials.ts new file mode 100644 index 0000000..67bf899 --- /dev/null +++ b/packages/deepseek/src/credentials.ts @@ -0,0 +1,122 @@ +/** + * Resolving the Upstash connection through the harness's credentials seam. + * + * `Redis.fromEnv()` alone is not enough in a DeepSeek Harness deployment. The + * harness has four credential layers, and the one users are steered toward — + * the managed `$DSH_HOME/.credentials.yaml` document that the Web UI writes and + * hot-reloads — is **deliberately never materialized into `process.env`**. It is + * resolved by name through `ctx.credentials`. A backend that only reads the + * environment therefore cannot see a key the user stored the recommended way. + * + * So the connection resolves in this order: + * + * 1. An explicit `redis` client from config (the runtime-only seam). + * 2. `ctx.credentials`, when a provider is mounted — which searches every layer + * (inherited env > `.credentials.yaml` > project `.env` > user `.env`). + * 3. `Redis.fromEnv()`, so a deployment with no credentials provider, or an + * embedder using this outside the harness, behaves exactly as before. + * + * The credentials service is bound structurally rather than imported, so this + * package takes no dependency on `@deepseek-ai/dsh-credentials` and works + * unchanged when no provider is mounted. + * + * @module @upstash/agentkit-deepseek/credentials + */ + +/** POSIX-portable environment-variable name — the credentials seam's ref pattern. */ +const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Default credential reference for the REST URL. */ +export const DEFAULT_URL_REF = "UPSTASH_REDIS_REST_URL"; + +/** Default credential reference for the REST token. */ +export const DEFAULT_TOKEN_REF = "UPSTASH_REDIS_REST_TOKEN"; + +/** One resolved credential and the provider-defined layer that supplied it. */ +export interface ResolvedCredential { + value: string; + source: string; +} + +/** + * The slice of `ctx.credentials` this package uses. + * + * Structural on purpose: binding by shape keeps `@deepseek-ai/dsh-credentials` + * out of the dependency graph, and the service is optional anyway. + */ +export interface CredentialResolver { + resolve(ref: string): Promise; +} + +/** What `describe` reports about one reference, without revealing its value. */ +export interface CredentialInfo { + configured: boolean; + source?: string; + writable: boolean; +} + +/** + * The write side of `ctx.credentials`, used by this package's CLI. + * + * `set` rejects for a reference the launching environment already supplies — + * the provider refuses a write that a higher-precedence layer would shadow, + * rather than storing a value the reader would never see. + */ +export interface CredentialWriter extends CredentialResolver { + describe(ref: string): Promise; + set(ref: string, value: string): Promise; + unset(ref: string): Promise; +} + +/** + * Validate a credential reference the same way the seam does. + * @param ref - candidate reference, e.g. `UPSTASH_REDIS_REST_TOKEN`. + * @returns the reference unchanged. + * @throws when it is not a POSIX identifier, which the seam would reject later. + */ +export function assertCredentialRef(ref: string): string { + if (!REF_PATTERN.test(ref)) { + throw new TypeError(`credential ref "${ref}" must match ${String(REF_PATTERN)}`); + } + return ref; +} + +/** + * Bind the credentials service if one is mounted. + * + * `ctx.get` returns `any` and does not throw for an absent service, so this is + * the whole optional-service story — no injection fiber to own or dispose. + * @param ctx - the plugin's context. + * @returns the resolver, or `undefined` when no provider is mounted. + */ +export function credentialResolverOf(ctx: { + get(name: string): unknown; +}): CredentialResolver | undefined { + const service = ctx.get("credentials") as CredentialResolver | undefined; + return typeof service?.resolve === "function" ? service : undefined; +} + +/** + * Resolve one credential pair through the service, if both are present. + * + * Both halves must resolve: a URL without a token (or the reverse) is a + * half-configured deployment, and falling through to `Redis.fromEnv()` gives a + * clearer failure than constructing a client from one good half. + * @param resolver - the bound credentials service. + * @param urlRef - reference holding the REST URL. + * @param tokenRef - reference holding the REST token. + * @returns both values, or `undefined` when either is unset. + */ +export async function resolveConnection( + resolver: CredentialResolver, + urlRef: string, + tokenRef: string, +): Promise<{ url: string; token: string } | undefined> { + const [url, token] = await Promise.all([ + resolver.resolve(assertCredentialRef(urlRef)), + resolver.resolve(assertCredentialRef(tokenRef)), + ]); + if (url === undefined || token === undefined) return undefined; + if (url.value.length === 0 || token.value.length === 0) return undefined; + return { url: url.value, token: token.value }; +} diff --git a/packages/deepseek/src/index.ts b/packages/deepseek/src/index.ts new file mode 100644 index 0000000..c9c1ce4 --- /dev/null +++ b/packages/deepseek/src/index.ts @@ -0,0 +1,40 @@ +/** + * DeepSeek Harness plugins backed by Upstash Redis. + * + * The default export is the session-persistence plugin, so a `cordis.yml` row + * can name the package directly: + * + * ```yaml + * - id: session-persistence + * name: '@upstash/agentkit-deepseek' + * ``` + * + * @module @upstash/agentkit-deepseek + */ + +export { + RedisSessionPersistence, + RedisSessionPersistence as default, + type Config, + type Config as RedisSessionPersistenceConfig, +} from "./session-persistence.js"; +export { DEFAULT_PREFIX, sessionKeys, type SessionKeys } from "./keys.js"; +export { + DEFAULT_TOKEN_REF, + DEFAULT_URL_REF, + assertCredentialRef, + credentialResolverOf, + resolveConnection, + type CredentialInfo, + type CredentialResolver, + type CredentialWriter, + type ResolvedCredential, +} from "./credentials.js"; +export { + decodeEvent, + decodeHeader, + encodeEvent, + encodeHeader, + scanRecords, + type ScannedRecords, +} from "./records.js"; diff --git a/packages/deepseek/src/keys.ts b/packages/deepseek/src/keys.ts new file mode 100644 index 0000000..cfd2882 --- /dev/null +++ b/packages/deepseek/src/keys.ts @@ -0,0 +1,60 @@ +/** + * Redis key layout for the DeepSeek Harness session-persistence backend. + * + * Three key shapes live under one configurable prefix: + * + * ``` + * :store # string — this store's identity (SET NX once) + * :ids # set — every materialized session id + * :meta: # hash — { meta, incarnation, revision } + * :events: # list — one JSON-encoded SessionEvent per element + * ``` + * + * The events LIST is the load-bearing choice: a session log is append-only with + * contiguous `seq` starting at 0, so **list index === event seq**. That makes + * `LLEN` the stored next-seq, `LRANGE key fromSeq -1` a real seek read (so this + * backend implements the optional `loadStoredFrom` hook rather than taking the + * coordinator's parse-everything fallback), and `LTRIM` a tail truncation. + * + * @module @upstash/agentkit-deepseek/keys + */ + +import type { SessionId } from "@deepseek-ai/dsh-session"; + +/** Default base prefix for every key this backend owns. */ +export const DEFAULT_PREFIX = "dsh:session"; + +/** + * The per-store key set, resolved once from a configured prefix. + * + * A prefix is the isolation boundary: two backends pointed at the same Redis + * database with different prefixes share nothing, including store identity, so + * their revisions can never compare equal. + */ +export interface SessionKeys { + /** The configured base prefix, verbatim. */ + readonly prefix: string; + /** Holds this store's generated identity. */ + readonly store: string; + /** Set of every materialized session id. */ + readonly ids: string; + /** Hash of one session's header, incarnation, and revision counter. */ + meta(id: SessionId | string): string; + /** List of one session's JSON-encoded events, indexed by seq. */ + events(id: SessionId | string): string; +} + +/** + * Build the key set for a prefix. + * @param prefix - base prefix; defaults to {@link DEFAULT_PREFIX}. + * @returns the resolved key set. + */ +export function sessionKeys(prefix: string = DEFAULT_PREFIX): SessionKeys { + return { + prefix, + store: `${prefix}:store`, + ids: `${prefix}:ids`, + meta: (id) => `${prefix}:meta:${String(id)}`, + events: (id) => `${prefix}:events:${String(id)}`, + }; +} diff --git a/packages/deepseek/src/records.ts b/packages/deepseek/src/records.ts new file mode 100644 index 0000000..c6dd270 --- /dev/null +++ b/packages/deepseek/src/records.ts @@ -0,0 +1,140 @@ +/** + * Encoding and load-time scanning for stored session records. + * + * `@upstash/redis` deserializes responses by default (`automaticDeserialization`), + * so a JSON string written into a list comes back already parsed. Every decoder + * here therefore accepts BOTH shapes — the parsed object and the raw text — and + * never assumes which one the client handed back. + * + * @module @upstash/agentkit-deepseek/records + */ + +import type { SessionEvent, SessionHeader } from "@deepseek-ai/dsh-session"; + +/** Result of scanning one session's stored records, mirroring the seam's crash contract. */ +export interface ScannedRecords { + /** The valid contiguous prefix, ready for the coordinator to validate and freeze. */ + preserved: SessionEvent[]; + /** First seq of a never-committed tail, when one exists. */ + tornFrom?: number; +} + +/** Encode one event for storage. */ +export function encodeEvent(event: SessionEvent): string { + return JSON.stringify(event); +} + +/** Encode a session header for storage. */ +export function encodeHeader(meta: SessionHeader): string { + return JSON.stringify(meta); +} + +/** + * Decode a stored value that may arrive parsed or as raw JSON text. + * @param value - the value Redis returned. + * @returns the decoded object, or `undefined` when it is absent or not an object. + */ +function decodeObject(value: unknown): Record | undefined { + if (value === null || value === undefined) return undefined; + if (typeof value === "string") { + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } + } + return typeof value === "object" ? (value as Record) : undefined; +} + +/** + * Decode a stored session header. + * @param value - the raw or parsed header from the meta hash. + * @returns the header, or `undefined` when it is absent or malformed. + */ +export function decodeHeader(value: unknown): SessionHeader | undefined { + const object = decodeObject(value); + if (object === undefined) return undefined; + if (typeof object["id"] !== "string") return undefined; + if (typeof object["version"] !== "number") return undefined; + if (typeof object["createdAt"] !== "number") return undefined; + return object as unknown as SessionHeader; +} + +/** + * Decode one stored event record. + * + * Structural validation stays deliberately shallow — the same depth the SQLite + * backend's row decode applies. Full validation belongs to the coordinator, + * which owns it for every backend; this only has to separate "a readable + * record" from "a hole", because that distinction decides crash repair. + * + * @param value - the raw or parsed record from the events list. + * @returns the event, or `undefined` when the record is a hole. + */ +export function decodeEvent(value: unknown): SessionEvent | undefined { + const object = decodeObject(value); + if (object === undefined) return undefined; + if (typeof object["type"] !== "string") return undefined; + if (typeof object["seq"] !== "number" || !Number.isSafeInteger(object["seq"])) return undefined; + if (typeof object["time"] !== "number") return undefined; + if (!("data" in object)) return undefined; + return object as unknown as SessionEvent; +} + +/** + * Find the preserved prefix of one session's ordered records. + * + * This is the Redis twin of the SQLite backend's row scan, and it enforces the + * same seam rule: a hole at or before the last valid `turn/end` is committed + * corruption and rejects, while a hole after it is a tolerated crash tail whose + * first seq becomes the truncation point. + * + * Because the events key is a list indexed by seq, a "hole" here can only be an + * unreadable record — a seq gap is structurally impossible unless the key was + * edited outside this backend, which the index check below still catches. + * + * @param records - one session's stored records in list order. + * @param base - the seq the first record must carry (`0` for a whole log, the + * requested `fromSeq` for a suffix read). + * @returns the preserved events, plus `tornFrom` when a torn tail exists. + */ +export function scanRecords(records: readonly unknown[], base = 0): ScannedRecords { + const decoded = records.map((record) => decodeEvent(record)); + + // The last readable `turn/end` closes the committed region: everything at or + // before it must be intact, everything after it may be crash debris. + let lastTurnEnd = -1; + for (let i = decoded.length - 1; i >= 0; i--) { + if (decoded[i]?.type === "turn/end") { + lastTurnEnd = i; + break; + } + } + + const preserved: SessionEvent[] = []; + for (let i = 0; i < decoded.length; i++) { + const event = decoded[i]; + if (event === undefined) { + if (i <= lastTurnEnd) { + throw new Error(`corrupt session log: unreadable committed event at index ${base + i}`); + } + break; + } + if (event.seq !== base + i) { + if (i <= lastTurnEnd) { + throw new Error( + `corrupt session log: seq gap in committed region (expected ${base + i}, got ${event.seq})`, + ); + } + break; + } + preserved.push(event); + } + + return preserved.length < records.length + ? { preserved, tornFrom: base + preserved.length } + : { preserved }; +} diff --git a/packages/deepseek/src/scripts.ts b/packages/deepseek/src/scripts.ts new file mode 100644 index 0000000..deb329b --- /dev/null +++ b/packages/deepseek/src/scripts.ts @@ -0,0 +1,140 @@ +/** + * The two mutating Lua scripts behind this backend. + * + * The seam requires `appendBatch` to materialize a session's metadata and write + * its first event batch **atomically** — a crash between them must not leave a + * materialized-but-empty session. The SQLite backend gets that from a + * transaction; here a single `EVAL` is the equivalent boundary, since Redis runs + * a script to completion without interleaving. + * + * That atomicity has a useful consequence: this backend can never produce a + * partially written record, so the torn-tail marker exists only to repair keys + * damaged from outside the backend. `commitRepair` still implements truncation + * rather than assuming it can't happen. + * + * @module @upstash/agentkit-deepseek/scripts + */ + +/** Elements pushed per `RPUSH` call, bounding the Lua stack on large batches. */ +const CHUNK = 500; + +/** + * Durably append a contiguous batch, materializing the session in the same + * atomic step. + * + * `KEYS`: events list, meta hash, ids set. + * `ARGV`: `[expectedFirstSeq, headerJson, incarnation, ttlSeconds, sessionId, ...eventJson]`. + * + * Guards the contiguous-seq contract at the storage layer, not just in the + * coordinator's in-memory bookkeeping: `LLEN` IS the stored next-seq, so a batch + * whose first seq disagrees is rejected before anything is written. That turns a + * second writer for the same session (another process, a stale instance) into a + * loud failure instead of a silently interleaved log. + * + * Returns the resulting log length. + */ +export const APPEND_BATCH = ` +local expected = tonumber(ARGV[1]) +local stored = redis.call('LLEN', KEYS[1]) +if stored ~= expected then + return redis.error_reply('AGENTKIT_SEQ_MISMATCH stored next-seq ' .. stored .. ', batch starts at ' .. expected) +end + +redis.call('HSET', KEYS[2], 'meta', ARGV[2]) +redis.call('HSETNX', KEYS[2], 'incarnation', ARGV[3]) + +local i = 6 +while i <= #ARGV do + local last = i + ${CHUNK - 1} + if last > #ARGV then last = #ARGV end + redis.call('RPUSH', KEYS[1], unpack(ARGV, i, last)) + i = last + 1 +end + +redis.call('HINCRBY', KEYS[2], 'revision', 1) +redis.call('SADD', KEYS[3], ARGV[5]) + +local ttl = tonumber(ARGV[4]) +if ttl > 0 then + redis.call('EXPIRE', KEYS[1], ttl) + redis.call('EXPIRE', KEYS[2], ttl) +end + +return redis.call('LLEN', KEYS[1]) +`; + +/** + * Read one session's header fields and its event suffix atomically. + * + * `KEYS`: events list, meta hash. `ARGV`: `[fromSeq]`. + * + * One script rather than two round trips because the seam requires a returned + * revision to identify exactly the returned header and events — separate reads + * could straddle a concurrent append and describe a different prefix. + * + * `HMGET` yields `false` for a missing field, and a `false` inside a Lua table + * truncates the reply, so the fields are checked before the table is built and + * an incomplete hash reads as an absent session. + */ +export const READ_LOG = ` +local fields = redis.call('HMGET', KEYS[2], 'meta', 'incarnation', 'revision') +if not fields[1] or not fields[2] or not fields[3] then return nil end +return { fields[1], fields[2], fields[3], redis.call('LRANGE', KEYS[1], tonumber(ARGV[1]), -1) } +`; + +/** + * Read one session's revision fields without touching its event log. + * + * `KEYS`: meta hash. + */ +export const READ_REVISION = ` +local fields = redis.call('HMGET', KEYS[1], 'incarnation', 'revision') +if not fields[1] or not fields[2] then return nil end +return { fields[1], fields[2] } +`; + +/** + * Make a crash repair durable: truncate a torn tail and append synthetic closers. + * + * `KEYS`: events list, meta hash. + * `ARGV`: `[tornFrom (-1 for none), ttlSeconds, ...closerJson]`. + * + * `tornFrom === 0` deletes the list outright — `LTRIM key 0 -1` would keep every + * element, which is the opposite of the intent. + * + * Returns the resulting log length. + */ +export const COMMIT_REPAIR = ` +local torn = tonumber(ARGV[1]) +local changed = false + +if torn >= 0 then + if torn == 0 then + redis.call('DEL', KEYS[1]) + else + redis.call('LTRIM', KEYS[1], 0, torn - 1) + end + changed = true +end + +local i = 3 +while i <= #ARGV do + local last = i + ${CHUNK - 1} + if last > #ARGV then last = #ARGV end + redis.call('RPUSH', KEYS[1], unpack(ARGV, i, last)) + i = last + 1 + changed = true +end + +if changed then + redis.call('HINCRBY', KEYS[2], 'revision', 1) +end + +local ttl = tonumber(ARGV[2]) +if ttl > 0 then + if redis.call('EXISTS', KEYS[1]) == 1 then redis.call('EXPIRE', KEYS[1], ttl) end + redis.call('EXPIRE', KEYS[2], ttl) +end + +return redis.call('LLEN', KEYS[1]) +`; diff --git a/packages/deepseek/src/session-persistence.ts b/packages/deepseek/src/session-persistence.ts new file mode 100644 index 0000000..b703326 --- /dev/null +++ b/packages/deepseek/src/session-persistence.ts @@ -0,0 +1,491 @@ +/** + * Durable DeepSeek Harness session persistence on Upstash Redis. + * + * This is a Service Provider for the `dsh-session-persistence` capability seam: + * it registers as `ctx.sessionPersistence` and stores each session's + * `SessionEvent` log plus its out-of-log `SessionHeader` in Redis. Like the + * SQLite backend, it composes the shared {@link PersistenceCoordinator} and + * implements only the small `PersistenceBackend` storage-hook interface, so the + * write-path orchestration the seam specifies — batching, per-id serialization, + * lazy materialization, crash repair sequencing, session adoption, quiescent + * disposal — is the same code every first-party backend runs. + * + * Redis is a networked store rather than a local artifact, so this backend has + * no per-session file: `locate()` returns `undefined` and `supportsRawArtifacts` + * is `false`. + * + * @module @upstash/agentkit-deepseek/session-persistence + */ + +import type { Context } from "@deepseek-ai/cordis"; +import z from "@deepseek-ai/schemastery"; +import { Redis } from "@upstash/redis"; +import { + DEFAULT_PREPARED_SESSION_CACHE_SIZE, + DEFAULT_WRITE_BATCH_MAX_DELAY_MS, + MAX_WRITE_BATCH_DELAY_MS, + PersistenceCoordinator, + SessionPersistence, + SessionPersistenceRevision, + type PersistenceBackend, + type SessionInspection, + type SessionLocation, + type SessionPersistenceSnapshot, + type StoredPrefix, + type StoredSuffix, +} from "@deepseek-ai/dsh-session-persistence"; +import type { + SessionEvent, + SessionHeader, + SessionId, + SessionPreparation, +} from "@deepseek-ai/dsh-session"; +import { DEFAULT_PREFIX, sessionKeys, type SessionKeys } from "./keys.js"; +import { decodeHeader, encodeEvent, encodeHeader, scanRecords } from "./records.js"; +import { APPEND_BATCH, COMMIT_REPAIR, READ_LOG, READ_REVISION } from "./scripts.js"; +import { + assertCredentialRef, + credentialResolverOf, + DEFAULT_TOKEN_REF, + DEFAULT_URL_REF, + resolveConnection, +} from "./credentials.js"; + +/** Plugin configuration. */ +export interface Config { + /** + * Redis client. Runtime-only seam — not settable from `cordis.yml`, because a + * client instance is not a config value. Omit it and the backend resolves the + * connection through `ctx.credentials`, falling back to `Redis.fromEnv()`. + */ + redis?: Redis; + /** + * Credential reference holding the Upstash REST URL. Names the credential, not + * the value — the same shape the harness's own adapters use for `apiKeyEnv`, + * so no secret is ever written into a config row. + */ + urlRef?: string; + /** Credential reference holding the Upstash REST token. */ + tokenRef?: string; + /** + * Base key prefix owned by this backend. Two backends on the same database + * with different prefixes share nothing, including store identity, so their + * revisions can never compare equal. + */ + prefix?: string; + /** + * Optional expiry refreshed on every write, in seconds. Omitted (the default) + * keeps sessions forever, which is what the seam's append-only contract + * assumes. Setting it makes stored sessions disappear on their own — fine for + * ephemeral or preview deployments, and a data-loss risk for anything a user + * may resume later, because an expired log is gone rather than repairable. + */ + ttlSeconds?: number; + /** Maximum cold Session preparations retained for history-to-resume reuse. */ + preparedSessionCacheSize?: number; + /** Fixed live-event coalescing window; not a backend completion deadline. */ + writeBatchMaxDelayMs?: number; +} + +/** Raw shape of one session's stored state, as the read script returns it. */ +type ReadLogReply = [unknown, unknown, unknown, unknown[]] | null; + +/** Raw shape of one session's revision fields. */ +type ReadRevisionReply = [unknown, unknown] | null; + +/** + * Prepare a client's scripts and claim or read this store's generated identity. + * + * A module-level factory rather than a method so `ConnectedStore` can be + * *inferred* from it: `@upstash/redis` does not export its `Script`/`ScriptRO` + * types, and inferring beats restating them by hand. + * + * Revisions must not compare equal across independently backed stores, and a + * per-session counter alone cannot promise that: two different databases both + * start at 1. A `SET NX` id, written once per prefix and read back by every + * later instance, qualifies every revision this backend emits. + * + * @param redis - the resolved client. + * @param keys - the resolved key layout. + * @returns the client, its prepared scripts, and this store's revision prefix. + */ +async function connectStore(redis: Redis, keys: SessionKeys) { + const candidate = crypto.randomUUID(); + await redis.set(keys.store, candidate, { nx: true }); + const stored = await redis.get(keys.store); + if (stored === null || stored === undefined || String(stored).length === 0) { + throw new Error(`redis session store at "${keys.prefix}" has no store identity`); + } + + return { + redis, + storeIdentity: `redis:prefix:${keys.prefix}:store:${String(stored)}`, + append: redis.createScript(APPEND_BATCH), + repair: redis.createScript(COMMIT_REPAIR), + readLog: redis.createScript(READ_LOG, { readonly: true }), + readRevision: redis.createScript(READ_REVISION, { readonly: true }), + }; +} + +/** A connected client with its prepared scripts and this store's revision prefix. */ +type ConnectedStore = Awaited>; + +/** + * The Upstash Redis session-persistence backend. Load it as a plugin; it + * registers `ctx.sessionPersistence` and (through the coordinator) installs the + * write-path listeners. + * + * The events key is a Redis list whose index is the event seq, which makes this + * a **seek-capable** backend: `readFrom` reads only the requested suffix via + * `LRANGE`, instead of parsing the whole log and skipping forward. + * + * Its torn-tail marker is the seq to truncate from. In practice this backend + * never produces one — every mutation is a single Lua script, so a batch is + * written completely or not at all — but truncation stays implemented so a key + * damaged from outside the backend is still repairable. + */ +export class RedisSessionPersistence + extends SessionPersistence + implements PersistenceBackend +{ + override readonly supportsRawArtifacts = false; + + static inject = ["sessions"]; + + static Config: z = z.object({ + prefix: z.string().default(DEFAULT_PREFIX), + ttlSeconds: z.number().step(1).min(1), + // `role('credential-ref')` is how the harness marks a field that names a + // credential: its settings UI renders those as key controls that write + // through the credentials domain instead of storing the value inline. + urlRef: z.string().role("credential-ref").default(DEFAULT_URL_REF), + tokenRef: z.string().role("credential-ref").default(DEFAULT_TOKEN_REF), + preparedSessionCacheSize: z + .number() + .step(1) + .min(1) + .default(DEFAULT_PREPARED_SESSION_CACHE_SIZE), + writeBatchMaxDelayMs: z + .number() + .step(1) + .min(1) + .max(MAX_WRITE_BATCH_DELAY_MS) + .default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS), + }) as unknown as z; + + /** + * Backend label for the coordinator's dispose diagnostics. Intentionally + * shadows the cordis `Service.name` the base class sets to + * `'sessionPersistence'`, exactly as the first-party backends do. + */ + override readonly name = "session-persistence-upstash-redis"; + + /** Resolved key layout for the configured prefix. */ + readonly keys: SessionKeys; + + private readonly ttlSeconds: number; + private readonly urlRef: string; + private readonly tokenRef: string; + private readonly coordinator: PersistenceCoordinator; + + /** + * The connected client, its scripts, and the store-qualified revision prefix. + * + * Everything client-shaped lives behind this one promise because resolving the + * connection is now asynchronous — `ctx.credentials.resolve()` is async, and + * the credentials document is the layer users are steered toward. Every + * storage hook already awaited a readiness promise, so the async client costs + * no extra round trip and no hook had to change shape. + */ + private readonly ready: Promise; + + constructor( + ctx: Context, + public config: Config = {}, + ) { + super(ctx); + // Programmatic construction skips Schemastery normalization, so every knob + // resolves its own default here too. + this.keys = sessionKeys(config.prefix ?? DEFAULT_PREFIX); + this.ttlSeconds = config.ttlSeconds ?? 0; + this.urlRef = assertCredentialRef(config.urlRef ?? DEFAULT_URL_REF); + this.tokenRef = assertCredentialRef(config.tokenRef ?? DEFAULT_TOKEN_REF); + + // Connect off the constructor's critical path so plugin apply does not wait + // on credential resolution or a network round trip. + this.ready = this.connect(); + + this.coordinator = new PersistenceCoordinator(this.ctx, this, { + preparedSessionCacheSize: + config.preparedSessionCacheSize ?? DEFAULT_PREPARED_SESSION_CACHE_SIZE, + writeBatchMaxDelayMs: config.writeBatchMaxDelayMs ?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS, + }); + } + + /** + * The client every storage hook runs against, once connected. + * + * Exposed for callers that want to reach the same database — note it *awaits*, + * unlike the old synchronous field, because the connection may come from the + * credentials service. + */ + get redis(): Promise { + return this.ready.then((store) => store.redis); + } + + /** Resolve the connection, then prepare scripts and store identity. */ + private async connect(): Promise { + return connectStore(await this.resolveClient(), this.keys); + } + + /** + * Build the Redis client from the highest-precedence source available. + * + * Precedence: an explicit client, then `ctx.credentials` (which searches every + * harness layer, including the `.credentials.yaml` document that never reaches + * `process.env`), then `Redis.fromEnv()` so a deployment with no credentials + * provider — or an embedder outside the harness — behaves as it did before. + */ + private async resolveClient(): Promise { + if (this.config.redis !== undefined) return this.config.redis; + + const resolver = credentialResolverOf(this.ctx); + if (resolver !== undefined) { + const connection = await resolveConnection(resolver, this.urlRef, this.tokenRef); + if (connection !== undefined) { + return new Redis({ url: connection.url, token: connection.token }); + } + } + + return Redis.fromEnv(); + } + + /** Build the source-qualified revision shared by full and lightweight reads. */ + private revision(storeIdentity: string, incarnation: unknown, revision: unknown) { + return SessionPersistenceRevision( + `${storeIdentity}:incarnation:${String(incarnation)}:revision:${String(revision)}`, + ); + } + + /** The TTL argument every mutating script takes (`0` disables expiry). */ + private get ttlArg(): string { + return String(this.ttlSeconds); + } + + // --- SessionPersistence service API (delegated to the coordinator) --- + + /** Redis holds one keyspace, not an independent local artifact per session. */ + locate(_meta: SessionHeader): SessionLocation | undefined { + return undefined; + } + + create(meta: SessionHeader): Promise { + return this.coordinator.create(meta); + } + + append(id: SessionId, events: readonly SessionEvent[]): Promise { + return this.coordinator.append(id, events); + } + + override prepare(id: SessionId, signal?: AbortSignal): Promise { + return this.coordinator.prepare(id, signal); + } + + load(id: SessionId): Promise { + return this.coordinator.load(id); + } + + inspect(id: SessionId, signal?: AbortSignal): Promise { + return this.coordinator.inspect(id, signal); + } + + readFrom( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.readFrom(id, fromSeq, signal); + } + + // `list` below serves both the public service method and the backend hook; + // delegating it to the coordinator would call this hook recursively. + + // --- PersistenceBackend hooks (the Redis storage primitives) --- + + /** + * Read a stored prefix by id. Session ids are globally unique, so there is one + * key pair to resolve and no scope to scan. + */ + async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + return this.readLog(id, 0, signal) as Promise | undefined>; + } + + /** + * Seek-capable suffix read: `LRANGE key fromSeq -1` addresses events by seq + * directly, so `readFrom` scales with the suffix rather than the log. Records + * past the preserved region are dropped, never repaired — this read must not + * mutate. + */ + async loadStoredFrom( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ): Promise { + const prefix = await this.readLog(id, fromSeq, signal); + return prefix === undefined ? undefined : { meta: prefix.meta, events: prefix.events }; + } + + /** + * Read one session's header, events, and revision in a single atomic script. + * + * The atomicity matters for the seam's revision contract: the returned + * revision must identify exactly the returned header and events. Two separate + * round trips could straddle a concurrent append and hand back a revision that + * describes a different prefix. + */ + private async readLog( + id: SessionId, + fromSeq: number, + signal?: AbortSignal, + ): Promise | undefined> { + signal?.throwIfAborted(); + const store = await this.ready; + signal?.throwIfAborted(); + + const reply = await store.readLog.evalRo( + [this.keys.events(id), this.keys.meta(id)], + [String(fromSeq)], + ); + signal?.throwIfAborted(); + if (reply === null || reply === undefined) return undefined; + + const [rawHeader, incarnation, revision, records] = reply; + const meta = decodeHeader(rawHeader); + if (meta === undefined) { + throw new Error(`corrupt session log: unreadable header for session "${String(id)}"`); + } + + const { preserved, tornFrom } = scanRecords(records ?? [], fromSeq); + return { + meta, + events: preserved, + revision: this.revision(store.storeIdentity, incarnation, revision), + ...(tornFrom !== undefined ? { tornMarker: tornFrom } : {}), + }; + } + + /** Read one session's revision without loading its event log. */ + async readStoredRevision(id: SessionId, signal?: AbortSignal) { + signal?.throwIfAborted(); + const store = await this.ready; + signal?.throwIfAborted(); + + const reply = await store.readRevision.evalRo([this.keys.meta(id)], []); + if (reply === null || reply === undefined) return undefined; + return this.revision(store.storeIdentity, reply[0], reply[1]); + } + + /** + * Durably append a contiguous batch in ONE script: write the header (the + * materialization step), push every event, bump the revision, and record the + * id — or change nothing. The script is the atomicity and durability boundary, + * so a rejected batch (a seq that does not continue the stored log) leaves the + * stored log untouched. + */ + async appendBatch( + meta: SessionHeader, + events: readonly SessionEvent[], + _isMaterialized: boolean, + ): Promise { + const store = await this.ready; + if (events.length === 0) return; + + const first = events[0]; + /* istanbul ignore next -- a non-empty batch always has a first element. */ + if (first === undefined) return; + + await store.append.eval( + [this.keys.events(meta.id), this.keys.meta(meta.id), this.keys.ids], + [ + String(first.seq), + encodeHeader(meta), + crypto.randomUUID(), + this.ttlArg, + String(meta.id), + ...events.map((event) => encodeEvent(event)), + ], + ); + } + + /** + * Make a crash repair durable in ONE script: truncate the torn tail and append + * the synthetic closers. The seam does not require this to be atomic; here it + * is anyway. + */ + async commitRepair( + meta: SessionHeader, + tornMarker: number | undefined, + closers: readonly SessionEvent[], + ): Promise { + const store = await this.ready; + if (tornMarker === undefined && closers.length === 0) return; + + await store.repair.eval( + [this.keys.events(meta.id), this.keys.meta(meta.id)], + [String(tornMarker ?? -1), this.ttlArg, ...closers.map((event) => encodeEvent(event))], + ); + } + + /** List all materialized sessions' metadata, without parsing any event log. */ + async list(signal?: AbortSignal): Promise { + const snapshots = await this.listSnapshots(signal); + return snapshots.map((snapshot) => snapshot.header); + } + + /** + * List metadata with a source-qualified revision per session. + * + * Snapshots are pipelined rather than read in one script: each revision only + * has to identify its OWN session, so a cross-session atomic view buys nothing + * and a script looping over every session would block the server. + */ + async listSnapshots(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const store = await this.ready; + signal?.throwIfAborted(); + + const members = await store.redis.smembers(this.keys.ids); + signal?.throwIfAborted(); + if (members.length === 0) return []; + + const ids = members.map((member) => String(member)); + const pipeline = store.redis.pipeline(); + for (const id of ids) { + pipeline.hmget(this.keys.meta(id), "meta", "incarnation", "revision"); + } + const replies = (await pipeline.exec()) as (Record | null)[]; + signal?.throwIfAborted(); + + const snapshots: SessionPersistenceSnapshot[] = []; + const stale: string[] = []; + for (const [index, id] of ids.entries()) { + const fields = replies[index]; + const header = decodeHeader(fields?.["meta"]); + if (header === undefined) { + // The id set outlived its session — only reachable with `ttlSeconds` set + // or an out-of-band delete. Drop it so listing stays bounded. + stale.push(id); + continue; + } + snapshots.push({ + header, + revision: this.revision(store.storeIdentity, fields?.["incarnation"], fields?.["revision"]), + }); + } + + if (stale.length > 0) await store.redis.srem(this.keys.ids, ...stale); + return snapshots; + } +} + +export default RedisSessionPersistence; diff --git a/packages/deepseek/src/test-support.ts b/packages/deepseek/src/test-support.ts new file mode 100644 index 0000000..f340e08 --- /dev/null +++ b/packages/deepseek/src/test-support.ts @@ -0,0 +1,38 @@ +/** + * Test-only helpers (not part of the published surface — never imported by `index.ts`). + * + * Per the project's testing policy, this package is tested against a real Upstash Redis instance + * rather than a mock. Credentials are read from the repo-root `.env` (or the environment). When + * they are absent, `hasRedisCreds` is false and the suites skip themselves so CI without secrets + * stays green. + */ +import { randomUUID } from "node:crypto"; +import { config } from "dotenv"; +import { Redis } from "@upstash/redis"; + +// Load repo-root .env (no-op if already loaded or absent). +config(); + +export const hasRedisCreds = Boolean( + process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN, +); + +/** A real Upstash Redis client from env. Only call when `hasRedisCreds` is true. */ +export function testRedis(): Redis { + return Redis.fromEnv(); +} + +/** A collision-proof key prefix so parallel test runs never share keys. */ +export function uniquePrefix(label: string): string { + return `test:${label}:${randomUUID().slice(0, 8)}`; +} + +/** Delete every key under a key prefix (best-effort cleanup in afterAll hooks). */ +export async function cleanupKeys(redis: Redis, prefix: string): Promise { + let cursor = "0"; + do { + const [next, keys] = await redis.scan(cursor, { match: `${prefix}*`, count: 200 }); + cursor = next; + if (keys.length) await redis.del(...keys); + } while (cursor !== "0"); +} diff --git a/packages/deepseek/test/contract.ts b/packages/deepseek/test/contract.ts new file mode 100644 index 0000000..2f8e98a --- /dev/null +++ b/packages/deepseek/test/contract.ts @@ -0,0 +1,448 @@ +/** + * Reusable contract test for any {@link SessionPersistence} backend. A backend + * package imports {@link runPersistenceContract} and calls it with a factory + * that yields a fresh, empty backend (and a teardown), so every backend is held + * to the same append-only / contiguous-seq / lazy-materialization / crash + * semantics. The JSONL backend's own spec adds file-specific tests on top. + * + * --- + * + * VENDORED, VERBATIM, from the DeepSeek Harness (MIT): + * `packages/session/session-persistence/tests/contract.ts` @ 0.1.0-rc.5. + * https://github.com/deepseek-ai/deepseek-harness + * + * This is the authoritative conformance suite the first-party JSONL and SQLite + * backends are held to, and running it unchanged is the point — it is what + * proves the Redis backend has the SAME semantics rather than merely similar + * ones. It is copied because the harness ships only `lib/` to npm, so the suite + * cannot be imported from the published package. + * + * Only the `SessionPersistence` type import was repointed at the published + * package. Do not "improve" anything else here; re-copy it when upgrading the + * `@deepseek-ai/dsh-session-persistence` peer instead. + * + * @module @deepseek-ai/dsh-session-persistence/tests/contract + */ + +import { describe, expect, it } from 'vitest' +import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session' +import { CallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' + +/** A backend under test plus its teardown. */ +export interface ContractBackend { + persistence: SessionPersistence + dispose: () => Promise +} + +/** Build a minimal {@link SessionHeader} for a session id. */ +export function meta(id: string, cwd?: string): SessionHeader { + return { + version: SESSION_FORMAT_VERSION, + id: SessionId(id), + createdAt: 1000, + ...cwd !== undefined ? { cwd } : {}, + } +} + +/** A well-formed one-turn event log (contiguous seqs from 0). */ +export function oneTurnLog(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'user/message', seq: 1, time: 2, data: freezeMessage({ + id: MessageId('one-turn-user'), + role: 'user', + content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, + }), surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 3, time: 4, data: { + turn: 1, step: 1, + message: freezeMessage({ + id: MessageId('one-turn-assistant'), + role: 'assistant', + content: [{ type: 'text', text: 'hello' }], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'mock' }, + }, + }), + }, surfaceOp: 'append' }, + { type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +/** + * Append recorded events to a live session while forwarding surface metadata verbatim. The broad + * `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a + * surface event whose fixture omitted it; this helper never synthesizes a default. + */ +export function appendLog(session: Session, events: readonly SessionEvent[]): void { + for (const e of events) { + const se = e as SessionEvent + if (se.surfaceOp !== undefined) { + const intent: SurfaceIntent = { + surfaceOp: se.surfaceOp, + ...se.sourceEventSeqs !== undefined ? { sourceEventSeqs: se.sourceEventSeqs } : {}, + } + session.append(e.type, e.data, intent) + } else { + session.append(e.type, e.data) + } + } +} + +/** + * Run the backend-agnostic contract suite. `make()` MUST return a fresh, empty + * backend each call. + */ +export function runPersistenceContract(name: string, make: () => Promise): void { + describe(`SessionPersistence contract: ${name}`, () => { + it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('s1', '/work') + const log = oneTurnLog() + await persistence.create(m) + await persistence.append(m.id, log) + + const loaded = await persistence.load(m.id) + expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' }) + expect(loaded.events).toEqual(log) + } finally { + await dispose() + } + }) + + it('rejects a fractional creation timestamp without reserving its session id', async () => { + const { persistence, dispose } = await make() + try { + const m = { ...meta('fractional-created-at'), createdAt: 1.5 } + await expect(persistence.create(m)) + .rejects.toThrow('session metadata createdAt must be a non-negative safe integer') + + const valid = meta('fractional-created-at') + await persistence.create(valid) + await persistence.append(valid.id, oneTurnLog()) + expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt) + } finally { + await dispose() + } + }) + + it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('interrupted') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5) + // A second turn that crashed mid-flight: turn/start + step/start were + // durably written, but no step/end / turn/end ever arrived. + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + ]) + const beforeRepair = (await persistence.listSnapshots()) + .find(snapshot => snapshot.header.id === m.id)?.revision + + const inspected = await persistence.inspect(m.id) + const afterInspect = (await persistence.listSnapshots()) + .find(snapshot => snapshot.header.id === m.id)?.revision + expect(afterInspect).toBe(beforeRepair) + expect(inspected.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', + 'turn/start', 'step/start', 'step/end', 'turn/end', + ]) + + // load PRESERVES the interrupted turn's events (a turn can be huge — they + // must not be truncated) and closes the orphaned turn with synthetic + // boundary events: step/end (the step was open) then turn/end {interrupted}. + const loaded = await persistence.load(m.id) + const afterRepair = (await persistence.listSnapshots()) + .find(snapshot => snapshot.header.id === m.id)?.revision + expect(afterRepair).not.toBe(beforeRepair) + expect(loaded.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 + 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers + ]) + expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + const last = loaded.events.at(-1)! + expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' }) + + // The closed log is durable and continuable: a fresh append continues at + // the balanced length (seq 10), and a reload round-trips identically. + await persistence.append(m.id, [ + { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } }, + { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } }, + ]) + const reloaded = await persistence.load(m.id) + expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + } finally { + await dispose() + } + }) + + it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('interrupted-toolcall') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5) + // Turn 2 crashed AFTER the assistant message asked for a tool call but + // BEFORE the tool/result was written (the loop runs tools after logging + // the assistant message — a process killed mid-tool lands exactly here). + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } }, + { type: 'assistant/message', seq: 8, time: 9, data: { + turn: 2, step: 1, + message: createMessage({ + role: 'assistant', + content: [ + { type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' }, + ], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'mock' }, + }, + }), + }, surfaceOp: 'append' }, + ]) + + const loaded = await persistence.load(m.id) + // The orphaned call is answered by a synthetic error tool/result BEFORE + // step/end + turn/end {interrupted}, so the step (and turn) are balanced + // and a resumed session derives a valid transcript (no dangling call). + expect(loaded.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 + 'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2 + ]) + const synthetic = loaded.events.find(e => e.type === 'tool/result') + expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({ + message: { + source: { kind: 'tool', callId: CallId('call-x') }, + content: [{ type: 'tool-result', toolCallId: CallId('call-x'), isError: true }], + }, + error: { code: TOOL_NOT_STARTED }, + }) + // The synthetic result carries the SAME callId as the orphaned tool-call, + // so deriveMessages() pairs them — no provider-invalid dangling call. + const call = loaded.events.findLast(e => e.type === 'assistant/message') + const callId = call?.type === 'assistant/message' + && call.data.message.content.find(b => b.type === 'tool-call') + expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x')) + } finally { + await dispose() + } + }) + + it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('unknown-tool-outcome') + await persistence.create(m) + await persistence.append(m.id, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + { type: 'assistant/message', seq: 2, time: 3, data: { + turn: 1, step: 1, + message: createMessage({ + role: 'assistant', + content: [ + { type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' }, + ], + source: { + kind: 'model', + ...{ provider: 'mock', model: 'mock' }, + }, + }), + }, surfaceOp: 'append' }, + { type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } }, + ]) + + const loaded = await persistence.load(m.id) + const synthetic = loaded.events.find(e => e.type === 'tool/result') + expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({ + name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN, + }) + if (synthetic?.type !== 'tool/result' || synthetic.data.message.content[0].content[0]?.type !== 'text') { + throw new Error('expected a text tool result') + } + expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent') + expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user') + const resumed = Session.create(m.id, loaded.events, loaded.meta) + const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result')) + expect(resumedResult?.content[0]).toMatchObject({ + type: 'tool-result', toolCallId: CallId('call-risk'), isError: true, + }) + } finally { + await dispose() + } + }) + + it('list() excludes a created-but-never-appended (zero-event) session', async () => { + const { persistence, dispose } = await make() + try { + await persistence.create(meta('empty')) + expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty')) + expect((await persistence.listSnapshots()).map(snapshot => snapshot.header.id)) + .not.toContain(SessionId('empty')) + } finally { + await dispose() + } + }) + + it('rejects pre-aborted observation reads with the exact cancellation reason', async () => { + const { persistence, dispose } = await make() + try { + const reason = new Error('persistence observation cancelled') + const controller = new AbortController() + await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([]) + controller.abort(reason) + + await expect(persistence.list(controller.signal)).rejects.toBe(reason) + await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason) + await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal)) + .rejects.toBe(reason) + await expect(persistence.readFrom(SessionId('cancelled-read-from'), 0, controller.signal)) + .rejects.toBe(reason) + } finally { + await dispose() + } + }) + + it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('read-from', '/work') + const log = oneTurnLog() + await persistence.create(m) + await persistence.append(m.id, log) + + const whole = await persistence.readFrom(m.id, 0) + expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' }) + expect(whole.events).toEqual(log) + + const suffix = await persistence.readFrom(m.id, 3) + expect(suffix.events).toEqual(log.slice(3)) + expect(suffix.events[0]?.seq).toBe(3) + + // At/past the stored end: an empty tail, never an error. + await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] }) + await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] }) + + // Non-mutating: an interrupted-turn log is served as stored, no closers. + await persistence.append(m.id, [ + { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } }, + ]) + const tail = await persistence.readFrom(m.id, 6) + expect(tail.events.map(event => event.type)).toEqual(['turn/start']) + + await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found') + await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer') + await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer') + } finally { + await dispose() + } + }) + + it('lists stable lightweight revisions that change after an append', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('s2') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) + expect((await persistence.list()).map(x => x.id)).toContain(m.id) + const first = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + const repeated = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + expect(first).toBeDefined() + expect(repeated?.revision).toBe(first?.revision) + + await persistence.append(m.id, [{ + type: 'turn/start', + seq: 6, + time: 7, + data: { turn: 2 }, + }]) + const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + expect(changed?.revision).not.toBe(first?.revision) + } finally { + await dispose() + } + }) + + it('append rejects a batch whose first seq does not match the stored next-seq', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('s3') + await persistence.create(m) + await persistence.append(m.id, oneTurnLog()) // seqs 0..5, next-seq = 6 + // A re-append of an already-stored seq must be rejected, not duplicated. + const restated = oneTurnLog() + await expect(persistence.append(m.id, restated)).rejects.toThrow() + } finally { + await dispose() + } + }) + + it('append rejects a mid-batch seq gap', async () => { + const { persistence, dispose } = await make() + try { + const m = meta('s4') + await persistence.create(m) + const gapped: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1 + ] + await expect(persistence.append(m.id, gapped)).rejects.toThrow() + } finally { + await dispose() + } + }) + + it('append rejects non-JSON-serializable event data, naming the event type', async () => { + const { persistence, dispose } = await make() + try { + // Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt — + // otherwise a backend could pass this contract while still accepting values that + // corrupt the durable round-trip. Each value is carried in a plugin-added field on one + // user message so the contract covers the complete JSON-value boundary. + const cyclic: Record = { type: 'text', text: 'x' } + cyclic['self'] = cyclic + const badValues: unknown[] = [ + 1n, // BigInt + undefined, // dropped by JSON.stringify + Infinity, // → null + () => 0, // function + Symbol('s'), // symbol + new Map(), // exotic object + cyclic, // circular ref + ] + for (const [i, bad] of badValues.entries()) { + // A fresh session per value isolates each rejection (a rejected append + // must leave no state behind, but isolating keeps the assertion clean). + const mi = meta(`s5-${i}`) + await persistence.create(mi) + const events = [ + { + type: 'user/message', + seq: 0, + time: 1, + data: { + id: MessageId(`invalid-json-${i}`), + role: 'user', + content: [{ type: 'text', text: 'x' }], + source: { kind: 'user' }, + extra: bad, + }, + }, + ] as unknown as SessionEvent[] + await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/) + } + } finally { + await dispose() + } + }) + }) +} diff --git a/packages/deepseek/test/session-persistence.test.ts b/packages/deepseek/test/session-persistence.test.ts new file mode 100644 index 0000000..8a8566f --- /dev/null +++ b/packages/deepseek/test/session-persistence.test.ts @@ -0,0 +1,348 @@ +/** + * The Redis backend under the DeepSeek Harness's own conformance suite, plus the + * Redis-specific behaviour the shared contract does not reach: key layout, store + * identity, seek reads, atomic append, out-of-band tail repair, and TTL. + * + * Runs against a real Upstash Redis (project policy: no Redis mocks). + */ +import { afterAll, describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Context } from "@deepseek-ai/cordis"; +import SessionStore, { SessionId } from "@deepseek-ai/dsh-session"; +import LocalCredentialProvider from "@deepseek-ai/dsh-credentials-local"; +import { RedisSessionPersistence } from "../src/session-persistence.js"; +import { sessionKeys } from "../src/keys.js"; +import { scanRecords } from "../src/records.js"; +import { parseArgs } from "../src/cli.js"; +import { cleanupKeys, hasRedisCreds, testRedis, uniquePrefix } from "../src/test-support.js"; +import { runPersistenceContract, meta, oneTurnLog } from "./contract.js"; + +const prefixes: string[] = []; + +/** A fresh, empty backend on its own key prefix — the contract's `make()`. */ +async function makeBackend() { + const prefix = uniquePrefix("dsh"); + prefixes.push(prefix); + const ctx = new Context(); + await ctx.plugin(SessionStore); + const fiber = await ctx.plugin(RedisSessionPersistence, { redis: testRedis(), prefix }); + return { + ctx, + prefix, + persistence: ctx.sessionPersistence, + backend: ctx.sessionPersistence as RedisSessionPersistence, + dispose: async () => { + await fiber.dispose(); + }, + }; +} + +afterAll(async () => { + if (!hasRedisCreds) return; + const redis = testRedis(); + for (const prefix of prefixes.splice(0)) await cleanupKeys(redis, prefix); +}, 120_000); + +describe.skipIf(!hasRedisCreds)("RedisSessionPersistence", () => { + // The backend-agnostic suite the first-party JSONL and SQLite backends pass. + // Passing it unchanged is what makes this a drop-in replacement rather than a + // lookalike. + runPersistenceContract("upstash-redis", async () => { + const { persistence, dispose } = await makeBackend(); + return { persistence, dispose }; + }); + + it("has no per-session artifact, so it neither locates nor exposes raw text", async () => { + const { backend, dispose } = await makeBackend(); + try { + expect(backend.supportsRawArtifacts).toBe(false); + expect(backend.locate(meta("no-artifact"))).toBeUndefined(); + await expect(backend.readRaw(SessionId("no-artifact"))).rejects.toThrow(/raw artifacts/); + } finally { + await dispose(); + } + }); + + it("stores the log as a list whose index is the event seq", async () => { + const { backend, prefix, dispose } = await makeBackend(); + try { + const m = meta("layout"); + const log = oneTurnLog(); + await backend.create(m); + await backend.append(m.id, log); + + const redis = testRedis(); + const keys = sessionKeys(prefix); + // The seek read (`LRANGE key fromSeq -1`) is only correct because index === seq. + expect(await redis.llen(keys.events(m.id))).toBe(log.length); + const stored = await redis.lrange<{ seq: number; type: string }>(keys.events(m.id), 0, -1); + expect(stored.map((event) => event.seq)).toEqual([0, 1, 2, 3, 4, 5]); + expect(stored[3]?.type).toBe("assistant/message"); + + // Metadata is out-of-log, and the id set is what `list` reads. + const stored_meta = await redis.hgetall>(keys.meta(m.id)); + expect(stored_meta?.["revision"]).toBe(1); + expect(stored_meta?.["incarnation"]).toEqual(expect.any(String)); + expect(await redis.smembers(keys.ids)).toContain(String(m.id)); + } finally { + await dispose(); + } + }); + + it("reads only the requested suffix instead of the whole log", async () => { + const { backend, dispose } = await makeBackend(); + try { + const m = meta("seek"); + await backend.create(m); + await backend.append(m.id, oneTurnLog()); + + // `loadStoredFrom` is the optional seek hook; implementing it is what keeps + // `readFrom` proportional to the suffix rather than the log. + const suffix = await backend.loadStoredFrom(m.id, 4); + expect(suffix?.events.map((event) => event.seq)).toEqual([4, 5]); + expect(suffix?.meta.id).toBe(m.id); + await expect(backend.loadStoredFrom(SessionId("absent"), 0)).resolves.toBeUndefined(); + } finally { + await dispose(); + } + }); + + it("qualifies revisions by store, so two prefixes never compare equal", async () => { + const first = await makeBackend(); + const second = await makeBackend(); + try { + const m = meta("revision-identity"); + for (const fixture of [first, second]) { + await fixture.backend.create(m); + await fixture.backend.append(m.id, oneTurnLog()); + } + const a = await first.backend.readStoredRevision(m.id); + const b = await second.backend.readStoredRevision(m.id); + // Same session id, same local counter, different stores. + expect(a).toBeDefined(); + expect(a).not.toBe(b); + + // A revision is stable while the log is unchanged, and moves after a write. + expect(await first.backend.readStoredRevision(m.id)).toBe(a); + await first.backend.append(m.id, [ + { type: "turn/start", seq: 6, time: 7, data: { turn: 2 } }, + ]); + expect(await first.backend.readStoredRevision(m.id)).not.toBe(a); + await expect(first.backend.readStoredRevision(SessionId("absent"))).resolves.toBeUndefined(); + } finally { + await first.dispose(); + await second.dispose(); + } + }); + + it("rejects a batch that does not continue the stored log, without partially writing it", async () => { + const { backend, prefix, dispose } = await makeBackend(); + try { + const m = meta("atomic-append"); + await backend.create(m); + await backend.append(m.id, oneTurnLog()); + + // Straight at the storage hook: the coordinator's in-memory next-seq is + // bypassed, so this is the Lua guard rejecting a second writer. + await expect( + backend.appendBatch(m, [{ type: "turn/start", seq: 99, time: 1, data: { turn: 9 } }], true), + ).rejects.toThrow(/AGENTKIT_SEQ_MISMATCH/); + + const redis = testRedis(); + expect(await redis.llen(sessionKeys(prefix).events(m.id))).toBe(6); + } finally { + await dispose(); + } + }); + + it("repairs a tail damaged outside the backend", async () => { + const { backend, prefix, dispose } = await makeBackend(); + try { + const m = meta("torn"); + await backend.create(m); + await backend.append(m.id, oneTurnLog()); + + // This backend cannot produce a torn tail — every mutation is one atomic + // script — so the marker path is only reachable by damaging the key + // directly, which is exactly what makes it worth keeping implemented. + const redis = testRedis(); + const keys = sessionKeys(prefix); + await redis.rpush(keys.events(m.id), "{not valid json"); + + const prefixRead = await backend.loadStored(m.id); + expect(prefixRead?.tornMarker).toBe(6); + expect(prefixRead?.events).toHaveLength(6); + + await backend.commitRepair(m, prefixRead?.tornMarker, []); + expect(await redis.llen(keys.events(m.id))).toBe(6); + expect((await backend.loadStored(m.id))?.tornMarker).toBeUndefined(); + } finally { + await dispose(); + } + }); + + it("expires stored sessions when a ttl is configured, and drops their stale ids", async () => { + const prefix = uniquePrefix("dsh-ttl"); + prefixes.push(prefix); + const ctx = new Context(); + await ctx.plugin(SessionStore); + const fiber = await ctx.plugin(RedisSessionPersistence, { + redis: testRedis(), + prefix, + ttlSeconds: 60, + }); + const backend = ctx.sessionPersistence as RedisSessionPersistence; + try { + const m = meta("ttl"); + await backend.create(m); + await backend.append(m.id, oneTurnLog()); + + const redis = testRedis(); + const keys = sessionKeys(prefix); + expect(await redis.ttl(keys.events(m.id))).toBeGreaterThan(0); + expect(await redis.ttl(keys.meta(m.id))).toBeGreaterThan(0); + // Store identity outlives any single session. + expect(await redis.ttl(keys.store)).toBe(-1); + + // Simulate the expiry the TTL will eventually cause: listing must not + // surface a session whose keys are gone, and must forget its id. + await redis.del(keys.meta(m.id), keys.events(m.id)); + expect(await backend.listSnapshots()).toEqual([]); + expect(await redis.smembers(keys.ids)).toEqual([]); + } finally { + await fiber.dispose(); + } + }); +}); + +describe.skipIf(!hasRedisCreds)("credential resolution", () => { + // Refs the environment does not carry. That matters twice: the provider + // refuses to write a ref the launching shell already supplies, and using + // absent names proves the connection came from the credentials document + // rather than falling through to `Redis.fromEnv()`. + const URL_REF = "AGENTKIT_TEST_UPSTASH_URL"; + const TOKEN_REF = "AGENTKIT_TEST_UPSTASH_TOKEN"; + + it("connects using credentials the harness stored, not the environment", async () => { + const home = await mkdtemp(join(tmpdir(), "agentkit-dsh-")); + const prefix = uniquePrefix("dsh-cred"); + prefixes.push(prefix); + const ctx = new Context(); + await ctx.plugin(SessionStore); + await ctx.plugin(LocalCredentialProvider, { dshHome: home }); + + const credentials = ctx.get("credentials") as { + set(ref: string, value: string): Promise; + }; + await credentials.set(URL_REF, process.env.UPSTASH_REDIS_REST_URL as string); + await credentials.set(TOKEN_REF, process.env.UPSTASH_REDIS_REST_TOKEN as string); + + // No `redis` in config, and these refs are absent from the environment — so + // a working round trip can only mean `ctx.credentials` supplied the client. + const fiber = await ctx.plugin(RedisSessionPersistence, { + prefix, + urlRef: URL_REF, + tokenRef: TOKEN_REF, + }); + try { + const backend = ctx.sessionPersistence as RedisSessionPersistence; + const m = meta("via-credentials"); + await backend.create(m); + await backend.append(m.id, oneTurnLog()); + expect((await backend.load(m.id)).events).toHaveLength(6); + } finally { + await fiber.dispose(); + await rm(home, { recursive: true, force: true }); + } + }); + + it("falls back to the environment when no credentials provider is mounted", async () => { + const prefix = uniquePrefix("dsh-env"); + prefixes.push(prefix); + const ctx = new Context(); + await ctx.plugin(SessionStore); + // No credentials provider and no `redis` in config: `Redis.fromEnv()` is the + // only remaining source, which is how an embedder outside dsh runs this. + const fiber = await ctx.plugin(RedisSessionPersistence, { prefix }); + try { + const backend = ctx.sessionPersistence as RedisSessionPersistence; + const m = meta("via-env"); + await backend.create(m); + await backend.append(m.id, oneTurnLog()); + expect((await backend.load(m.id)).events).toHaveLength(6); + } finally { + await fiber.dispose(); + } + }); +}); + +describe("cli argument parsing", () => { + it("accepts the command with or without the `credentials` noun", () => { + expect(parseArgs(["credentials", "status"]).command).toBe("status"); + expect(parseArgs(["status"]).command).toBe("status"); + }); + + it("defaults the refs and overrides them from flags", () => { + expect(parseArgs(["set"]).urlRef).toBe("UPSTASH_REDIS_REST_URL"); + expect(parseArgs(["set", "--url-ref", "MY_URL"]).urlRef).toBe("MY_URL"); + }); + + it("reads values and treats a bare command as help", () => { + const args = parseArgs(["credentials", "set", "--url", "https://x", "--token", "t"]); + expect(args).toMatchObject({ command: "set", url: "https://x", token: "t" }); + expect(parseArgs([]).command).toBe("help"); + expect(parseArgs(["--help"]).command).toBe("help"); + }); + + it("rejects unknown commands, unknown flags, and value-less flags", () => { + expect(() => parseArgs(["bogus"])).toThrow(/unknown command/); + expect(() => parseArgs(["set", "--nope"])).toThrow(/unknown option/); + expect(() => parseArgs(["set", "--url"])).toThrow(/requires a value/); + expect(() => parseArgs(["set", "--url-ref", "1bad"])).toThrow(/must match/); + }); +}); + +describe("scanRecords", () => { + const turnEnd = { + type: "turn/end", + seq: 1, + time: 2, + data: { turn: 1, reason: { kind: "completed" } }, + }; + + it("accepts records that arrive parsed or as raw JSON text", () => { + // `@upstash/redis` deserializes responses by default, so both shapes are real. + const parsed = scanRecords([{ type: "turn/start", seq: 0, time: 1, data: {} }, turnEnd]); + const raw = scanRecords([ + JSON.stringify({ type: "turn/start", seq: 0, time: 1, data: {} }), + JSON.stringify(turnEnd), + ]); + expect(parsed.preserved).toHaveLength(2); + expect(raw.preserved).toHaveLength(2); + expect(parsed.tornFrom).toBeUndefined(); + }); + + it("tolerates a hole after the last turn/end and reports it as the truncation point", () => { + const { preserved, tornFrom } = scanRecords([ + { type: "turn/start", seq: 0, time: 1, data: {} }, + turnEnd, + "{not valid json", + ]); + expect(preserved).toHaveLength(2); + expect(tornFrom).toBe(2); + }); + + it("rejects a hole inside the committed region", () => { + expect(() => + scanRecords([{ type: "turn/start", seq: 0, time: 1, data: {} }, "{torn", turnEnd]), + ).toThrow(/committed/); + }); + + it("bases a suffix scan at the requested seq", () => { + const { preserved, tornFrom } = scanRecords([{ ...turnEnd, seq: 4 }], 4); + expect(preserved.map((event) => event.seq)).toEqual([4]); + expect(tornFrom).toBeUndefined(); + }); +}); diff --git a/packages/deepseek/tsconfig.json b/packages/deepseek/tsconfig.json new file mode 100644 index 0000000..bb07ae1 --- /dev/null +++ b/packages/deepseek/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/deepseek/tsup.config.ts b/packages/deepseek/tsup.config.ts new file mode 100644 index 0000000..d527ae8 --- /dev/null +++ b/packages/deepseek/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + "session-persistence": "src/session-persistence.ts", + cli: "src/cli.ts", + }, + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0447fa..1129440 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,7 @@ importers: version: 3.8.4 tsup: specifier: ^8.1.0 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.5.3 version: 5.9.3 @@ -259,6 +259,34 @@ importers: specifier: ^16.4.5 version: 16.6.1 + packages/deepseek: + dependencies: + '@deepseek-ai/schemastery': + specifier: ^3.18.1 + version: 3.18.1 + '@upstash/redis': + specifier: ^1.38.0 + version: 1.38.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: ^4.0.1 + version: 4.0.1 + '@deepseek-ai/dsh-credentials-local': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-atomic-write@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-credentials@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-home-paths@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-launch-environment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))) + '@deepseek-ai/dsh-llm': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))) + '@deepseek-ai/dsh-session': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6) + '@deepseek-ai/dsh-session-persistence': + specifier: 0.1.0-rc.6 + version: 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-session@0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))) + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + packages/eve: dependencies: '@upstash/agentkit-ai-sdk': @@ -445,6 +473,123 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@deepseek-ai/cordis@4.0.1': + resolution: {integrity: sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ^1.0.6 + '@deepseek-ai/cordis-plugin-loader': ^1.0.2 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.2': + resolution: {integrity: sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==} + + '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6': + resolution: {integrity: sha512-Ot/2aygDzWuN4ypUkDJA6MpSlGbcvzzKnWAms0lg+2WUtPZr7O0cGb83K3TEO2NoAu0LJfVxlbo8Eya3tDaFtw==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-attachment@0.1.0-rc.6': + resolution: {integrity: sha512-3P6N17NQ8jqSQGzeCs+svCIqArU8oq0YmgEAo+axN9aVuUDferWU4DLRSX59UGpmyldX4LQn81toA+c+DqMcHg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-brand': ^0.1.0-rc.6 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-brand@0.1.0-rc.6': + resolution: {integrity: sha512-E8j9Nby24qP4rfrdcfc7bpt1CHpGT3tYmycOJJkEOH4ptIdT1m2ro9nmnSd5CWYukTr64A77vjm2WGqHRI92UA==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-credentials-local@0.1.0-rc.6': + resolution: {integrity: sha512-DSDcC1i8hd3Ynyr6MTPFoEdAsxcmZSLo68IPBPvaPGHZ+rCX8XDAjghWvXrNkLtkjms0ngNVPJS3rNMRcJFq0w==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-atomic-write': ^0.1.0-rc.6 + '@deepseek-ai/dsh-credentials': ^0.1.0-rc.6 + '@deepseek-ai/dsh-home-paths': ^0.1.0-rc.6 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + '@deepseek-ai/dsh-launch-environment': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-credentials@0.1.0-rc.6': + resolution: {integrity: sha512-zyYRs3A9gxfZjZfONzJdMhM0Gzbslpha+FGYkLDRAozgPj0N7mZdE+Qflx2V4WNrCnsSjuvOW0HFBxWtSRUTUw==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-brand': ^0.1.0-rc.6 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-home-paths@0.1.0-rc.6': + resolution: {integrity: sha512-gIiUBmqB3L8inFr+hvjZv2/i6EVJOHIHCHg7bBFVhcl4HDK94+8wUCXTLGy80tcuISzIQiIaLuJq/NhSmV9Amw==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-invariants@0.1.0-rc.6': + resolution: {integrity: sha512-WfEfOi99a4cpOugRAHTBSTnesLieu3ist1q9PXDXFBHX++K1rAl9+sB7YrdnbB8LH0UOY532gS9xJUYU6w0SLw==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + + '@deepseek-ai/dsh-launch-environment@0.1.0-rc.6': + resolution: {integrity: sha512-tTRJ1464PJUDe1Em1qq0mfdgGREzGGWo3JSqP6xeYDoX+MRXVP9/ChsZ5k6VBMjARAxw0HGBf7WR6VcupHbMZg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-llm@0.1.0-rc.6': + resolution: {integrity: sha512-kuFGC8bHlzGTwlRxQhXjf3CYWl8M4NzH+EYIkrW8rri4iMc9W53xrdvkil5No/DUlMm8g1u7GdeiWYFy0TMvtA==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-attachment': ^0.1.0-rc.6 + '@deepseek-ai/dsh-brand': ^0.1.0-rc.6 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + '@deepseek-ai/dsh-timeout': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-scope@0.1.0-rc.6': + resolution: {integrity: sha512-UlDLV4syLoJinNg9imhXrSAHrdaTa5Ff8gg46rzjFJGPUOhAk3DZff0hryT5OhrBi0A5Tj92qVpg2pRVvxnUzQ==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6': + resolution: {integrity: sha512-AbNBe+IYCbZqSHqOACVdj8QTynm2HZ0cThrEuI6nGMtlWLYLx6lzZ1rgO/56Av9mIScjyTBGJAIKhEYaMTBG9g==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-brand': ^0.1.0-rc.6 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + '@deepseek-ai/dsh-session': ^0.1.0-rc.6 + '@deepseek-ai/dsh-timeout': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-session@0.1.0-rc.6': + resolution: {integrity: sha512-8tu8I6VWC7050GAUXWhcEWQw4pakALQc8TlhKr52m7Y4+kIKeNt3FBgP86PaGPBtpK0p5zUPRQNkFpzZbBdxyw==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-brand': ^0.1.0-rc.6 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + '@deepseek-ai/dsh-llm': ^0.1.0-rc.6 + '@deepseek-ai/dsh-scope': ^0.1.0-rc.6 + '@deepseek-ai/dsh-typert-protocol': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-timeout@0.1.0-rc.6': + resolution: {integrity: sha512-CUean0fAnfsJVszFEip7PsU/S26W+JfDFfsza2dCtlw8n6xlkbHA9Gjxdk2aTwqDGCgXEPkRW7mYkdJ0n6FR7w==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6': + resolution: {integrity: sha512-weWzN8r01YCkoDCAM7BsKw2YhRrD4zL8N2SAZu9hovYtXSq8xHXsP4Zh8RLYIlYcuotjyff/6hic+0TJPd14YA==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.6 + + '@deepseek-ai/schemastery@3.18.1': + resolution: {integrity: sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==} + '@emnapi/core@1.11.0': resolution: {integrity: sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==} @@ -5109,6 +5254,11 @@ packages: resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} engines: {node: '>= 6.0'} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5346,6 +5496,108 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@deepseek-ai/cordis@4.0.1': + dependencies: + '@deepseek-ai/cosmokit': 1.8.2 + '@standard-schema/spec': 1.1.0 + + '@deepseek-ai/cosmokit@1.8.2': {} + + '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-brand': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-credentials-local@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-atomic-write@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-credentials@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-home-paths@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-launch-environment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-atomic-write': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-credentials': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-home-paths': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/dsh-launch-environment': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/schemastery': 3.18.1 + chokidar: 4.0.3 + yaml: 2.9.0 + + '@deepseek-ai/dsh-credentials@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-brand': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-home-paths@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/schemastery': 3.18.1 + + '@deepseek-ai/dsh-launch-environment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-llm@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-attachment': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-brand': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/dsh-timeout': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/schemastery': 3.18.1 + + '@deepseek-ai/dsh-scope@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-session@0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-brand': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/dsh-session': 0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6) + '@deepseek-ai/dsh-timeout': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + + '@deepseek-ai/dsh-session@0.1.0-rc.6(6fd26f59436a18b115f326d6060415e6)': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-brand': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/dsh-llm': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-attachment@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-brand@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)))(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))) + '@deepseek-ai/dsh-scope': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-typert-protocol': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)) + + '@deepseek-ai/dsh-timeout@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.6(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1 + '@deepseek-ai/dsh-invariants': 0.1.0-rc.6(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/schemastery@3.18.1': + dependencies: + '@deepseek-ai/cosmokit': 1.8.2 + '@standard-schema/spec': 1.1.0 + '@emnapi/core@1.11.0': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -9306,12 +9558,13 @@ snapshots: path-data-parser: 0.1.0 points-on-curve: 0.2.0 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 postcss: 8.5.15 + yaml: 2.9.0 postcss@8.4.31: dependencies: @@ -9850,7 +10103,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.15)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -9861,7 +10114,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.15)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.62.0 source-map: 0.7.6 @@ -10108,6 +10361,8 @@ snapshots: dependencies: os-paths: 4.4.0 + yaml@2.9.0: {} + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.2(zod@4.4.3):