ds4-context-engine 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,7 +16,7 @@ bounded active context with provenance
16
16
  Pi provider
17
17
  ```
18
18
 
19
- > **Project status:** The coordinated `0.3.10` release adds opt-in BPE token estimation, estimator-specific provider calibration and bounded auto-tuning of context category ceilings. `chars-v1` and disabled auto-tuning remain the defaults. The bounded compaction controls from `0.3.9` remain in place; canonical history, SQLite schema 16 and runtime contracts are unchanged. Pi remains pinned to `0.84.3`. See the [0.3.10 release record](docs/releases/0.3.10.md) and [model-awareness validation](docs/MODEL_AWARENESS.md).
19
+ > **Project status:** The coordinated `0.4.0` release adds `storage.scope: "agent" | "project"`, now defaulting to per-project SQLite projections with shared token calibration in the agent database (opt out with `storage.scope: "agent"`). The opt-in BPE estimation and bounded auto-tuning from `0.3.10` keep `chars-v1` and disabled auto-tuning as their defaults. The bounded compaction controls from `0.3.9` remain in place; canonical history, SQLite schema 16 and runtime contracts are unchanged. Pi remains pinned to `0.84.3`. See the [0.4.0 release record](docs/releases/0.4.0.md), [ADR 064](docs/ADR/064-per-project-databases-with-shared-calibration.md) and [model-awareness validation](docs/MODEL_AWARENESS.md).
20
20
 
21
21
  **Current compaction defaults:** `compaction.directUpdate=true`, `compaction.inputBudget="context"`, `compaction.segmentTargetTokens=30000`, `compaction.maxRequestInputTokens=64000`, `compaction.maxOperationInputTokens=2000000`, `compaction.maxConcurrentSegments=2`. Every DS4 provider attempt is bounded by the effective request limit, and the operation limit includes retries; `inputBudget="summary"` remains an explicit throughput-oriented opt-in. Existing compaction/master switches still apply. See [latency controls and compatibility](docs/COMPACTION.md#latency-controls). No real-provider speedup is claimed from mock tests. The five optional editing/reading/artifact/job features introduced in `0.3.4` remain default-off.
22
22
 
@@ -385,6 +385,7 @@ The following example shows the main configuration groups. Omitted values use th
385
385
  "logLevel": "info"
386
386
  },
387
387
  "storage": {
388
+ "scope": "project",
388
389
  "databasePath": "ds4-context/context.db",
389
390
  "busyTimeoutMs": 5000,
390
391
  "writeRetryTimeoutMs": 30000,
@@ -514,6 +515,7 @@ scripts package and release-readiness checks
514
515
  - [Roadmap 0.2.0](docs/ROADMAP_0.2.0.md)
515
516
  - [Release process](docs/RELEASING.md)
516
517
  - [0.2.0 release readiness](docs/RELEASE_READINESS_0.2.0.md)
518
+ - [0.4.0 release notes](docs/releases/0.4.0.md)
517
519
  - [0.3.10 release notes](docs/releases/0.3.10.md)
518
520
  - [0.3.9 release notes](docs/releases/0.3.9.md)
519
521
  - [0.3.8 release notes](docs/releases/0.3.8.md)
@@ -542,7 +544,7 @@ scripts package and release-readiness checks
542
544
 
543
545
  The original M0–M13 roadmap is complete. `ds4-context-core` contains the compiled runtime-neutral implementation. M14 context-quality metrics, M15 rich symbol indexing, M16 hybrid semantic retrieval, M17 cross-session project memory, M18 learned-ranking shadow evaluation, M19's runtime adapter/conformance kit, and M20 opt-in local KV eligibility/replay are implemented on `main`. Learned active ranking remains promotion-gated, Pi reports local KV as unsupported, and static ranking/native completion stay authoritative on every failure.
544
546
 
545
- The [0.2.0 roadmap](docs/ROADMAP_0.2.0.md) is complete. The stable 0.3 line carries forward the [context persistence tool](docs/CONTEXT_PERSISTENCE_TOOL.md), privacy-safe [compaction](docs/COMPACTION.md), bounded persisted manifests, cooperative client leases and recoverable offline maintenance. Version 0.3.10 adds opt-in BPE estimation and bounded model-budget auto-tuning while retaining the `chars-v1` default. Version 0.3.9 extends the bounded compaction updates, summary input headroom, concurrent segments and phase timings introduced in 0.3.5 with per-request and cumulative operation input limits; 0.3.8 adds indexed FTS key deletion without changing search results. The opt-in [anchored editing](docs/ANCHORED_EDITING.md) and [portable agent tools](docs/PORTABLE_AGENT_TOOLS.md) from 0.3.4 remain default-off, without backend rewind, forced sampling or operational KV integration. Confirmation, provenance, Pi fallback and canonical/configuration/SQLite/runtime contracts remain unchanged. The [0.2 readiness record](docs/RELEASE_READINESS_0.2.0.md) remains the compatibility baseline; the lexical planner stays available as the deterministic fallback.
547
+ The [0.2.0 roadmap](docs/ROADMAP_0.2.0.md) is complete. The stable 0.3 line carries forward the [context persistence tool](docs/CONTEXT_PERSISTENCE_TOOL.md), privacy-safe [compaction](docs/COMPACTION.md), bounded persisted manifests, cooperative client leases and recoverable offline maintenance. Version 0.4.0 introduces `storage.scope` (`"agent" | "project"`, default `"project"`): one rebuildable SQLite projection per trusted canonical project root, with token calibration shared in the agent database; `storage.scope: "agent"` restores the previous single-file layout. Version 0.3.10 adds opt-in BPE estimation and bounded model-budget auto-tuning while retaining the `chars-v1` default. Version 0.3.9 extends the bounded compaction updates, summary input headroom, concurrent segments and phase timings introduced in 0.3.5 with per-request and cumulative operation input limits; 0.3.8 adds indexed FTS key deletion without changing search results. The opt-in [anchored editing](docs/ANCHORED_EDITING.md) and [portable agent tools](docs/PORTABLE_AGENT_TOOLS.md) from 0.3.4 remain default-off, without backend rewind, forced sampling or operational KV integration. Confirmation, provenance, Pi fallback and canonical/configuration/SQLite/runtime contracts remain unchanged. The [0.2 readiness record](docs/RELEASE_READINESS_0.2.0.md) remains the compatibility baseline; the lexical planner stays available as the deterministic fallback.
546
548
 
547
549
  ## Contributing
548
550
 
@@ -0,0 +1,88 @@
1
+ # 064 — Per-project databases with shared token calibration
2
+
3
+ **Date:** 2026-09-25
4
+ **Status:** Accepted
5
+ **Related:** [002](002-pi-jsonl-canonical-sqlite-rebuildable.md), [058](058-bounded-manifest-storage.md), [063](063-fts-rowid-key-mappings.md)
6
+
7
+ ## Context
8
+
9
+ The storage plan decided **D1**: keep one shared SQLite projection at
10
+ `~/.pi/agent/ds4-context/context.db` for every Pi session and explicitly avoid
11
+ per-session *or per-project* databases. That projection is deliberately
12
+ derived, rebuildable and disposable.
13
+
14
+ The session index (`entries`, `entries_fts`) is the table that actually grows
15
+ with total indexed history, and automatic eviction remains deferred until an
16
+ on-demand rehydration path is verified. A single file therefore also means a
17
+ single growth boundary, a single write lock and a single point of physical
18
+ reset shared by unrelated projects. Content is already partitioned logically
19
+ by `sessions.project_path`, `project_states`, `project_files` and
20
+ `project_memory_sessions`; the file boundary was the only thing missing.
21
+
22
+ The one piece of genuinely global learning is token calibration. Schema v10's
23
+ `token_calibration` is keyed by exact `provider + model + estimator_version`
24
+ and has **no project column**: a naive per-project split would restart
25
+ calibration for every project (minimum 3 samples, 8 accepted for the opt-in
26
+ `autoTune` expansion), weakening exactly the BPE/auto-tuning path it should
27
+ protect.
28
+
29
+ ## Decision
30
+
31
+ Add `storage.scope: "agent" | "project"` (default `"project"`) to the storage
32
+ configuration:
33
+
34
+ - `agent` keeps the previous shared-database behavior and remains selectable.
35
+ - `project` derives one database per trusted canonical project root:
36
+ `projects/<sha256(canonicalRoot)[0..32]>.db`, next to the configured agent
37
+ database. Untrusted projects, broad roots (home directory, filesystem root)
38
+ and any resolution failure fall back to the agent database.
39
+
40
+ Both files receive the **same schema and the same migrations**; there is no
41
+ schema fork and migrations 1–15 are untouched. The split changes only which
42
+ repository each handle is used for:
43
+
44
+ - the agent database keeps `token_calibration` (shared learning);
45
+ - the project database keeps the session index, project index, context
46
+ manifests, summary graph, memory/pin projections, embeddings, quality
47
+ samples and artifact metadata; artifact object bytes move to
48
+ `projects/artifacts/<project-digest>/` so the orphan garbage collector,
49
+ which only sees references in the current database, can never delete
50
+ another project's objects;
51
+ - `resource_leases` and the client lease stay per file, protecting each
52
+ database independently.
53
+
54
+ With `project`, a calibration sample is derived from the project manifest and
55
+ inserted into the agent database with `manifest_id = NULL` (the column and its
56
+ partial unique index already allow this). The two writes are intentionally
57
+ **not** one cross-database transaction: losing one calibration sample is
58
+ harmless, whereas losing manifest/usage consistency is not. In `agent` scope
59
+ the previous single transaction is unchanged. Manifest pruning only detaches
60
+ calibration rows in `agent` scope; the project database's calibration table
61
+ stays empty.
62
+
63
+ Every project database starts empty and is rebuilt from canonical Pi JSONL,
64
+ project files and memory/pin `CustomEntry` records. The previous shared
65
+ database is left untouched: with the default change, existing users cold-start
66
+ their per-project indexes while their existing calibration remains available
67
+ in the agent database.
68
+
69
+ ## Consequences
70
+
71
+ - Physical isolation per project: separate growth, separate write lock,
72
+ "reset project state" = remove one file, and no eviction needed to bound a
73
+ single project's index.
74
+ - Calibration stays global: a sample learned in project A is immediately
75
+ visible in project B for the same provider/model/estimator.
76
+ - Default behavior changes. A pre-existing shared database becomes the agent
77
+ database (calibration and old manifests) and is no longer the active
78
+ projection for new sessions. This requires a minor release and release
79
+ notes; `storage.scope: "agent"` restores the old layout.
80
+ - Maintenance and diagnostics become per file: `/context storage` reports the
81
+ active project database and, when split, the shared agent database;
82
+ `ds4-context-storage inspect|compact|recover --database <path>` must be
83
+ pointed at each file.
84
+ - `storage.databasePath` now names the agent database; project databases
85
+ derive from its directory. A manually configured per-project path keeps
86
+ working, but the derived `projects/` directory is the supported layout.
87
+ - D1 is superseded by this ADR; the development plan keeps the original text
88
+ with an explicit amendment pointer.
@@ -67,5 +67,6 @@ The initial decisions from the development plan are accepted:
67
67
  | [061](061-compaction-latency.md) | Bound compaction update calls, input budgets, concurrent segments and phase timings | Accepted |
68
68
  | [062](062-cache-aware-context-planning.md) | Opt-in cache-aware tail planning using model pricing and observed cache shares | Accepted |
69
69
  | [063](063-fts-rowid-key-mappings.md) | Resolve FTS key deletes through rowid mapping tables | Accepted |
70
+ | [064](064-per-project-databases-with-shared-calibration.md) | Split project state into per-project databases and keep token calibration shared | Accepted |
70
71
 
71
72
  Each decision will receive a dedicated record when implementation pressure introduces alternatives or consequences not already covered by the development plan.
@@ -86,6 +86,8 @@ Until enough samples exist, the multiplier remains `1.0`. The default window is
86
86
 
87
87
  Provider-token capacities are computed first from context window, output reserve, safety margin, and policy ratios. Global hard/soft/preferred input limits are converted into local-estimator units using **at least** a multiplier of 1: observed underestimation may reduce them, but apparent overestimation on short prompts never raises them above nominal model limits. Raw manifest estimates remain uncalibrated so future samples do not feed a corrected estimate back into itself. Adaptive tail/history/project budgets use the accepted multiplier but remain capped by the configured `context.*` maxima even after conversion.
88
88
 
89
+ Calibration samples are global learning, not project data: with the default `storage.scope: "project"` the manifests live in the per-project database while `token_calibration` stays in the shared agent database (see [ADR 064](ADR/064-per-project-databases-with-shared-calibration.md)). A sample learned in one project therefore applies to every other project for the same provider/model/estimator.
90
+
89
91
  ### Optional BPE estimator and measured budget tuning
90
92
 
91
93
  The default remains `chars-v1`. To opt a profile into local OpenAI `o200k_base` BPE text counting (without fetching vocabulary over the network):
package/docs/STORAGE.md CHANGED
@@ -10,6 +10,19 @@ Pi's session JSONL is canonical for conversations and live project files are can
10
10
 
11
11
  The extension never edits or rewrites Pi JSONL or project source files. Manual memory/pin commands, confirmed `context_persistence` canonical writes, and learned-ranking feedback append versioned classified Pi `CustomEntry` records through Pi's official `appendEntry()` API. The tool does not write SQLite as a substitute for a canonical Pin or Memory append.
12
12
 
13
+ ## Storage scope
14
+
15
+ `storage.scope` selects where the disposable projection lives (see [ADR 064](ADR/064-per-project-databases-with-shared-calibration.md)):
16
+
17
+ - `agent` — the previous behavior: one shared `context.db` for every session and project.
18
+ - `project` (**default**) — one database per trusted canonical project root, derived as `projects/<sha256(root)[0..32]>.db` next to the agent database. Untrusted projects and broad roots (home directory, filesystem root) fall back to the agent database.
19
+
20
+ Both files receive the same schema and migrations. The agent database keeps only `token_calibration`, so a sample learned in one project is visible to every other project for the same provider/model/estimator; project databases keep the session index, project index, manifests, summary graph, memory/pin projections, embeddings, quality samples and artifact metadata. Project artifact object bytes move under `projects/artifacts/<project-digest>/` so garbage collection stays scoped to one project. A project database starts empty and is rebuilt from canonical JSONL and project files; the split never rewrites the previous shared database.
21
+
22
+ Calibration and manifest writes are intentionally not one cross-database transaction: a failed calibration insert loses at most one sample, while manifest/usage consistency stays inside the project database. Manifest pruning detaches calibration rows only in `agent` scope; in `project` scope the project database's calibration table stays empty. `storage.databasePath` names the agent database; the derived `projects/` directory is the supported layout.
23
+
24
+ Maintenance and diagnostics are per file: `/context storage` reports the active project database and, when split, the shared agent database; `ds4-context-storage inspect|compact|recover --database <path>` must be pointed at each file.
25
+
13
26
  The M19 non-Pi reference adapter owns a separate `ds4-runtime-session-v1` JSONL source selected by its host runtime. Its header binds runtime/session identity and the exact canonical project root; following records contain provenance-checked canonical messages. DS4 snapshots and capability diagnostics are disposable. `createReferenceHistory()` refuses overwrite, append uses a dedicated provenance-checked operation, files are mode `0600` where supported, and rebuild never edits this runtime-owned canonical file. Reference JSONL is not imported into Pi or `context.db`.
14
27
 
15
28
  M20 local KV state is entirely runtime-owned and volatile. Core returns only an in-memory eligibility fingerprint to the runtime port; it has no cache-handle field or serialization API. Prefixes, fingerprints, handles and provider outputs are absent from Pi/reference JSONL, Context Manifests, ranking artifacts and every SQLite table. Aggregate hit/miss/prefill counters live only on the adapter controller, and a restart safely resets them with the runtime cache. M20 adds no database migration.
@@ -83,7 +96,7 @@ Custom entries have empty lexical search text and never enter Pi context directl
83
96
 
84
97
  Schema v10 extends `context_manifests` and `token_calibration` with separate uncached-input, cache-read, and cache-write token columns. New calibration rows also carry the correlated manifest ID and explicit estimator version. Legacy pre-v10 samples migrate as `chars-v1` with their prior total stored as uncached input and zero cache fields; this preserves historical ratio behavior without inventing cache hits.
85
98
 
86
- Calibration rows are derived telemetry, isolated by exact provider/model and bounded to the latest configured window at read time. The runtime recomputes median/MAD outlier filtering deterministically; no learned model or mutable provider state is stored. Deleting the database loses calibration and cache history but never session content. Ephemeral sessions and configurations that disable manifest persistence keep only a bounded in-memory window.
99
+ Calibration rows are derived telemetry, isolated by exact provider/model and bounded to the latest configured window at read time. The runtime recomputes median/MAD outlier filtering deterministically; no learned model or mutable provider state is stored. Deleting the database loses calibration and cache history but never session content. Ephemeral sessions and configurations that disable manifest persistence keep only a bounded in-memory window. With `storage.scope: "project"`, calibration lives in the agent database while the manifest that produced the sample lives in the project database; the sample therefore carries no `manifest_id` and the two writes are separate transactions.
87
100
 
88
101
  ## Context quality samples
89
102
 
@@ -107,7 +120,7 @@ The volatile state is cleared on lifecycle/model/branch/compaction boundaries an
107
120
 
108
121
  Schema v8 splits content objects from source references. `artifact_objects` is keyed by SHA-256 and stores the private file path, MIME, byte size, verification timestamps, and integrity status. `artifacts` is keyed by a deterministic source-specific ID and references session/entry/tool identity plus original/condensed token estimates and an optional derived privacy classification in `metadata_json`. Equal bytes across calls or sessions deduplicate to one object while retaining independent provenance.
109
122
 
110
- Objects live under `ds4-context/artifacts/<sha-prefix>/<sha256>` with private permissions and atomic writes. Pi's full JSONL tool result remains canonical; the object file is a rebuildable local cache. No artifact content is stored in Context Manifests. Search recomputes SHA-256 and returns only bounded, redacted, JSON-quoted literal-match windows for a current-branch reference. The runtime reapplies the stored artifact classification before returning excerpts to the active provider; prohibited remote searches return no content.
123
+ Objects live under `ds4-context/artifacts/<sha-prefix>/<sha256>` with private permissions and atomic writes. With `storage.scope: "project"` the store root becomes `projects/artifacts/<project-digest>/...` so artifact bytes and their metadata share one boundary; the orphan garbage collector only sees references in the current database and must never delete another project's objects. Pi's full JSONL tool result remains canonical; the object file is a rebuildable local cache. No artifact content is stored in Context Manifests. Search recomputes SHA-256 and returns only bounded, redacted, JSON-quoted literal-match windows for a current-branch reference. The runtime reapplies the stored artifact classification before returning excerpts to the active provider; prohibited remote searches return no content.
111
124
 
112
125
  A full index rebuild replays all message entries, recreates missing qualifying objects, removes stale session references, and garbage-collects object rows/files with no references. Missing/corrupt states are reported by `/context health` without blocking Pi.
113
126
 
@@ -117,7 +130,7 @@ For persisted sessions, each `context` hook stores a metadata-only manifest cont
117
130
 
118
131
  `before_provider_request` updates the pending in-memory manifest with final-check/redaction counters but never the provider payload. The following finalized assistant response updates only the existing scalar usage columns (`actual_tokens`, `input_tokens`, `cache_read_tokens`, and `cache_write_tokens`) and adds at most one exact-model calibration sample. It does not read or rewrite `manifest_json`. Repository reads hydrate authoritative usage from those columns. Ephemeral, oversize-skipped, concurrently pruned, and otherwise uncorrelated manifests retain bounded calibration only in memory.
119
132
 
120
- Retention is bounded without a schema change: SQLite keeps the latest 128 manifests globally and at most 200 calibration samples for each provider/model/estimator profile. A manifest prune first detaches its small calibration row, then removes the large diagnostic JSON; calibration has its own per-profile retention. Save and prune are one transaction. Existing oversized stores are reduced incrementally by at most 32 rows and 8 MiB of serialized manifest payload per subsequent manifest write; one individually oversized oldest row may be removed to guarantee progress. There is no startup purge.
133
+ Retention is bounded without a schema change: SQLite keeps the latest 128 manifests globally and at most 200 calibration samples for each provider/model/estimator profile. A manifest prune first detaches its small calibration row, then removes the large diagnostic JSON; calibration has its own per-profile retention. Save and prune are one transaction. In `project` scope the manifest transaction runs in the project database while the calibration sample is inserted separately into the agent database. Existing oversized stores are reduced incrementally by at most 32 rows and 8 MiB of serialized manifest payload per subsequent manifest write; one individually oversized oldest row may be removed to guarantee progress. There is no startup purge.
121
134
 
122
135
  New manifest persistence is byte-bounded. Payloads up to 256 KiB remain complete. Larger payloads preserve all `included` provenance and replace only the `excluded` inventory with a deterministic first/last sample of at most 256 details plus explicit `ds4-context-manifest-inventory-v1` counts, token/classification/kind rollups, and digests. The wrapper returned by `getStored()` declares `complete` or `excluded-rollup`; the live runtime manifest remains complete. A projected payload over 1 MiB is skipped without affecting the model request. Deleted pages become reusable by SQLite but do not promise an immediate reduction in filesystem size. Manifests and calibration remain disposable; Pi JSONL and project files are untouched.
123
136
 
@@ -152,3 +165,25 @@ A full rebuild does not blindly delete unchanged entries. It upserts all observe
152
165
  Session reconciliation is transactional. Memory/pin mutation replacement, checkpoint update, source exclusion and full materialization each occur under the shared write coordinator. Each manifest upsert and dual-bound incremental retention prune share one transaction; each scalar usage/calibration update and its independent per-profile prune do the same. Each quality upsert and bounded-retention prune also share one transaction; quality failures do not affect manifests or planning. Each changed project file is replaced transactionally with its snippets and FTS rows; embedding upserts and canonical-source pruning are transactional; artifact object/reference metadata and project deletion batches are atomic. A filesystem artifact write precedes its metadata transaction, so an interrupted metadata write may leave only an unreferenced content-addressed cache file; canonical JSONL remains sufficient for recovery. If manifest serialization, projection, retention, or SQLite writing fails, the complete current manifest remains in memory and the provider request is unchanged. Other artifact/project failures contribute no replacement/snippets; planner failures discard all synthetic evidence; Pi continues with its native context.
153
166
 
154
167
  After bounded busy-aware replay is exhausted, DS4 emits `database.write_lock_timeout` with only the coordinator operation name, attempt count, elapsed/configured waits, and SQLite primary code. The thrown error repeats the operation and categorical lock status but never includes SQL, bound values, provider content, or the raw SQLite message. Retry and rollback diagnostics follow the same metadata-only rule.
168
+
169
+ ## Growth measurements
170
+
171
+ `tests/benchmarks/storage-scale.bench.ts` seeds session indexes of 5,000 / 50,000 / 200,000 entries and measures the paths a session actually pays. Run it on demand:
172
+
173
+ ```bash
174
+ npx vitest bench tests/benchmarks/storage-scale.bench.ts
175
+ ```
176
+
177
+ Measured means on this development machine (Node.js 26.5.1, `node:sqlite`, one session per database):
178
+
179
+ | Path | 5k entries | 50k entries | 200k entries |
180
+ | --- | ---: | ---: | ---: |
181
+ | Exact identifier scan (`instr` over one session) | 0.97 ms | 12.5 ms | 52.7 ms |
182
+ | Exact phrase scan | 0.98 ms | 12.8 ms | 53.2 ms |
183
+ | FTS retrieval, common token | 0.14 ms | 2.3 ms | 9.0 ms |
184
+ | FTS retrieval, rare token | 0.06 ms | 0.15 ms | 0.94 ms |
185
+ | Per-session stats (`COUNT`/`SUM` for one session) | 0.33 ms | 4.8 ms | 21.8 ms |
186
+ | Storage diagnostics | 0.10 ms | 0.10 ms | 0.10 ms |
187
+ | Append-only unchanged re-check (1,000 entries) | 1.4 ms | 2.9 ms | 7.2 ms |
188
+
189
+ Growth is **real but bounded**: exact identifier and phrase scans are literal `instr()` scans over the session's rows and grow roughly linearly, passing the 50 ms typical-operation target only around 200k indexed entries in a single session. FTS retrieval and per-session aggregate SQL grow sublinearly and stay in single-digit milliseconds; bounded manifest/calibration diagnostics are flat. The dominant cost tracks a single session's size, not the file size, so `storage.scope: "project"` bounds physical growth, lock scope and reset per project but does not by itself change this per-session scan profile. Long single sessions near or above the 200k-entry range are the case where exact-identifier retrieval latency becomes measurable.
@@ -1,6 +1,6 @@
1
1
  # Offline SQLite Storage Maintenance
2
2
 
3
- DS4 keeps `context.db` as disposable derived state, while Pi session JSONL and live project files remain canonical. Normal runtime retention stops unbounded manifest growth and makes deleted pages reusable. It does not promise that an existing high-water SQLite file shrinks physically.
3
+ DS4 keeps `context.db` as disposable derived state, while Pi session JSONL and live project files remain canonical. Normal runtime retention stops unbounded manifest growth and makes deleted pages reusable. It does not promise that an existing high-water SQLite file shrinks physically. With the default `storage.scope: "project"` each trusted project has its own database under `ds4-context/projects/` and the agent database keeps shared token calibration: run the maintenance commands per file, not only on the agent database (see [`STORAGE.md`](STORAGE.md)).
4
4
 
5
5
  Physical compaction is therefore an explicit offline operation. It is never model-callable, never runs at startup, and never edits Pi JSONL or project files.
6
6
 
@@ -26,4 +26,4 @@ No new defaults are enabled: `chars-v1`, disabled `autoTune`, and disabled DS4 n
26
26
 
27
27
  ## Validation and publication
28
28
 
29
- On Node 26.5.1, the coordinated release passed a clean `npm ci`, `npm run check` (97 Vitest files, 607 tests, TypeScript builds and root typecheck), deterministic `npm run quality:compare`, the `npm run schema:context-persistence` size bound, and `npm run pack:check` in a clean consumer after synchronizing both exported runtime version constants. All typecheck/tests were also rerun after that correction. Dry-run tarball review contained 243 core files, 7 reference-adapter files, and 97 extension files; none included untracked local state. Registry publication and exact-artifact verification are still pending.
29
+ On Node 26.5.1, the coordinated release passed a clean `npm ci`, `npm run check` (97 Vitest files, 607 tests, TypeScript builds and root typecheck), deterministic `npm run quality:compare`, the `npm run schema:context-persistence` size bound, and `npm run pack:check` in a clean consumer after synchronizing both exported runtime version constants. All typecheck/tests were also rerun after that correction. Dry-run tarball review contained 243 core files, 7 reference-adapter files, and 97 extension files; none included untracked local state. All three packages were then published to npm at 0.3.10 in dependency order. After registry propagation, `npm run registry:check -- 0.3.10` verified all three exact-version artifacts in a fresh consumer.
@@ -0,0 +1,86 @@
1
+ # Release 0.4.0 — Per-project databases with shared token calibration
2
+
3
+ **Coordinated packages:** `ds4-context-core`, `ds4-context-reference-adapter`, and `ds4-context-engine` 0.4.0.
4
+ **Implementation commit:** `2c2e248`.
5
+ **Decision record:** [ADR 064](../ADR/064-per-project-databases-with-shared-calibration.md).
6
+
7
+ ## Summary
8
+
9
+ Adds the opt-out `storage.scope: "agent" | "project"` setting and defaults it to
10
+ `project`. Each trusted canonical project root now gets its own SQLite
11
+ projection next to the configured agent database, while token calibration stays
12
+ in the agent database and remains shared across projects. `storage.scope:
13
+ "agent"` restores the previous single-file layout. The reference adapter and Pi
14
+ extension continue to depend exactly on the matching core version.
15
+
16
+ ## Changes
17
+
18
+ - `storage.scope: "project"` (new default) derives one database per trusted
19
+ canonical project root at `projects/<sha256(root)[0..32]>.db`, next to
20
+ `storage.databasePath`. Untrusted projects, broad roots (home directory,
21
+ filesystem root) and any resolution failure fall back to the agent database.
22
+ - Both files receive the same schema and the same migrations; migrations 1–15
23
+ are untouched and there is no schema fork. The project database holds the
24
+ session index, project index, context manifests, summary graph, memory/pin
25
+ projections, embeddings, quality samples and artifact metadata; the agent
26
+ database holds `token_calibration`.
27
+ - With the split, a calibration sample is written to the agent database with
28
+ `manifest_id = NULL`, exactly once per manifest. The two writes are
29
+ intentionally not one cross-database transaction: a lost calibration sample is
30
+ harmless, a lost manifest/usage consistency is not. In `agent` scope the
31
+ previous single transaction is unchanged.
32
+ - Artifact object bytes move to `projects/artifacts/<project-digest>/` under
33
+ project scope so the orphan garbage collector, which only sees references in
34
+ the current database, can never delete another project's objects.
35
+ - `/context storage` and `/context diagnostics` report the active project
36
+ database and, when split, the shared agent database. Storage maintenance
37
+ stays per file: `ds4-context-storage inspect|compact|recover --database <path>`
38
+ must be pointed at each database.
39
+ - The previous shared database is left untouched. With the new default, existing
40
+ users cold-start per-project indexes while existing calibration remains
41
+ available in the agent database; project indexes rebuild from canonical Pi
42
+ JSONL, project files and memory/pin `CustomEntry` records.
43
+
44
+ ## Breaking behavior
45
+
46
+ The default storage layout changes. A pre-existing
47
+ `~/.pi/agent/ds4-context/context.db` becomes the agent database and is no longer
48
+ the active projection for new sessions. `storage.scope: "agent"` restores the
49
+ old single-database behavior. Rows are not migrated between files; the project
50
+ databases are derived and rebuildable. Decision D1 of the storage plan ("no
51
+ per-session or per-project databases") is superseded by ADR 064 and the plan
52
+ keeps the original text with an explicit amendment pointer.
53
+
54
+ ## Measured scope and limitations
55
+
56
+ `tests/benchmarks/storage-scale.bench.ts` seeds one session with 5,000 /
57
+ 50,000 / 200,000 entries and measures the paths a session pays. Measured means
58
+ on the development machine (Node.js 26.5.1, `node:sqlite`): exact identifier
59
+ scan 0.97 / 12.5 / 52.7 ms, exact phrase scan 0.98 / 12.8 / 53.2 ms, FTS
60
+ common token 0.14 / 2.3 / 9.0 ms, per-session stats 0.33 / 4.8 / 21.8 ms,
61
+ storage diagnostics flat at 0.10 ms, unchanged re-check 1.4 / 2.9 / 7.2 ms.
62
+ Growth is real but bounded and tracks single-session size, not file size:
63
+ project scope bounds physical growth, lock scope and reset per project, but does
64
+ not by itself change the per-session exact-scan profile. See
65
+ [storage growth measurements](../STORAGE.md#growth-measurements).
66
+
67
+ These are local measurements on one host, not portable guarantees. No provider
68
+ calls are involved in this release procedure.
69
+
70
+ ## Compatibility
71
+
72
+ No provider-facing default changes: `chars-v1` remains the default estimator,
73
+ `modelAwareness.autoTune` and DS4 native continuation stay disabled. No SQLite
74
+ migration, no change to canonical Pi JSONL, privacy consent, native continuation
75
+ or the portable runtime-adapter contract. Restart Pi after upgrading so the new
76
+ compiled core and session configuration are loaded.
77
+
78
+ ## Validation and publication
79
+
80
+ On Node 26.5.1, the coordinated release passed `npm run check` (99 Vitest files,
81
+ 615 tests, TypeScript builds and root typecheck), deterministic
82
+ `npm run quality:compare`, the `npm run schema:context-persistence` size bound,
83
+ and `npm run pack:check` in a clean consumer after synchronizing both exported
84
+ runtime version constants. `git diff --check` was clean and dry-run tarball
85
+ review contained 247 core files, 7 reference-adapter files and 98 extension
86
+ files, none including untracked local state.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ds4-context-engine",
3
- "version": "0.3.10",
3
+ "version": "0.4.0",
4
4
  "description": "Non-destructive, provider-independent context management for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,6 +52,7 @@
52
52
  "quality:compare": "node scripts/compare-context-quality.mjs",
53
53
  "schema:context-persistence": "node scripts/measure-context-persistence-schema.mjs",
54
54
  "latency:check": "npm run build:core && node scripts/compare-disabled-planning-latency.mjs",
55
+ "storage:scale": "npm run build:core && vitest bench tests/benchmarks/storage-scale.bench.ts",
55
56
  "pack:check": "node scripts/verify-packages.mjs",
56
57
  "registry:check": "node scripts/verify-registry-packages.mjs",
57
58
  "prepare": "npm run build:core && npm run build:adapters"
@@ -62,7 +63,7 @@
62
63
  ]
63
64
  },
64
65
  "dependencies": {
65
- "ds4-context-core": "0.3.10",
66
+ "ds4-context-core": "0.4.0",
66
67
  "js-tiktoken": "1.0.21"
67
68
  },
68
69
  "peerDependencies": {
@@ -835,13 +835,20 @@ function formatAdapter(diagnostics: RuntimeDiagnostics): string {
835
835
  ].join("\n");
836
836
  }
837
837
 
838
- function formatStorage(storage: StorageDiagnostics, databasePath?: string): string {
838
+ function formatStorage(
839
+ storage: StorageDiagnostics,
840
+ databasePath?: string,
841
+ agentDatabasePath?: string,
842
+ ): string {
843
+ const agentLine = agentDatabasePath && agentDatabasePath !== databasePath
844
+ ? [`Agent database (shared): ${agentDatabasePath}`] : [];
839
845
  if (storage.status === "unavailable") {
840
846
  return [
841
847
  "DS4 Storage",
842
848
  "",
843
849
  "Status: unavailable",
844
850
  `Database: ${databasePath ?? "unavailable"}`,
851
+ ...agentLine,
845
852
  "Pi fallback remains active; no storage mutation was attempted.",
846
853
  ].join("\n");
847
854
  }
@@ -850,6 +857,7 @@ function formatStorage(storage: StorageDiagnostics, databasePath?: string): stri
850
857
  "",
851
858
  `Status: ${storage.status}`,
852
859
  `Database: ${databasePath ?? "unavailable"}`,
860
+ ...agentLine,
853
861
  `Schema / journal: ${storage.schemaVersion ?? "n/a"} / ${storage.journalMode ?? "n/a"}`,
854
862
  `Database / WAL / SHM: ${bytes(storage.databaseBytes)} / ${bytes(storage.walBytes)} / ${bytes(storage.shmBytes)}`,
855
863
  `Allocated / reusable: ${bytes(storage.allocatedBytes)} / ${bytes(storage.reusableBytes)}`,
@@ -1234,7 +1242,7 @@ export function registerContextCommand(pi: ExtensionAPI, runtime: Ds4ContextRunt
1234
1242
  const storage = runtime.storageDiagnostics();
1235
1243
  present(
1236
1244
  ctx,
1237
- formatStorage(storage, diagnostics.databasePath),
1245
+ formatStorage(storage, diagnostics.databasePath, diagnostics.agentDatabasePath),
1238
1246
  storage.status === "ok" ? "info" : "warning",
1239
1247
  );
1240
1248
  return;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { dirname, join, parse, resolve } from "node:path";
4
+ import { basename, dirname, join, parse, resolve } from "node:path";
5
5
  import type {
6
6
  Api,
7
7
  AssistantMessage,
@@ -46,11 +46,12 @@ export type {
46
46
  import {
47
47
  loadConfig,
48
48
  resolveDatabasePath,
49
+ resolveProjectDatabasePath as deriveProjectDatabasePath,
49
50
  resolveRankingModelPath,
50
51
  validateConfigFile,
51
52
  type LoadedConfig,
52
53
  } from "ds4-context-core/config/config-loader";
53
- import { CONFIG_SCHEMA_VERSION, createDefaultConfig, type Ds4ContextConfig } from "ds4-context-core/config/config";
54
+ import { CONFIG_SCHEMA_VERSION, createDefaultConfig, type Ds4ContextConfig, type StorageScope } from "ds4-context-core/config/config";
54
55
  import {
55
56
  applyConfigValue,
56
57
  findConfigField,
@@ -387,6 +388,8 @@ export interface RuntimeDiagnostics {
387
388
  session?: PiSessionSnapshot;
388
389
  model?: { provider: string; id: string };
389
390
  databasePath?: string;
391
+ /** Present only when storage.scope splits calibration from project state. */
392
+ agentDatabasePath?: string;
390
393
  databaseSchemaVersion?: number;
391
394
  indexed?: SessionIndexStats;
392
395
  observation?: ContextObservation;
@@ -426,8 +429,10 @@ export class Ds4ContextRuntime {
426
429
  private loadedConfig?: LoadedConfig;
427
430
  private session?: PiSessionSnapshot;
428
431
  private database?: ContextDatabase;
432
+ private agentDatabase?: ContextDatabase;
429
433
  private indexer?: PiSessionIndexer;
430
434
  private databasePath?: string;
435
+ private agentDatabasePath?: string;
431
436
  private observation?: ContextObservation;
432
437
  private lastManifest?: ContextManifest;
433
438
  private lastPersistedInventory?: PersistedManifestInventory;
@@ -579,17 +584,33 @@ export class Ds4ContextRuntime {
579
584
  return;
580
585
  }
581
586
 
582
- this.databasePath = resolveDatabasePath(
587
+ const agentDatabasePath = resolveDatabasePath(
583
588
  this.config.storage.databasePath,
584
589
  this.dependencies.agentDir,
585
590
  this.dependencies.homeDir,
586
591
  );
592
+ this.agentDatabasePath = agentDatabasePath;
593
+ const projectDatabasePath = this.resolveProjectDatabasePath(
594
+ this.config.storage.scope,
595
+ ctx,
596
+ agentDatabasePath,
597
+ );
598
+ this.databasePath = projectDatabasePath ?? agentDatabasePath;
599
+ if (projectDatabasePath) {
600
+ this.agentDatabase = ContextDatabase.open(agentDatabasePath, {
601
+ logger: this.logger,
602
+ now: this.now(),
603
+ busyTimeoutMs: this.config.storage.busyTimeoutMs,
604
+ writeRetryTimeoutMs: this.config.storage.writeRetryTimeoutMs,
605
+ });
606
+ }
587
607
  this.database = ContextDatabase.open(this.databasePath, {
588
608
  logger: this.logger,
589
609
  now: this.now(),
590
610
  busyTimeoutMs: this.config.storage.busyTimeoutMs,
591
611
  writeRetryTimeoutMs: this.config.storage.writeRetryTimeoutMs,
592
612
  });
613
+ this.agentDatabase ??= this.database;
593
614
  this.initializeRanking();
594
615
 
595
616
  this.indexer = new PiSessionIndexer(this.database.sessionIndex, {
@@ -765,10 +786,25 @@ export class Ds4ContextRuntime {
765
786
  ? O200K_ESTIMATOR : CHARS_ESTIMATOR;
766
787
  }
767
788
 
789
+ private resolveProjectDatabasePath(
790
+ scope: StorageScope,
791
+ ctx: ExtensionContext,
792
+ agentDatabasePath: string,
793
+ ): string | undefined {
794
+ if (scope !== "project") return undefined;
795
+ // Untrusted projects ignore project configuration; they also fall back to
796
+ // the shared agent database instead of creating a new physical boundary.
797
+ if (!ctx.isProjectTrusted()) return undefined;
798
+ const projectRoot = canonicalProjectPath(ctx.cwd);
799
+ if (isBroadProjectRoot(projectRoot, this.dependencies.homeDir ?? homedir())) return undefined;
800
+ const projectDatabasePath = deriveProjectDatabasePath(agentDatabasePath, projectRoot);
801
+ return projectDatabasePath === agentDatabasePath ? undefined : projectDatabasePath;
802
+ }
803
+
768
804
  private calibrationSamples(model: ModelDescriptor): TokenCalibrationSample[] {
769
805
  const version = this.estimatorForModel(model).version;
770
806
  if (this.database && this.session?.sessionFile && this.config.diagnostics.storeContextManifest) {
771
- return this.database.manifests.listCalibrationSamples(
807
+ return (this.agentDatabase ?? this.database).calibrations.list(
772
808
  model.provider,
773
809
  model.id,
774
810
  this.config.modelAwareness.calibrationWindow,
@@ -1884,13 +1920,39 @@ export class Ds4ContextRuntime {
1884
1920
  const createdAt = this.now();
1885
1921
  try {
1886
1922
  if (manifestPersisted && this.database) {
1887
- const updated = this.database.manifests.recordProviderUsage(
1923
+ const estimatorVersion = manifest?.modelAwareness?.calibration.estimator ?? "chars-v1";
1924
+ // With storage.scope=project the manifest lives in the project database
1925
+ // while calibration stays shared in the agent database. The two writes
1926
+ // are intentionally not one transaction: losing one sample is harmless,
1927
+ // losing manifest/usage consistency is not.
1928
+ const calibrationDatabase = this.agentDatabase !== this.database
1929
+ ? this.agentDatabase : undefined;
1930
+ const updated = this.database.manifests.recordProviderUsageOutcome(
1888
1931
  manifestId,
1889
1932
  providerUsage,
1890
1933
  createdAt,
1891
- manifest?.modelAwareness?.calibration.estimator ?? "chars-v1",
1934
+ estimatorVersion,
1935
+ { writeCalibration: calibrationDatabase === undefined },
1892
1936
  );
1893
- if (!updated && manifest?.estimatedInputTokens) {
1937
+ if (updated.outcome === "recorded" && calibrationDatabase) {
1938
+ const source = this.database.manifests.calibrationSource(manifestId);
1939
+ const recorded = source
1940
+ ? calibrationDatabase.calibrations.record({
1941
+ provider: source.provider,
1942
+ model: source.model,
1943
+ estimatedTokens: source.estimatedTokens,
1944
+ actualInputTokens: providerUsage.totalInputTokens,
1945
+ inputTokens: providerUsage.inputTokens,
1946
+ cacheReadTokens: providerUsage.cacheReadTokens,
1947
+ cacheWriteTokens: providerUsage.cacheWriteTokens,
1948
+ createdAt,
1949
+ estimatorVersion,
1950
+ })
1951
+ : false;
1952
+ if (!recorded && manifest?.estimatedInputTokens) {
1953
+ this.rememberVolatileCalibration(manifest, providerUsage, createdAt);
1954
+ }
1955
+ } else if (!updated.manifest && manifest?.estimatedInputTokens) {
1894
1956
  this.rememberVolatileCalibration(manifest, providerUsage, createdAt);
1895
1957
  }
1896
1958
  } else if (manifest?.estimatedInputTokens) {
@@ -2707,10 +2769,13 @@ export class Ds4ContextRuntime {
2707
2769
  return;
2708
2770
  }
2709
2771
  try {
2710
- const store = new FileArtifactStore(
2711
- join(this.dependencies.agentDir, "ds4-context", "artifacts"),
2712
- this.now,
2713
- );
2772
+ // Artifact bytes and their metadata must share one boundary: the orphan
2773
+ // garbage collector only sees references in the current database, so a
2774
+ // shared store would let one project delete another project's objects.
2775
+ const storeRoot = this.agentDatabase !== this.database && this.databasePath
2776
+ ? join(dirname(this.databasePath), "artifacts", basename(this.databasePath, ".db"))
2777
+ : join(this.dependencies.agentDir, "ds4-context", "artifacts");
2778
+ const store = new FileArtifactStore(storeRoot, this.now);
2714
2779
  this.artifactManager = new ArtifactManager(
2715
2780
  store,
2716
2781
  this.database.artifacts,
@@ -3295,6 +3360,8 @@ export class Ds4ContextRuntime {
3295
3360
  session: currentSession,
3296
3361
  ...(ctx.model ? { model: { provider: ctx.model.provider, id: ctx.model.id } } : {}),
3297
3362
  ...(this.databasePath ? { databasePath: this.databasePath } : {}),
3363
+ ...(this.agentDatabasePath && this.agentDatabasePath !== this.databasePath
3364
+ ? { agentDatabasePath: this.agentDatabasePath } : {}),
3298
3365
  ...(this.database ? { databaseSchemaVersion: this.database.schemaVersion } : {}),
3299
3366
  ...(indexed ? { indexed } : {}),
3300
3367
  ...(this.observation ? { observation: this.observation } : {}),
@@ -3332,7 +3399,9 @@ export class Ds4ContextRuntime {
3332
3399
  }
3333
3400
 
3334
3401
  storageDiagnostics(): StorageDiagnostics {
3335
- return this.database?.storageDiagnostics(this.session?.projectPath)
3402
+ const calibrationDatabase = this.agentDatabase && this.agentDatabase !== this.database
3403
+ ? this.agentDatabase : undefined;
3404
+ return this.database?.storageDiagnostics(this.session?.projectPath, calibrationDatabase)
3336
3405
  ?? unavailableStorageDiagnostics();
3337
3406
  }
3338
3407
 
@@ -3459,17 +3528,25 @@ export class Ds4ContextRuntime {
3459
3528
  try {
3460
3529
  this.database?.close();
3461
3530
  } finally {
3462
- this.compaction = undefined;
3463
- this.retrievalEngine = undefined;
3464
- this.projectKnowledge = undefined;
3465
- this.projectRefreshPending = false;
3466
- this.memoryManager = undefined;
3467
- this.projectMemorySynchronizer = undefined;
3468
- this.lastCrossSessionMemory = disabledCrossSessionMemoryDiagnostics();
3469
- this.lastMemoryMutationSignature = undefined;
3470
- this.artifactManager = undefined;
3471
- this.indexer = undefined;
3472
- this.database = undefined;
3531
+ try {
3532
+ if (this.agentDatabase && this.agentDatabase !== this.database) {
3533
+ this.agentDatabase.close();
3534
+ }
3535
+ } finally {
3536
+ this.agentDatabase = undefined;
3537
+ this.agentDatabasePath = undefined;
3538
+ this.compaction = undefined;
3539
+ this.retrievalEngine = undefined;
3540
+ this.projectKnowledge = undefined;
3541
+ this.projectRefreshPending = false;
3542
+ this.memoryManager = undefined;
3543
+ this.projectMemorySynchronizer = undefined;
3544
+ this.lastCrossSessionMemory = disabledCrossSessionMemoryDiagnostics();
3545
+ this.lastMemoryMutationSignature = undefined;
3546
+ this.artifactManager = undefined;
3547
+ this.indexer = undefined;
3548
+ this.database = undefined;
3549
+ }
3473
3550
  }
3474
3551
  }
3475
3552
 
@@ -1,4 +1,4 @@
1
- export const EXTENSION_VERSION = "0.3.10";
1
+ export const EXTENSION_VERSION = "0.4.0";
2
2
  export const SUPPORTED_PI_VERSION = "0.84.3";
3
3
  export const OBSERVER_PLANNER_VERSION = "observer-model-aware-v1";
4
4
  export const PLANNER_VERSION = "managed-learned-ranking-v1";