From e90b0d51df234531669c0794ce916f95350c6bbc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:06:48 +0800 Subject: [PATCH] feat(storage): storage hub with named backend registry and data-form mounts ctx.storage is a pure registration hub: multiple named backends stay mounted side by side, data forms (domain first) mount via the merge-extensible StorageForms map. src/backend.ts is the normative KV-facet contract; tests/contract.ts is the shared conformance suite every backend runs. Backends expose data-shape facets (kv now, an append-log facet reserved for the future session-backend migration). --- packages/storage/storage/README.md | 18 +++ packages/storage/storage/package.json | 37 +++++++ packages/storage/storage/src/backend.ts | 104 ++++++++++++++++++ packages/storage/storage/src/error.ts | 35 ++++++ packages/storage/storage/src/index.ts | 86 +++++++++++++++ packages/storage/storage/src/invariant.ts | 32 ++++++ packages/storage/storage/src/registry.ts | 58 ++++++++++ packages/storage/storage/tests/contract.ts | 102 +++++++++++++++++ .../storage/storage/tests/registry.spec.ts | 62 +++++++++++ packages/storage/storage/tsconfig.json | 21 ++++ tsconfig.base.json | 4 + tsconfig.host.json | 5 + 12 files changed, 564 insertions(+) create mode 100644 packages/storage/storage/README.md create mode 100644 packages/storage/storage/package.json create mode 100644 packages/storage/storage/src/backend.ts create mode 100644 packages/storage/storage/src/error.ts create mode 100644 packages/storage/storage/src/index.ts create mode 100644 packages/storage/storage/src/invariant.ts create mode 100644 packages/storage/storage/src/registry.ts create mode 100644 packages/storage/storage/tests/contract.ts create mode 100644 packages/storage/storage/tests/registry.spec.ts create mode 100644 packages/storage/storage/tsconfig.json diff --git a/packages/storage/storage/README.md b/packages/storage/storage/README.md new file mode 100644 index 0000000000..e15c35b368 --- /dev/null +++ b/packages/storage/storage/README.md @@ -0,0 +1,18 @@ +# @deepseek-ai/dsh-storage + +Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, data forms own semantics. Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md). + +## Shape + +- `ctx.storage.backend` — name → backend table. Multiple backends stay mounted side by side (`json`, `sqlite`); which backend serves a consumer is that consumer's configuration (the domain layer's route table), never a hub-global choice. `register()` returns the disposer; duplicate names and unknown lookups fail loud. +- `ctx.storage.mount(form, facility)` / `ctx.storage.form(form)` — data-form mounting. `StorageForms` is merge-extensible; the domain layer merges `domain` and is reached as `ctx.storage.domain`. +- A backend owns one medium (file-tree root, database file) and exposes optional data-shape **facets** — `kv` today; an append-log facet is reserved for the future session-backend migration. `src/backend.ts` is the normative contract text; `tests/contract.ts` exports the shared conformance suite every backend runs. + +## Packages in this group + +| Package | Role | +| --- | --- | +| `dsh-storage` | The hub service + backend vocabulary + shared conformance suite | +| `dsh-storage-json` | JSON backend: one unit per human-readable file, atomic whole-file rewrite | +| `dsh-storage-sqlite` | SQLite backend: one database hosting all routed units, document-per-row | +| `dsh-domain` | Domain data form (`ctx.storage.domain`): typed schemas, write chain, change events | diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json new file mode 100644 index 0000000000..600eb997ee --- /dev/null +++ b/packages/storage/storage/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-storage", + "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/storage/storage/src/backend.ts b/packages/storage/storage/src/backend.ts new file mode 100644 index 0000000000..9b029e7895 --- /dev/null +++ b/packages/storage/storage/src/backend.ts @@ -0,0 +1,104 @@ +/** + * Backend-facing vocabulary of the storage hub: a backend owns one medium + * (a file-tree root, a database file) and exposes data-shape facets over it. + * This module is the normative contract text for backend implementers; the + * shared conformance suite in `tests/contract.ts` asserts every clause. + * @module @deepseek-ai/dsh-storage/src/backend + */ + +/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */ +export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/ + +/** + * One registered backend. A backend owns exactly one medium and shares its + * lifecycle across all facets; facets are optional members — a backend that + * cannot serve a shape simply omits it, and resolution fails loud instead. + */ +export interface StorageBackend { + /** Key-value data shape; absent when this backend cannot serve it. */ + readonly kv?: KvFacet + + /** + * Drain in-flight writes across all open units and release the medium. + * Idempotent; concurrent and repeated calls resolve once teardown finishes. + * @returns resolution after the medium is released. + */ + close(): Promise +} + +/** The key-value data shape: whole-unit snapshots plus per-record durable writes. */ +export interface KvFacet { + /** + * Open one unit, creating it when the medium holds no trace of it yet + * (materialization may defer to the first write, but {@link KvUnit.loadAll} + * must immediately serve the empty shape). A version already stamped on the + * medium that differs from `descriptor.version` rejects with + * `version-mismatch`; a medium that cannot be parsed as this unit rejects + * with `malformed-medium`. Opening the same unit name twice without closing + * is a caller bug and rejects. + * @param descriptor - Static identity and shape of the unit to open. + * @returns the opened unit. + */ + open(descriptor: KvUnitDescriptor): Promise +} + +/** Static identity and shape of one KV unit, projected from its owner's spec. */ +export interface KvUnitDescriptor { + /** Unit name; must match {@link UNIT_NAME_RE}. Also the file-name / SQL-identifier segment. */ + readonly name: string + /** Unit format version; a non-negative integer stamped on the medium at first materialization. */ + readonly version: number + /** Table names; each must match {@link UNIT_NAME_RE}. */ + readonly tables: readonly string[] + /** Whether this unit carries the global singleton slot. */ + readonly hasGlobal: boolean +} + +/** + * One opened unit. Values are opaque JSON to this layer: no schema, no + * events, no domain meaning. The unit does NOT serialize concurrent writes — + * write ordering is the caller's responsibility (the domain layer runs one + * write chain per unit); the unit only guarantees that each single call is + * atomic on the medium and durable once resolved (a crash after resolution + * followed by a re-open observes the write). Any call after {@link close} + * rejects with `closed`. + */ +export interface KvUnit { + /** + * Read the full current snapshot. + * @returns every table's records keyed by table name, plus the global + * singleton (`null` when never written or not declared). + */ + loadAll(): Promise<{ tables: Record>; global: unknown | null }> + + /** + * Upsert one record durably. Overwrite semantics: an existing key is replaced. + * @param table - Declared table name. + * @param key - Record key; any string is safe (keys never reach file paths). + * @param value - Opaque JSON-serializable record. + * @returns resolution after durability. + */ + putRecord(table: string, key: string, value: unknown): Promise + + /** + * Delete one record durably. Idempotent: a missing key is a no-op. + * @param table - Declared table name. + * @param key - Record key. + * @returns resolution after durability. + */ + deleteRecord(table: string, key: string): Promise + + /** + * Write the global singleton durably. Only valid when the descriptor + * declared `hasGlobal`. + * @param value - Opaque JSON-serializable value. + * @returns resolution after durability. + */ + setGlobal(value: unknown): Promise + + /** + * Drain this unit's in-flight writes and release it. Idempotent. + * @returns resolution after the unit is released. + */ + close(): Promise +} diff --git a/packages/storage/storage/src/error.ts b/packages/storage/storage/src/error.ts new file mode 100644 index 0000000000..9e3424db55 --- /dev/null +++ b/packages/storage/storage/src/error.ts @@ -0,0 +1,35 @@ +/** + * Error vocabulary for the storage hub and its backends. + * @module @deepseek-ai/dsh-storage/src/error + */ + +/** Discriminant codes carried by every {@link StorageError}. */ +export type StorageErrorCode = + | 'backend-not-found' + | 'form-not-mounted' + | 'duplicate-backend' + | 'duplicate-mount' + | 'version-mismatch' + | 'malformed-medium' + | 'closed' + +/** + * Error thrown by the hub and by backend implementations. The `code` is the + * stable contract consumers may switch on; `message` is diagnostic prose. + */ +export class StorageError extends Error { + override readonly name = 'StorageError' + + /** + * @param code - Stable discriminant for the failure class. + * @param message - Human-readable diagnostic detail. + * @param options - Standard error options (`cause`). + */ + constructor( + readonly code: StorageErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options) + } +} diff --git a/packages/storage/storage/src/index.ts b/packages/storage/storage/src/index.ts new file mode 100644 index 0000000000..ffe8b2f3f6 --- /dev/null +++ b/packages/storage/storage/src/index.ts @@ -0,0 +1,86 @@ +/** + * Storage hub (`ctx.storage`): a named backend registry plus mounted + * data-form facilities. The hub itself performs no IO — backends own media, + * data forms (the domain layer first) own semantics. + * @module @deepseek-ai/dsh-storage + */ + +import { Context, Service } from 'cordis' +import { StorageError } from './error.ts' +import { BackendRegistry } from './registry.ts' + +export { BackendRegistry } from './registry.ts' +export { StorageError } from './error.ts' +export type { StorageErrorCode } from './error.ts' +export { UNIT_NAME_RE } from './backend.ts' +export type { StorageBackend, KvFacet, KvUnit, KvUnitDescriptor } from './backend.ts' + +declare module 'cordis' { + interface Context { + storage: Storage + } +} + +/** + * Data forms mountable on the hub, keyed by form name. Form owners extend + * this map via declaration merging (the domain layer merges + * `domain: DomainFacility`) and mount the facility in their `apply`. + */ +export interface StorageForms {} + +/** + * The storage hub service. Backends register under `backend`; data forms + * mount under their `StorageForms` key and are reached as `ctx.storage.
`. + */ +export class Storage extends Service { + /** Named backend table; multiple backends stay mounted side by side. */ + readonly backend = new BackendRegistry() + + private readonly forms = new Map() + + constructor(ctx: Context) { + super(ctx, 'storage') + } + + /** + * Mount a data-form facility on the hub. Mounting is an effect: the + * returned disposer unmounts the form. + * @param form - Form key declared in {@link StorageForms}. + * @param facility - The facility instance to expose. + * @returns the disposer that unmounts the form. + */ + mount(form: K, facility: StorageForms[K]): () => void { + if (this.forms.has(form)) { + throw new StorageError('duplicate-mount', `storage form '${String(form)}' is already mounted`) + } + this.forms.set(form, facility) + return () => { + this.forms.delete(form) + } + } + + /** + * Resolve a mounted data form. + * @param form - Form key declared in {@link StorageForms}. + * @returns the mounted facility. + */ + form(form: K): StorageForms[K] { + if (!this.forms.has(form)) { + throw new StorageError('form-not-mounted', `storage form '${String(form)}' is not mounted`) + } + return this.forms.get(form) as StorageForms[K] + } + + /** Domain data form; present once the domain layer plugin is loaded. */ + get domain(): StorageForms extends { domain: infer D } ? D : never { + return this.form('domain' as keyof StorageForms) as StorageForms extends { domain: infer D } ? D : never + } +} + +/** + * Mount the storage hub service. + * @param ctx - Plugin context. + */ +export function apply(ctx: Context) { + ctx.plugin(Storage) +} diff --git a/packages/storage/storage/src/invariant.ts b/packages/storage/storage/src/invariant.ts new file mode 100644 index 0000000000..cac811a39a --- /dev/null +++ b/packages/storage/storage/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-storage`. + * @module @deepseek-ai/dsh-storage/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-storage' + +/** Cordis companion plugin name. */ +export const name = 'storage-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the hub is a pure registration table (names → + * backends, forms → facilities) whose consistency is fully enforced at the + * call sites (duplicate/missing entries fail loud synchronously); it owns no + * event stream or mutable medium to cross-check. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/storage/storage/src/registry.ts b/packages/storage/storage/src/registry.ts new file mode 100644 index 0000000000..f8d8d41866 --- /dev/null +++ b/packages/storage/storage/src/registry.ts @@ -0,0 +1,58 @@ +/** + * Named backend registry of the storage hub. + * @module @deepseek-ai/dsh-storage/src/registry + */ + +import type { StorageBackend } from './backend.ts' +import { StorageError } from './error.ts' + +/** + * Mutable name → backend table. Multiple backends stay mounted side by side; + * which backend serves which consumer is the consumer's configuration + * (e.g. the domain layer's route table), never a hub-global choice. + */ +export class BackendRegistry { + private readonly backends = new Map() + + /** + * Register a named backend. Registration is an effect: the returned + * disposer removes the name. Disposal does NOT close the backend — the + * owning plugin closes it after unregistering. + * @param name - Backend name, e.g. `json` or `sqlite`. + * @param backend - The backend instance. + * @returns the disposer that unregisters the name. + */ + register(name: string, backend: StorageBackend): () => void { + if (this.backends.has(name)) { + throw new StorageError('duplicate-backend', `storage backend '${name}' is already registered`) + } + this.backends.set(name, backend) + return () => { + this.backends.delete(name) + } + } + + /** + * Resolve a backend by name. + * @param name - Registered backend name. + * @returns the backend. + */ + get(name: string): StorageBackend { + const backend = this.backends.get(name) + if (!backend) { + throw new StorageError( + 'backend-not-found', + `storage backend '${name}' is not registered (registered: ${[...this.backends.keys()].join(', ') || 'none'})`, + ) + } + return backend + } + + /** + * Registered backend names, for diagnostics. + * @returns a snapshot array of names. + */ + names(): string[] { + return [...this.backends.keys()] + } +} diff --git a/packages/storage/storage/tests/contract.ts b/packages/storage/storage/tests/contract.ts new file mode 100644 index 0000000000..d173918dce --- /dev/null +++ b/packages/storage/storage/tests/contract.ts @@ -0,0 +1,102 @@ +/** + * Shared KV-backend conformance suite. Each backend's spec file calls + * {@link runKvBackendContract} with a factory bound to its own medium; the + * suite asserts every clause of the `src/backend.ts` contract so both + * backends are held to identical semantics. + * @module + */ + +import { describe, expect, it } from 'vitest' +import type { KvUnitDescriptor, StorageBackend } from '../src/backend.ts' + +/** One conformance run: a fresh backend plus a way to reopen the same medium (crash simulation). */ +export interface KvBackendContractHarness { + /** The backend under test, freshly created over an empty medium. */ + backend: StorageBackend + /** Open a NEW backend instance over the SAME medium, as after a process restart. */ + reopen(): Promise +} + +const DESCRIPTOR: KvUnitDescriptor = { + name: 'contract_unit', + version: 3, + tables: ['alpha', 'beta'], + hasGlobal: true, +} + +/** + * Run the shared conformance suite against one backend implementation. + * @param label - Suite label, e.g. `json` / `sqlite`. + * @param create - Factory producing a fresh harness per test. + */ +export function runKvBackendContract(label: string, create: () => Promise) { + describe(`kv backend contract: ${label}`, () => { + it('opens a missing unit as empty and serves loadAll immediately', async () => { + const { backend } = await create() + const unit = await backend.kv!.open(DESCRIPTOR) + const snapshot = await unit.loadAll() + expect(snapshot.tables).toEqual({ alpha: {}, beta: {} }) + expect(snapshot.global).toBeNull() + await backend.close() + }) + + it('round-trips records and global durably across reopen', async () => { + const harness = await create() + const unit = await harness.backend.kv!.open(DESCRIPTOR) + await unit.putRecord('alpha', 'k1', { n: 1 }) + await unit.putRecord('alpha', 'k2', { n: 2 }) + await unit.putRecord('beta', 'weird key / with:stuff', { ok: true }) + await unit.setGlobal({ counter: 7 }) + await harness.backend.close() + + const reopened = await harness.reopen() + const unit2 = await reopened.kv!.open(DESCRIPTOR) + const snapshot = await unit2.loadAll() + expect(snapshot.tables['alpha']).toEqual({ k1: { n: 1 }, k2: { n: 2 } }) + expect(snapshot.tables['beta']).toEqual({ 'weird key / with:stuff': { ok: true } }) + expect(snapshot.global).toEqual({ counter: 7 }) + await reopened.close() + }) + + it('putRecord overwrites and deleteRecord is idempotent', async () => { + const { backend } = await create() + const unit = await backend.kv!.open(DESCRIPTOR) + await unit.putRecord('alpha', 'k', { v: 'old' }) + await unit.putRecord('alpha', 'k', { v: 'new' }) + await unit.deleteRecord('alpha', 'k') + await unit.deleteRecord('alpha', 'k') + await unit.deleteRecord('alpha', 'never-existed') + const snapshot = await unit.loadAll() + expect(snapshot.tables['alpha']).toEqual({}) + await backend.close() + }) + + it('rejects a version mismatch on reopen without touching the data', async () => { + const harness = await create() + const unit = await harness.backend.kv!.open(DESCRIPTOR) + await unit.putRecord('alpha', 'k', { v: 1 }) + await harness.backend.close() + + const reopened = await harness.reopen() + await expect(reopened.kv!.open({ ...DESCRIPTOR, version: 4 })).rejects.toMatchObject({ + name: 'StorageError', + code: 'version-mismatch', + }) + // Original version still opens and still holds the data. + const unit2 = await reopened.kv!.open(DESCRIPTOR) + expect((await unit2.loadAll()).tables['alpha']).toEqual({ k: { v: 1 } }) + await reopened.close() + }) + + it('rejects operations after unit close, and close is idempotent', async () => { + const { backend } = await create() + const unit = await backend.kv!.open(DESCRIPTOR) + await unit.close() + await unit.close() + await expect(unit.putRecord('alpha', 'k', {})).rejects.toMatchObject({ code: 'closed' }) + await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' }) + await backend.close() + await backend.close() + }) + }) +} diff --git a/packages/storage/storage/tests/registry.spec.ts b/packages/storage/storage/tests/registry.spec.ts new file mode 100644 index 0000000000..f5cdc6bac2 --- /dev/null +++ b/packages/storage/storage/tests/registry.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { BackendRegistry, Storage, apply } from '../src/index.ts' +import type { StorageBackend } from '../src/index.ts' + +const fakeBackend = (): StorageBackend => ({ close: async () => {} }) + +describe('BackendRegistry', () => { + it('registers, resolves, and disposes names', () => { + const registry = new BackendRegistry() + const backend = fakeBackend() + const dispose = registry.register('json', backend) + expect(registry.get('json')).toBe(backend) + expect(registry.names()).toEqual(['json']) + dispose() + expect(registry.names()).toEqual([]) + expect(() => registry.get('json')).toThrowMatchingObject({ code: 'backend-not-found' }) + }) + + it('rejects duplicate names', () => { + const registry = new BackendRegistry() + registry.register('json', fakeBackend()) + expect(() => registry.register('json', fakeBackend())).toThrowMatchingObject({ code: 'duplicate-backend' }) + }) +}) + +describe('Storage service', () => { + it('mounts on the context and exposes registry plus form mounting', async () => { + const ctx = new Context() + await ctx.plugin({ apply }) + expect(ctx.storage).toBeInstanceOf(Storage) + + const facility = { marker: true } + const dispose = ctx.storage.mount('domain' as never, facility as never) + expect(ctx.storage.form('domain' as never)).toBe(facility) + expect(() => ctx.storage.mount('domain' as never, facility as never)).toThrowMatchingObject({ + code: 'duplicate-mount', + }) + dispose() + expect(() => ctx.storage.form('domain' as never)).toThrowMatchingObject({ code: 'form-not-mounted' }) + }) +}) + +expect.extend({ + toThrowMatchingObject(received: () => unknown, expected: object) { + try { + received() + } catch (error) { + const pass = Object.entries(expected).every( + (entry) => (error as Record)[entry[0]] === entry[1], + ) + return { pass, message: () => `expected thrown error to match ${JSON.stringify(expected)}, got ${String(error)}` } + } + return { pass: false, message: () => 'expected function to throw' } + }, +}) + +declare module 'vitest' { + interface Assertion { + toThrowMatchingObject(expected: object): T + } +} diff --git a/packages/storage/storage/tsconfig.json b/packages/storage/storage/tsconfig.json new file mode 100644 index 0000000000..9966c8ca8a --- /dev/null +++ b/packages/storage/storage/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 56ab3ffef3..fc416f7484 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,8 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/storage/*/src/invariant.ts", + "./packages/workspace/*/src/invariant.ts", "./packages/sdk/*/src/invariant.ts", "./packages/ui/*/src/invariant.ts", "./packages/examples/*/src/invariant.ts", @@ -142,6 +144,8 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/storage/*/src", + "./packages/workspace/*/src", "./packages/sdk/*/src", "./packages/ui/*/src", "./packages/examples/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..8dd8d4b7be 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,11 @@ { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/storage/storage" }, + { "path": "./packages/storage/storage-json" }, + { "path": "./packages/storage/storage-sqlite" }, + { "path": "./packages/storage/domain" }, + { "path": "./packages/workspace/workspace" }, { "path": "./packages/session-title/session-title" }, { "path": "./packages/session-title/session-title-llm" }, { "path": "./packages/session-title/session-title-first-message-llm" },