Goal
Two durable-store gaps in the matrix row "Durable stores" close. A hand-written Cloudflare Worker can import KVStore from @lousho/build-ai-agent/kv (today no entry point exports it, yet docs/deployment.md points users at it). A Node user gets fileStore(dir), a ready-made AgentStore of plain inspectable files, instead of assembling three classes and a StorageService by hand.
Current state
src/deploy/kvStore.ts:105 defines export class KVStore implements Required<AgentStore> with new KVStore(kv: KVBinding, { prefix?, ttl?, historyLimit? }). KVStoreOptions is at line 24. KVCheckpointStore, KVBinding, KVPutOptions and DEFAULT_KV_KEY_PREFIX live in src/deploy/kvCheckpointStore.ts (lines 72-99); CHECKPOINT_KV_BINDING in src/deploy/checkpointBinding.ts:16. None is reachable from src/index.ts or any package.json exports entry. Only src/deploy/runtime.worker.ts:70 imports KVStore.
docs/deployment.md:202-205 says: "KVStore(kvBinding, { prefix?, ttl? }) ... is not exported from any entry point of the package, so importing it in a hand-written Worker is not supported yet." docs/deployment.md:305-306 and docs/durable-execution.md:202 mention KVCheckpointStore / KVStore; docs/sessions.md:228-232 has the store table with the Cloudflare row "- | KVCheckpointStore | -".
src/deploy/kvStore.ts:19 imports assertSessionId from ../session/sessionStore, and that module imports node:fs/promises, node:path and node:crypto (src/session/sessionStore.ts:7-9). I bundled src/deploy/kvStore.ts with esbuild (platform: 'browser', external: ['ai']) and it fails with Could not resolve "node:fs/promises", "node:path", "node:crypto". The generated Worker only works because workerNodeShimPlugin() (src/deploy/bundle.ts:103) redirects those imports for session/sessionStore. A user's own Worker has no such shim, so exporting KVStore as it is would not bundle. src/execution/checkpoint.ts:6-7 also has non-type imports of ../providers and ../storage (Message, StorageService) that esbuild reported as retained imports; see Notes.
package.json exports has ./sqlite (dist storage/sqlite/index) and no ./kv. tsup.config.ts:41 lists 'storage/sqlite/index' as an entry; there is no deploy/... entry.
- File stores today:
FileSessionStore(dir) (src/session/sessionStore.ts:106, root export via src/session/index.ts:12), LocalStorageCheckpointStore(storage) (src/execution/checkpoint.ts:238), StorageServiceApprovalStore(storage) (src/execution/ApprovalGate.ts:171). The last two need a StorageService(databaseIdHash, schema, fs, path, rootPath) (src/storage/StorageService.ts:53) and use .lock files: a crashed writer blocks others for 5 seconds (50 attempts x 100 ms) and then they throw. docs/sessions.md:236-257 tells users to combine them by hand with an undefined storage. Agent Forge has its own FileCheckpointStore (apps/agent-forge/server/checkpointStore.ts).
src/storage/agentStore.ts holds AgentStore and memoryStore(); src/index.ts:56 exports memoryStore, AgentStore, MemoryStoreOptions from it.
Scope
In:
@lousho/build-ai-agent/kv subpath.
- New barrel
src/deploy/kv.ts exporting KVStore, KVCheckpointStore, CHECKPOINT_KV_BINDING, and the types KVStoreOptions, KVBinding, KVPutOptions.
tsup.config.ts: add entry 'deploy/kv': 'src/deploy/kv.ts'. package.json exports: add "./kv" with types ./dist/deploy/kv.d.ts, import ./dist/deploy/kv.mjs, require ./dist/deploy/kv.js. This is additive, so the ticket is not owner-decision.
- Make the barrel Worker-safe: move
assertSessionId and its pattern into a new Node-free module src/session/sessionId.ts and re-export it from src/session/sessionStore.ts so every existing import keeps working. src/deploy/kvStore.ts imports from the new module and uses import type for SessionStore. Change the two value imports in src/execution/checkpoint.ts:6-7 to import type if they are used only as types.
- New test
src/deploy/kv.test.ts: (a) esbuild bundles src/deploy/kv.ts with platform: 'browser', external: ['ai', 'zod', '@opentelemetry/api'], metafile: true and no node:* external, the build succeeds, and no input in the metafile imports a node: path; (b) the barrel exports the names above. This is the regression guard for the Node-free graph. esbuild is a devDependency.
fileStore(dir, options?).
src/storage/fileStore.ts: export function fileStore(dir: string, options?: { historyLimit?: number }): Required<AgentStore>. Layout under dir: sessions/<id>.json (reuse FileSessionStore(join(dir, 'sessions'))), checkpoints/<id>.json, checkpoint-history/<id>.json, approvals/<id>.json.
- Checkpoints: a
FileCheckpointStore class in the same file (not exported from the root; Agent Forge keeps its own). It uses the helpers appendToRing, newestFirst, resolveHistoryLimit, toHistoryEntry from src/execution/checkpoint.ts, writes atomically (temp file, then rename, like FileSessionStore.save), validates the session id with assertSessionId, creates directories on first write, and treats a corrupt history file as empty (as apps/agent-forge/server/checkpointStore.ts does).
- Approvals: a
FileApprovalStore implements ApprovalStore in the same file. save writes approvals/<id>.json atomically. resolve(id) claims the record by renaming it to a unique temp name, reads it, deletes it, and returns null when the rename fails with ENOENT, so two processes resolving one approval cannot both get it. Validate id against /^[A-Za-z0-9_-]{1,128}$/ first: approval ids reach resolve from HTTP input and become file names.
- No
.lock files and no StorageService.
- Export
fileStore from the root: src/index.ts, next to the memoryStore line 56. FileSessionStore is a root export already, so Node imports in the root are not new.
- Docs.
docs/deployment.md: replace the paragraph at lines 202-205 with the import import { KVStore } from '@lousho/build-ai-agent/kv' and a Worker example (createAgent({ provider, store: new KVStore(env.AGENT_KV) }), with env typed using KVBinding); keep the key table.
docs/sessions.md: replace the hand-built file store snippet (lines 236-257) with fileStore('./.lousho'), change the "Files" row to fileStore(dir), and the Cloudflare KV row to KVStore from /kv.
docs/durable-execution.md:202: add fileStore(dir) to the history table (history yes, option fileStore(dir, { historyLimit })) and the import path of KVStore.
docs/api-overview.md:51-52: add fileStore() and KVStore (from /kv) beside SqliteStore.
- Every snippet passes
npm run docs:verify-snippets. Do not rename or add ##/###/#### headings in existing pages.
- CHANGELOG under
## [Unreleased]: Added @lousho/build-ai-agent/kv (KVStore, KVCheckpointStore) and fileStore().
Out:
- A D1 or Durable Object store, and a Redis store.
- Making Agent Forge use
fileStore (its FileCheckpointStore uses .lousho/agents/<agentId>/checkpoints).
- Changing how the generated Worker builds its store (
workerStore() in src/deploy/runtime.worker.ts:118).
- Docs-site changes: the pages already exist; no new page.
Acceptance criteria
Live test
None: this ticket spends nothing.
Dependencies
None. Conflicts: package.json and tsup.config.ts are also touched by R4 (the files list) and by A1 to A3 (new subpaths); keep the exports edit to one added block. docs/sessions.md and docs/deployment.md are also edited by tickets G2a and G6; keep edits to the lines named above.
Notes for the implementer
- Decision: the subpath is
./kv (the name in the plan), and fileStore is a root export, not a subpath, because FileSessionStore already pulls node:fs into the root. SqliteStore is not the precedent: it needs node:sqlite, which the root must never load.
- Decision: no new dependency, and no
StorageService in fileStore: its lock files go stale after a crash and block for 5 seconds.
src/session/sessionStore.ts must keep exporting assertSessionId (used by src/cli/chatRepl.ts:15, src/server/fetchRoutes.ts:19, src/session/AgentSession.ts:17). Re-export it; do not change those imports.
NODE_SHIMMED_IMPORTERS in src/deploy/bundle.ts:103 still lists session/sessionStore; leave it, the generated Worker still bundles AgentSession, which imports it.
tsconfig.json:18 has isolatedModules, so esbuild keeps an import unless it is import type. If the browser bundle of kv.ts still reaches ../providers or ../storage, change those lines to import type. If anything else with a node: import shows up, fix it at the source; do not add a shim.
fallow flags dead exports: export only what the root barrel and tests use.
- Windows:
rename over an existing file works in Node on the same volume (FileSessionStore already relies on it). The resolve claim renames to a new unique name, which is safe.
Round 2 ticket R2. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; put Closes #<this issue> in it.
Goal
Two durable-store gaps in the matrix row "Durable stores" close. A hand-written Cloudflare Worker can import
KVStorefrom@lousho/build-ai-agent/kv(today no entry point exports it, yetdocs/deployment.mdpoints users at it). A Node user getsfileStore(dir), a ready-madeAgentStoreof plain inspectable files, instead of assembling three classes and aStorageServiceby hand.Current state
src/deploy/kvStore.ts:105definesexport class KVStore implements Required<AgentStore>withnew KVStore(kv: KVBinding, { prefix?, ttl?, historyLimit? }).KVStoreOptionsis at line 24.KVCheckpointStore,KVBinding,KVPutOptionsandDEFAULT_KV_KEY_PREFIXlive insrc/deploy/kvCheckpointStore.ts(lines 72-99);CHECKPOINT_KV_BINDINGinsrc/deploy/checkpointBinding.ts:16. None is reachable fromsrc/index.tsor anypackage.jsonexportsentry. Onlysrc/deploy/runtime.worker.ts:70importsKVStore.docs/deployment.md:202-205says: "KVStore(kvBinding, { prefix?, ttl? })... is not exported from any entry point of the package, so importing it in a hand-written Worker is not supported yet."docs/deployment.md:305-306anddocs/durable-execution.md:202mentionKVCheckpointStore/KVStore;docs/sessions.md:228-232has the store table with the Cloudflare row "- |KVCheckpointStore| -".src/deploy/kvStore.ts:19importsassertSessionIdfrom../session/sessionStore, and that module importsnode:fs/promises,node:pathandnode:crypto(src/session/sessionStore.ts:7-9). I bundledsrc/deploy/kvStore.tswith esbuild (platform: 'browser',external: ['ai']) and it fails withCould not resolve "node:fs/promises","node:path","node:crypto". The generated Worker only works becauseworkerNodeShimPlugin()(src/deploy/bundle.ts:103) redirects those imports forsession/sessionStore. A user's own Worker has no such shim, so exportingKVStoreas it is would not bundle.src/execution/checkpoint.ts:6-7also has non-type imports of../providersand../storage(Message,StorageService) that esbuild reported as retained imports; see Notes.package.jsonexportshas./sqlite(diststorage/sqlite/index) and no./kv.tsup.config.ts:41lists'storage/sqlite/index'as an entry; there is nodeploy/...entry.FileSessionStore(dir)(src/session/sessionStore.ts:106, root export viasrc/session/index.ts:12),LocalStorageCheckpointStore(storage)(src/execution/checkpoint.ts:238),StorageServiceApprovalStore(storage)(src/execution/ApprovalGate.ts:171). The last two need aStorageService(databaseIdHash, schema, fs, path, rootPath)(src/storage/StorageService.ts:53) and use.lockfiles: a crashed writer blocks others for 5 seconds (50 attempts x 100 ms) and then they throw.docs/sessions.md:236-257tells users to combine them by hand with an undefinedstorage. Agent Forge has its ownFileCheckpointStore(apps/agent-forge/server/checkpointStore.ts).src/storage/agentStore.tsholdsAgentStoreandmemoryStore();src/index.ts:56exportsmemoryStore,AgentStore,MemoryStoreOptionsfrom it.Scope
In:
@lousho/build-ai-agent/kvsubpath.src/deploy/kv.tsexportingKVStore,KVCheckpointStore,CHECKPOINT_KV_BINDING, and the typesKVStoreOptions,KVBinding,KVPutOptions.tsup.config.ts: add entry'deploy/kv': 'src/deploy/kv.ts'.package.jsonexports: add"./kv"withtypes ./dist/deploy/kv.d.ts,import ./dist/deploy/kv.mjs,require ./dist/deploy/kv.js. This is additive, so the ticket is notowner-decision.assertSessionIdand its pattern into a new Node-free modulesrc/session/sessionId.tsand re-export it fromsrc/session/sessionStore.tsso every existing import keeps working.src/deploy/kvStore.tsimports from the new module and usesimport typeforSessionStore. Change the two value imports insrc/execution/checkpoint.ts:6-7toimport typeif they are used only as types.src/deploy/kv.test.ts: (a) esbuild bundlessrc/deploy/kv.tswithplatform: 'browser',external: ['ai', 'zod', '@opentelemetry/api'],metafile: trueand nonode:*external, the build succeeds, and no input in the metafile imports anode:path; (b) the barrel exports the names above. This is the regression guard for the Node-free graph.esbuildis a devDependency.fileStore(dir, options?).src/storage/fileStore.ts:export function fileStore(dir: string, options?: { historyLimit?: number }): Required<AgentStore>. Layout underdir:sessions/<id>.json(reuseFileSessionStore(join(dir, 'sessions'))),checkpoints/<id>.json,checkpoint-history/<id>.json,approvals/<id>.json.FileCheckpointStoreclass in the same file (not exported from the root; Agent Forge keeps its own). It uses the helpersappendToRing,newestFirst,resolveHistoryLimit,toHistoryEntryfromsrc/execution/checkpoint.ts, writes atomically (temp file, thenrename, likeFileSessionStore.save), validates the session id withassertSessionId, creates directories on first write, and treats a corrupt history file as empty (asapps/agent-forge/server/checkpointStore.tsdoes).FileApprovalStore implements ApprovalStorein the same file.savewritesapprovals/<id>.jsonatomically.resolve(id)claims the record by renaming it to a unique temp name, reads it, deletes it, and returnsnullwhen the rename fails withENOENT, so two processes resolving one approval cannot both get it. Validateidagainst/^[A-Za-z0-9_-]{1,128}$/first: approval ids reachresolvefrom HTTP input and become file names..lockfiles and noStorageService.fileStorefrom the root:src/index.ts, next to thememoryStoreline 56.FileSessionStoreis a root export already, so Node imports in the root are not new.docs/deployment.md: replace the paragraph at lines 202-205 with the importimport { KVStore } from '@lousho/build-ai-agent/kv'and a Worker example (createAgent({ provider, store: new KVStore(env.AGENT_KV) }), withenvtyped usingKVBinding); keep the key table.docs/sessions.md: replace the hand-built file store snippet (lines 236-257) withfileStore('./.lousho'), change the "Files" row tofileStore(dir), and the Cloudflare KV row toKVStorefrom/kv.docs/durable-execution.md:202: addfileStore(dir)to the history table (history yes, optionfileStore(dir, { historyLimit })) and the import path ofKVStore.docs/api-overview.md:51-52: addfileStore()andKVStore(from/kv) besideSqliteStore.npm run docs:verify-snippets. Do not rename or add##/###/####headings in existing pages.## [Unreleased]: Added@lousho/build-ai-agent/kv(KVStore,KVCheckpointStore) andfileStore().Out:
fileStore(itsFileCheckpointStoreuses.lousho/agents/<agentId>/checkpoints).workerStore()insrc/deploy/runtime.worker.ts:118).Acceptance criteria
@lousho/build-ai-agent/kvresolves in ESM and CJS and its.d.tsexportsKVStore,KVCheckpointStore,CHECKPOINT_KV_BINDING,KVStoreOptions,KVBinding,KVPutOptions.npm run pack-smokepasses (it loads everyexportssubpath).src/deploy/kv.test.tsproves the barrel bundles for the browser platform with nonode:import (the same bundle ofkvStore.tsfails onmaintoday).src/session/sessionId.tsexists andsrc/session/sessionStore.tsstill exportsassertSessionId;npx vitest run src/session src/deploypasses.src/storage/fileStore.test.tscovers, in a temp directory: session round-trip including aUint8Arrayfile part; checkpoint save/load/delete andhistory()newest first withhistoryLimithonored (0keeps none;delete(id, { keepHistory: true })keeps the ring); approvalsavethenresolvereturns the record once and a secondresolvereturnsnull; two concurrentresolvecalls on one id give exactly one record; a session id and an approval id containing../are rejected; a truncated history file reads as empty; the files appear at the layout above.fileStore()runs through the existingsrc/execution/__fixtures__/checkpointHistoryContract.ts(seesrc/execution/checkpointHistory.contract.test.tsfor how a store is plugged in).createAgent({ provider: mockModel(...), store: fileStore(dir) })test: a session survives a secondcreateAgent()on the samedir; an approval-gated run paused on the first agent resumes on the second (model it onsrc/createAgentStore.test.ts).npm run docs:verify-snippets -- --skip-buildandnpm run docs:llms:checkpass afternpm run docs:llms.Live test
None: this ticket spends nothing.
Dependencies
None. Conflicts:
package.jsonandtsup.config.tsare also touched by R4 (thefileslist) and by A1 to A3 (new subpaths); keep theexportsedit to one added block.docs/sessions.mdanddocs/deployment.mdare also edited by tickets G2a and G6; keep edits to the lines named above.Notes for the implementer
./kv(the name in the plan), andfileStoreis a root export, not a subpath, becauseFileSessionStorealready pullsnode:fsinto the root.SqliteStoreis not the precedent: it needsnode:sqlite, which the root must never load.StorageServiceinfileStore: its lock files go stale after a crash and block for 5 seconds.src/session/sessionStore.tsmust keep exportingassertSessionId(used bysrc/cli/chatRepl.ts:15,src/server/fetchRoutes.ts:19,src/session/AgentSession.ts:17). Re-export it; do not change those imports.NODE_SHIMMED_IMPORTERSinsrc/deploy/bundle.ts:103still listssession/sessionStore; leave it, the generated Worker still bundlesAgentSession, which imports it.tsconfig.json:18hasisolatedModules, so esbuild keeps an import unless it isimport type. If the browser bundle ofkv.tsstill reaches../providersor../storage, change those lines toimport type. If anything else with anode:import shows up, fix it at the source; do not add a shim.fallowflags dead exports: export only what the root barrel and tests use.renameover an existing file works in Node on the same volume (FileSessionStorealready relies on it). Theresolveclaim renames to a new unique name, which is safe.Round 2 ticket
R2. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; putCloses #<this issue>in it.