ds4-context-engine 0.2.0 → 0.3.0-alpha.2

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/docs/RELEASING.md CHANGED
@@ -23,12 +23,14 @@ The automated package check enforces matching versions, exact core dependencies,
23
23
  ```bash
24
24
  npm ci
25
25
  npm run check
26
+ npm run quality:compare
27
+ npm run schema:context-persistence
26
28
  npm run pack:check
27
29
  git diff --check
28
30
  git status --short
29
31
  ```
30
32
 
31
- For a 0.2 release candidate, compare feature-disabled planning against exact stable `ds4-context-core@0.1.2` on the same host:
33
+ For a 0.2 release candidate or a coordinated 0.3 prerelease, compare feature-disabled planning against exact stable `ds4-context-core@0.1.2` on the same host:
32
34
 
33
35
  ```bash
34
36
  BASELINE_DIR="$(mktemp -d)"
@@ -39,7 +41,7 @@ npm run latency:check -- "$BASELINE_DIR/node_modules/ds4-context-core"
39
41
  rm -rf "$BASELINE_DIR"
40
42
  ```
41
43
 
42
- The check rejects a candidate p95 above 110% of the exact 0.1.2 baseline. See [`RELEASE_READINESS_0.2.0.md`](RELEASE_READINESS_0.2.0.md) for the complete gate matrix and rollback procedure.
44
+ The check rejects a candidate p95 above 110% of the exact 0.1.2 baseline. Run latency measurements on an otherwise idle host and repeat an anomalous run before drawing a release conclusion. See [`RELEASE_READINESS_0.2.0.md`](RELEASE_READINESS_0.2.0.md) for the stable-line gate matrix, the versioned notes under [`releases/`](releases/) for prerelease evidence, and [`DOGFOODING_0.3.0_ALPHA.md`](DOGFOODING_0.3.0_ALPHA.md) for the post-publication operating matrix.
43
45
 
44
46
  CI runs the same checks on the minimum supported Node.js version and the current Node.js LTS line. `npm run pack:check` uses a temporary directory and removes it when complete. Set `DS4_KEEP_PACK_TMP=1` only when diagnosing a failed package check.
45
47
 
@@ -65,13 +67,13 @@ npm install --package-lock-only
65
67
  npm run pack:check
66
68
  ```
67
69
 
68
- Review `package.json`, both workspace package manifests, and `package-lock.json` before committing the release change.
70
+ Review `package.json`, both workspace package manifests, and `package-lock.json` before committing the release change. For every coordinated prerelease, all three manifests and both exact adapter dependencies must use precisely the intended version; do not publish only the root package without a separate release-policy decision.
69
71
 
70
72
  ## Publish
71
73
 
72
74
  Publishing is manual-only. GitHub Actions workflows must remain validation-only: do not add npm credentials, `NODE_AUTH_TOKEN`, `NPM_TOKEN`, `id-token: write`, `packages: write`, or an `npm publish` step. The CI workflow explicitly denies OIDC and package-write permissions.
73
75
 
74
- Authenticate with npm using an interactive OTP or a granular publish token with bypass 2FA, verify the active account, and publish in dependency order:
76
+ Authenticate with npm using an interactive OTP or a granular publish token with bypass 2FA, verify the active account, and publish in dependency order. Stable releases may use npm's default `latest` tag:
75
77
 
76
78
  ```bash
77
79
  npm whoami
@@ -80,7 +82,15 @@ npm publish --workspace ds4-context-reference-adapter --access public
80
82
  npm publish --access public
81
83
  ```
82
84
 
83
- If core succeeds but an adapter publication fails, fix that adapter release and retry it with the same version. Do not rewrite or unpublish a valid core release merely to make the commands appear atomic.
85
+ Prereleases must pass the same explicit channel tag to all three commands so they cannot move `latest`. For a 0.3 alpha:
86
+
87
+ ```bash
88
+ npm publish --workspace ds4-context-core --access public --tag alpha
89
+ npm publish --workspace ds4-context-reference-adapter --access public --tag alpha
90
+ npm publish --access public --tag alpha
91
+ ```
92
+
93
+ After prerelease publication, verify both the exact artifacts and that `latest` still resolves to the intended stable version. If core succeeds but an adapter publication fails, fix that adapter release and retry it with the same version and channel tag. Do not rewrite or unpublish a valid core release merely to make the commands appear atomic.
84
94
 
85
95
  After all registry packages are available:
86
96
 
package/docs/STORAGE.md CHANGED
@@ -8,7 +8,7 @@ Pi's session JSONL is canonical for conversations and live project files are can
8
8
  /context rebuild-index
9
9
  ```
10
10
 
11
- The extension never edits or rewrites Pi JSONL or project source files. Manual memory/pin commands and learned-ranking feedback append versioned classified Pi `CustomEntry` records through Pi's official `appendEntry()` API.
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
13
  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
14
 
@@ -73,7 +73,7 @@ Schema v9 adds append-only `memory_mutations` and `pin_mutations`, each keyed to
73
73
 
74
74
  Before replay, the current session's mutation rows are replaced from its complete Pi entry tree. Other indexed sessions remain available, preserving the 0.1 project-scope behavior.
75
75
 
76
- Schema v15 adds `project_memory_sessions`, `project_memory_source_exclusions`, and mutation creation-parent columns. When `memory.crossSession` is opted in, DS4 enumerates at most `memory.maxProjectSessions` sibling Pi JSONL files, validates each header against the exact trusted canonical project path, indexes only changed suffixes, and materializes their explicit mutations. Source rows retain header/checkpoint hashes, offsets, record/mutation counts, malformed-line counts, status and bounded error text. Exclusions are local derived policy; mutation content remains only in canonical JSONL and existing local projection tables.
76
+ Schema v15 adds `project_memory_sessions`, `project_memory_source_exclusions`, and mutation creation-parent columns. When `memory.crossSession` is opted in, DS4 enumerates at most `memory.maxProjectSessions` sibling Pi JSONL files, validates each header against the exact trusted canonical project path, indexes only changed suffixes, and materializes their explicit mutations. Source rows retain header/checkpoint hashes, offsets, record/mutation counts, malformed-line counts, status and bounded error text. Exclusions are local derived policy; mutation content remains only in canonical JSONL and existing local projection tables. `context_persistence` exposes a process-local `sourceRef` instead of the session ID/path. Source-reference and revision mappings are TTL/cap-bounded, never written to SQLite, manifests, logs or JSONL, and disappear at restart. Deleting `context.db` deliberately removes exclusions; replay then restores contributions from unchanged canonical sibling JSONL.
77
77
 
78
78
  A missing, moved, truncated, identity-mismatched or corrupt sibling source stops contributing unverifiable project-scoped mutations without discarding its isolated session-scoped projection. The active session retains its last transactional projection if an auxiliary cross-session refresh fails. Restored files rebuild deterministically. Deleting the database loses no canonical mutation; source discovery recreates the complete project projection without opening every source session. Unbacked legacy pre-v9 materialized rows are inspectable immediately after migration but are not treated as canonical during a later full replay.
79
79
 
@@ -0,0 +1,89 @@
1
+ # DS4 Context Engine 0.3.0-alpha.1
2
+
3
+ Status: published prerelease on 2026-08-27; tag `v0.3.0-alpha.1`.
4
+
5
+ This prerelease adds a confirmation-gated, model-callable persistence surface while preserving the stable 0.2 canonical and projection contracts.
6
+
7
+ ## Added
8
+
9
+ - `context_persistence` with contract `ds4-context-persistence-tool-v1` and sequential execution.
10
+ - Result envelope `ds4-context-persistence-result-v1` with bounded metadata-only read and mutation DTOs.
11
+ - Fourteen actions covering Pin/Memory list, find, canonical mutations, project-memory sources, and derived source include/exclude policy.
12
+ - Bounded keyset repository APIs, exact visible-item reads, scan caps, stable process-local revisions, and volatile project source references.
13
+ - Local Pi UI confirmation for every model-callable write; all writes fail closed with `confirmation-required` when no UI is available.
14
+ - Active-branch provenance derivation and post-confirmation revalidation for content-bearing mutations.
15
+ - Fail-closed `runtime-unavailable` behavior when no persistent Pi session JSONL destination exists.
16
+ - Tracked canonical append outcomes distinguishing committed state, projection pending, and indeterminate append completion.
17
+ - Provider-specific historical tool-call/result sanitization for Anthropic-, Google-, and generic-shaped payloads.
18
+ - Integration coverage for real extension registration, Pi append-only custom entries, projection, lifecycle replay, derived-policy reset, and metadata-only logging.
19
+
20
+ ## Persistence guarantees
21
+
22
+ Canonical Pin and Memory writes continue to use the unchanged records:
23
+
24
+ ```text
25
+ ds4-context-pin-v1
26
+ ds4-context-memory-v1
27
+ ```
28
+
29
+ They pass through `Ds4ContextRuntime` and `pi.appendEntry()` before projection reconciliation. SQLite remains disposable and rebuildable. Project-memory source exclusion remains derived SQLite policy and deliberately disappears when the database is deleted. No migration was added; schema remains 15 and migrations 1–15 are unchanged.
30
+
31
+ The reference adapter remains on its append-only `ds4-runtime-session-v1` history contract. Local-KV handles/state, ranking models, revisions, source-reference mappings, SQLite projections, and source exclusion policy remain local and non-canonical.
32
+
33
+ ## Authorization and privacy
34
+
35
+ - Ordinary conversation never creates a Pin or Memory automatically.
36
+ - Targeted writes require a prior exact ID/reference and `targetRevision`; fuzzy writes are rejected by construction.
37
+ - Confirmation is obtained only from `ctx.ui.confirm()` and is revalidated against current provider, trust, provenance, capability, and target state before dispatch.
38
+ - Explicit supersession cannot lower the target's effective classification. Markers and credential-like detection may only elevate it.
39
+ - Tool results, historical arguments/results, errors, diagnostics, and logs are bounded and allowlisted. Content, claims, keys, reasons, paths, source identity, raw errors, and confirmation text are not echoed.
40
+ - `local-only` is denied to remote/unknown providers and is never presented as proof that prior input stayed local.
41
+
42
+ ## Known alpha.1 limitation
43
+
44
+ The published alpha.1 historical sanitizer replaces sensitive tool arguments with `[omitted-by-ds4-egress-policy]`. If a model copies that output-only marker into a later write—most plausibly after a cancelled confirmation—alpha.1 can show a new confirmation for the literal marker. Dogfooders must refuse that dialog; accepting it can append the marker as content or metadata, although it does not recover the omitted value. Current source rejects any incoming string argument containing the marker as `egress-placeholder` before confirmation or persistence. Publishing that hardening requires a new prerelease version; the immutable alpha.1 package is not replaced.
45
+
46
+ ## Package/version policy
47
+
48
+ The coordinated prerelease version is `0.3.0-alpha.1` for:
49
+
50
+ ```text
51
+ ds4-context-core
52
+ ds4-context-reference-adapter
53
+ ds4-context-engine
54
+ ```
55
+
56
+ Both adapters retain an exact dependency on `ds4-context-core@0.3.0-alpha.1`. The packages were published manually under the explicit npm `alpha` dist-tag, while `latest` continues to resolve to stable `0.2.0`; GitHub Actions remains validation-only with OIDC and package-write permissions denied.
57
+
58
+ ## Validation evidence
59
+
60
+ Latest local verification:
61
+
62
+ - `npm run check`: 64 files, 283 tests passed.
63
+ - `npm run quality:compare`: candidate quality score `0.9875` versus static baseline `0.808156` on `ds4-quality-corpus-v1`.
64
+ - `npm run schema:context-persistence`: 1,197 bytes, 300 estimated tokens; below both the 1,500-token absolute and 320-token relative gates.
65
+ - `npm run latency:check -- <exact ds4-context-core@0.1.2>`: isolated run ratio `0.880399`, below `1.10` (`0.495890` ms baseline p95, `0.436581` ms candidate p95).
66
+ - `npm run pack:check`: verified `ds4-context-core@0.3.0-alpha.1` (203 files), `ds4-context-reference-adapter@0.3.0-alpha.1` (7 files), and `ds4-context-engine@0.3.0-alpha.1` (57 files) in a clean consumer.
67
+ - `npm pack --dry-run --json` for all three packages: passed with the same bounded inventories.
68
+ - `npm run registry:check -- 0.3.0-alpha.1`: passed against all three exact published versions; `alpha` resolves to `0.3.0-alpha.1` and `latest` remains `0.2.0` for every package.
69
+ - The complete candidate change set was replayed onto a detached clean checkout at `f130115`; offline install, `npm run check`, schema gate, package verification, and `git diff --check` all passed there.
70
+ - `git diff --check`: passed.
71
+
72
+ Isolated Pi `0.84.3` smoke with the configured `openai-codex` provider passed:
73
+
74
+ - TUI read/add and exact-revision unpin committed only after confirmation; refusal produced no append; remote `local-only` input was denied before confirmation.
75
+ - RPC confirmation acceptance/refusal produced the expected committed/cancelled envelopes with no result-content leak; `--no-session` returned `runtime-unavailable` before confirmation. Pi `0.84.3` advertises UI capability even when no RPC UI client answers, so an unanswered request remains pending without append rather than being treated as `confirmation-required`.
76
+ - Print/JSON reads remained available and writes returned `confirmation-required` with no append.
77
+ - A natural explicit persistence request selected `memory_add`; an ordinary suggestion did not call the tool.
78
+
79
+ No live local provider was configured for this smoke; the local-provider privacy path remains covered by automated policy/tool tests. Exact registry verification passed before the annotated tag and GitHub prerelease were created.
80
+
81
+ ## Documentation
82
+
83
+ - [`../CONTEXT_PERSISTENCE_TOOL.md`](../CONTEXT_PERSISTENCE_TOOL.md)
84
+ - [`../DOGFOODING_0.3.0_ALPHA.md`](../DOGFOODING_0.3.0_ALPHA.md)
85
+ - [`../MEMORY_AND_PINS.md`](../MEMORY_AND_PINS.md)
86
+ - [`../PRIVACY.md`](../PRIVACY.md)
87
+ - [`../STORAGE.md`](../STORAGE.md)
88
+ - [`../ARCHITECTURE.md`](../ARCHITECTURE.md)
89
+ - [`../RELEASING.md`](../RELEASING.md)
@@ -0,0 +1,61 @@
1
+ # DS4 Context Engine 0.3.0-alpha.2
2
+
3
+ Status: release candidate approved for manual publication.
4
+
5
+ This coordinated prerelease hardens the `context_persistence` provider-egress boundary discovered during alpha.1 dogfooding. It adds no new persistence format, migration, default-on feature, or model-callable action.
6
+
7
+ ## Fixed
8
+
9
+ - Reserves `[omitted-by-ds4-egress-policy]` as an output-only historical sanitization sentinel.
10
+ - Rejects any incoming string argument containing that sentinel as `egress-placeholder` before runtime access, local confirmation, canonical append, projection, or derived source-policy mutation.
11
+ - Prevents a model from turning a sanitized cancelled-call argument into a new confirmation for the literal omission marker.
12
+ - Centralizes the sentinel constant across the tool contract, historical egress sanitizer, and historical result renderer.
13
+ - Adds a prompt guideline requiring fresh user-provided text instead of reusing an egress omission marker.
14
+
15
+ ## Compatibility and persistence
16
+
17
+ The tool and result contracts remain `ds4-context-persistence-tool-v1` and `ds4-context-persistence-result-v1`. The fourteen actions and sequential execution contract are unchanged. `egress-placeholder` is an additive validation error code.
18
+
19
+ Canonical Pin and Memory records remain `ds4-context-pin-v1` and `ds4-context-memory-v1`. Pi JSONL remains canonical and append-only; SQLite remains a disposable projection. Schema 15, migrations 1–15, `ds4-context-config-v1`, `runtime-adapter-v1`, and the reference adapter's append-only `ds4-runtime-session-v1` history contract are unchanged.
20
+
21
+ ## Package/version policy
22
+
23
+ The coordinated version is `0.3.0-alpha.2` for:
24
+
25
+ ```text
26
+ ds4-context-core
27
+ ds4-context-reference-adapter
28
+ ds4-context-engine
29
+ ```
30
+
31
+ Both adapters depend exactly on `ds4-context-core@0.3.0-alpha.2`. Publication uses the explicit npm `alpha` dist-tag; `latest` must remain `0.2.0`. GitHub Actions remains validation-only with OIDC and package-write permissions denied.
32
+
33
+ ## Dogfood evidence
34
+
35
+ The operator completed the alpha.1 TUI/RPC/print/JSON dogfood matrix, including canonical append audits, with one identified issue: a sanitized historical marker could be copied into a retry. Alpha.2 addresses that finding. Automated unit and extension-integration tests prove the marker retry is rejected before a second confirmation, runtime mutation, projection, or canonical append. The published alpha.2 artifact should receive the focused regression procedure in [`../DOGFOODING_0.3.0_ALPHA.md`](../DOGFOODING_0.3.0_ALPHA.md) before beta promotion.
36
+
37
+ ## Validation evidence
38
+
39
+ Local candidate verification on Node.js `26.5.1`:
40
+
41
+ - `npm ci`: passed.
42
+ - `npm run check`: 64 files and 286 tests passed.
43
+ - `npm run quality:compare`: candidate quality `0.9875` versus baseline `0.808156`.
44
+ - `npm run schema:context-persistence`: 1,197 bytes and 300 estimated tokens; below the 1,500 absolute and 320 relative limits.
45
+ - `npm run latency:check -- <exact ds4-context-core@0.1.2>`: after one anomalous noisy sample, three consecutive repetitions passed with ratios `1.051605`, `1.059638`, and `1.087275`, all at or below `1.10`.
46
+ - `npm run pack:check`: verified core (203 files), reference adapter (7 files), and Pi adapter (59 files) in a clean consumer.
47
+ - `npm pack --dry-run --json` for all three packages: passed with the same bounded inventories.
48
+ - The committed candidate was replayed from detached clean checkout `30a5f0f`; `npm ci`, the 64-file/286-test suite, schema gate, package verification, and `git diff --check` passed.
49
+ - `git diff --check`: passed.
50
+ - Protected CI, compatibility golden, Pi fixture, and migration files: unchanged.
51
+
52
+ Exact registry verification, annotated tag, and GitHub prerelease creation remain post-publication gates.
53
+
54
+ ## Documentation
55
+
56
+ - [`../CONTEXT_PERSISTENCE_TOOL.md`](../CONTEXT_PERSISTENCE_TOOL.md)
57
+ - [`../DOGFOODING_0.3.0_ALPHA.md`](../DOGFOODING_0.3.0_ALPHA.md)
58
+ - [`../PRIVACY.md`](../PRIVACY.md)
59
+ - [`../MEMORY_AND_PINS.md`](../MEMORY_AND_PINS.md)
60
+ - [`../RELEASING.md`](../RELEASING.md)
61
+ - [`0.3.0-alpha.1.md`](0.3.0-alpha.1.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ds4-context-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.0-alpha.2",
4
4
  "description": "Non-destructive, provider-independent context management for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,6 +46,7 @@
46
46
  "test:watch": "npm run build:core && npm run build:adapters && vitest",
47
47
  "check": "npm run build:core && npm run build:adapters && tsc --noEmit && vitest run",
48
48
  "quality:compare": "node scripts/compare-context-quality.mjs",
49
+ "schema:context-persistence": "node scripts/measure-context-persistence-schema.mjs",
49
50
  "latency:check": "npm run build:core && node scripts/compare-disabled-planning-latency.mjs",
50
51
  "pack:check": "node scripts/verify-packages.mjs",
51
52
  "registry:check": "node scripts/verify-registry-packages.mjs",
@@ -57,7 +58,7 @@
57
58
  ]
58
59
  },
59
60
  "dependencies": {
60
- "ds4-context-core": "0.2.0"
61
+ "ds4-context-core": "0.3.0-alpha.2"
61
62
  },
62
63
  "peerDependencies": {
63
64
  "@earendil-works/pi-ai": "0.84.3",
@@ -0,0 +1,159 @@
1
+ import { StringEnum, Type, type Static } from "@earendil-works/pi-ai";
2
+
3
+ export const CONTEXT_PERSISTENCE_TOOL_CONTRACT = "ds4-context-persistence-tool-v1" as const;
4
+ export const CONTEXT_PERSISTENCE_RESULT_CONTRACT = "ds4-context-persistence-result-v1" as const;
5
+ export const CONTEXT_PERSISTENCE_EGRESS_SENTINEL = "[omitted-by-ds4-egress-policy]" as const;
6
+ export const CONTEXT_PERSISTENCE_TOOL_NAME = "context_persistence" as const;
7
+ export const CONTEXT_PERSISTENCE_DESCRIPTION = "Inspect DS4 Pins/Memory. Write only after an explicit user request; writes require local user confirmation." as const;
8
+ export const CONTEXT_PERSISTENCE_PROMPT_SNIPPET = "Inspect or manage user-confirmed DS4 pins and durable memory" as const;
9
+ export const CONTEXT_PERSISTENCE_PROMPT_GUIDELINES = [
10
+ "Use context_persistence only to inspect DS4 persistent state or when the user explicitly requests a persistence mutation.",
11
+ "After an explicit persistence request, call the write action directly; context_persistence itself obtains the required local UI confirmation, so do not ask for separate confirmation in chat.",
12
+ "Never reuse an egress omission marker as tool input; use fresh user-provided text or ask the user to restate it.",
13
+ "Never create a pin or memory merely because information appears useful.",
14
+ "Use pins for confirmed constraints or instructions that must remain prominent. Use memory for durable facts, decisions, and historical knowledge.",
15
+ "Default new persistence to session scope. Use project or branch scope only when explicitly requested or unambiguous; durable Memory does not support branch scope.",
16
+ "Before superseding, removing, invalidating, expiring, excluding, or including, read first and use the exact ID or reference plus targetRevision; never mutate from a fuzzy query.",
17
+ "Do not claim local-only protection if a remote model already saw the input; use /context or a local provider for data that must never reach a remote provider.",
18
+ ] as const;
19
+
20
+ export const CONTEXT_PERSISTENCE_ACTIONS = [
21
+ "pins_list",
22
+ "pins_find",
23
+ "pin_add",
24
+ "pin_supersede",
25
+ "pin_unpin",
26
+ "memory_list",
27
+ "memory_find",
28
+ "memory_add",
29
+ "memory_supersede",
30
+ "memory_invalidate",
31
+ "memory_expire",
32
+ "memory_sources",
33
+ "memory_source_exclude",
34
+ "memory_source_include",
35
+ ] as const;
36
+
37
+ export type ContextPersistenceAction = typeof CONTEXT_PERSISTENCE_ACTIONS[number];
38
+
39
+ export const CONTEXT_PERSISTENCE_READ_ACTIONS = [
40
+ "pins_list",
41
+ "pins_find",
42
+ "memory_list",
43
+ "memory_find",
44
+ "memory_sources",
45
+ ] as const satisfies readonly ContextPersistenceAction[];
46
+
47
+ export type ContextPersistenceReadAction = typeof CONTEXT_PERSISTENCE_READ_ACTIONS[number];
48
+
49
+ export const CONTEXT_PERSISTENCE_WRITE_ACTIONS = CONTEXT_PERSISTENCE_ACTIONS.filter(
50
+ (action): action is Exclude<ContextPersistenceAction, ContextPersistenceReadAction> =>
51
+ !(CONTEXT_PERSISTENCE_READ_ACTIONS as readonly string[]).includes(action),
52
+ );
53
+
54
+ export const CONTEXT_PERSISTENCE_PARAMS = Type.Object({
55
+ action: StringEnum(CONTEXT_PERSISTENCE_ACTIONS),
56
+ scope: Type.Optional(StringEnum(["session", "branch", "project"] as const, {
57
+ description: "Add only; Memory excludes branch",
58
+ })),
59
+ id: Type.Optional(Type.String({ minLength: 1, maxLength: 128 })),
60
+ targetRevision: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
61
+ content: Type.Optional(Type.String({ minLength: 1, maxLength: 20_000 })),
62
+ key: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
63
+ query: Type.Optional(Type.String({ minLength: 2, maxLength: 200 })),
64
+ reason: Type.Optional(Type.String({
65
+ minLength: 1,
66
+ maxLength: 500,
67
+ description: "Lifecycle or source exclusion only",
68
+ })),
69
+ classification: Type.Optional(StringEnum([
70
+ "normal",
71
+ "internal",
72
+ "sensitive",
73
+ "local-only",
74
+ ] as const)),
75
+ activeOnly: Type.Optional(Type.Boolean()),
76
+ maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
77
+ }, { additionalProperties: false });
78
+
79
+ export type ContextPersistenceParams = Static<typeof CONTEXT_PERSISTENCE_PARAMS>;
80
+
81
+ const ALLOWED_FIELDS = {
82
+ pins_list: ["action", "activeOnly", "maxResults"],
83
+ pins_find: ["action", "query", "activeOnly", "maxResults"],
84
+ pin_add: ["action", "content", "scope", "classification"],
85
+ pin_supersede: ["action", "id", "targetRevision", "content", "classification"],
86
+ pin_unpin: ["action", "id", "targetRevision", "reason"],
87
+ memory_list: ["action", "activeOnly", "maxResults"],
88
+ memory_find: ["action", "query", "activeOnly", "maxResults"],
89
+ memory_add: ["action", "content", "scope", "key", "classification"],
90
+ memory_supersede: ["action", "id", "targetRevision", "content", "classification"],
91
+ memory_invalidate: ["action", "id", "targetRevision", "reason"],
92
+ memory_expire: ["action", "id", "targetRevision", "reason"],
93
+ memory_sources: ["action", "maxResults"],
94
+ memory_source_exclude: ["action", "id", "targetRevision", "reason"],
95
+ memory_source_include: ["action", "id", "targetRevision"],
96
+ } as const satisfies Record<ContextPersistenceAction, readonly (keyof ContextPersistenceParams)[]>;
97
+
98
+ const REQUIRED_FIELDS = {
99
+ pins_list: [],
100
+ pins_find: ["query"],
101
+ pin_add: ["content"],
102
+ pin_supersede: ["id", "targetRevision", "content"],
103
+ pin_unpin: ["id", "targetRevision"],
104
+ memory_list: [],
105
+ memory_find: ["query"],
106
+ memory_add: ["content"],
107
+ memory_supersede: ["id", "targetRevision", "content"],
108
+ memory_invalidate: ["id", "targetRevision"],
109
+ memory_expire: ["id", "targetRevision"],
110
+ memory_sources: [],
111
+ memory_source_exclude: ["id", "targetRevision"],
112
+ memory_source_include: ["id", "targetRevision"],
113
+ } as const satisfies Record<ContextPersistenceAction, readonly (keyof ContextPersistenceParams)[]>;
114
+
115
+ export type ContextPersistenceValidationCode =
116
+ | "invalid-parameters"
117
+ | "invalid-scope"
118
+ | "egress-placeholder";
119
+
120
+ export type ContextPersistenceValidation =
121
+ | { ok: true; value: ContextPersistenceParams }
122
+ | { ok: false; errorCode: ContextPersistenceValidationCode };
123
+
124
+ function isAction(value: unknown): value is ContextPersistenceAction {
125
+ return typeof value === "string"
126
+ && (CONTEXT_PERSISTENCE_ACTIONS as readonly string[]).includes(value);
127
+ }
128
+
129
+ export function isReadAction(action: ContextPersistenceAction): action is ContextPersistenceReadAction {
130
+ return (CONTEXT_PERSISTENCE_READ_ACTIONS as readonly string[]).includes(action);
131
+ }
132
+
133
+ /**
134
+ * Action-specific validation performed after TypeBox transport validation.
135
+ * Values are never included in failures so provider-visible errors stay metadata-only.
136
+ */
137
+ export function validateContextPersistenceParams(
138
+ params: ContextPersistenceParams,
139
+ ): ContextPersistenceValidation {
140
+ if (!params || !isAction(params.action)) return { ok: false, errorCode: "invalid-parameters" };
141
+ const keys = Object.keys(params) as (keyof ContextPersistenceParams)[];
142
+ const allowed = new Set<keyof ContextPersistenceParams>(ALLOWED_FIELDS[params.action]);
143
+ if (keys.some((key) => !allowed.has(key))) {
144
+ return { ok: false, errorCode: "invalid-parameters" };
145
+ }
146
+ if (REQUIRED_FIELDS[params.action].some((key) => params[key] === undefined)) {
147
+ return { ok: false, errorCode: "invalid-parameters" };
148
+ }
149
+ if (keys.some((key) => {
150
+ const value = params[key];
151
+ return typeof value === "string" && value.includes(CONTEXT_PERSISTENCE_EGRESS_SENTINEL);
152
+ })) {
153
+ return { ok: false, errorCode: "egress-placeholder" };
154
+ }
155
+ if (params.scope === "branch" && params.action === "memory_add") {
156
+ return { ok: false, errorCode: "invalid-scope" };
157
+ }
158
+ return { ok: true, value: params };
159
+ }
@@ -0,0 +1,224 @@
1
+ import {
2
+ CONTEXT_PERSISTENCE_ACTIONS,
3
+ CONTEXT_PERSISTENCE_EGRESS_SENTINEL,
4
+ CONTEXT_PERSISTENCE_TOOL_NAME,
5
+ } from "./context-persistence-contract.ts";
6
+ import {
7
+ renderHistoricalContextPersistenceResult,
8
+ sanitizeHistoricalContextPersistenceDetails,
9
+ } from "./context-persistence-result.ts";
10
+
11
+ export { CONTEXT_PERSISTENCE_EGRESS_SENTINEL } from "./context-persistence-contract.ts";
12
+
13
+ const ACTIONS = new Set<string>(CONTEXT_PERSISTENCE_ACTIONS);
14
+ const SAFE_ARGUMENT_KEYS = new Set([
15
+ "action",
16
+ "scope",
17
+ "id",
18
+ "targetRevision",
19
+ "classification",
20
+ "activeOnly",
21
+ "maxResults",
22
+ ]);
23
+ const SENSITIVE_ARGUMENT_KEYS = new Set(["content", "query", "key", "reason"]);
24
+ const OPAQUE_ID = /^(?:source_[A-Za-z0-9_-]{22,57}|[A-Za-z0-9._:-]{1,128})$/u;
25
+ const REVISION = /^rev_[A-Za-z0-9_-]{1,60}$/u;
26
+ const SAFE_ENUM = /^(?:session|branch|project|normal|internal|sensitive|local-only)$/u;
27
+ const SAFE_METADATA_LINE = /^(?:pin|memory|project-memory-source) (?:source_[A-Za-z0-9_-]{22,57}|[A-Za-z0-9._:-]{1,128})(?:; (?:scope|status|classification|applicable|createdAt|updatedAt|revision|match|score|indexedMutations|activeProjectMemories|activeProjectPins|hasMalformedLines|error)=[A-Za-z0-9_-]+)+$/u;
28
+ const SAFE_SUMMARY_LINE = /^(?:pins_list|pins_find|memory_list|memory_find|memory_sources): \d{1,3} (?:item\(s\)|match\(es\)); truncated=(?:true|false); incomplete=(?:true|false)\.$/u;
29
+ const PREVIEW_LINE = /^(pin|memory) ([\x21-\x7e]{1,128}): /u;
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return value !== null && typeof value === "object" && !Array.isArray(value);
33
+ }
34
+
35
+ function safeArgumentValue(key: string, value: unknown): unknown {
36
+ if (key === "action") return typeof value === "string" && ACTIONS.has(value)
37
+ ? value
38
+ : CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
39
+ if (key === "activeOnly") return typeof value === "boolean"
40
+ ? value
41
+ : CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
42
+ if (key === "maxResults") return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 100
43
+ ? value
44
+ : CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
45
+ if (key === "scope" || key === "classification") {
46
+ return typeof value === "string" && SAFE_ENUM.test(value)
47
+ ? value
48
+ : CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
49
+ }
50
+ if (key === "id") {
51
+ return typeof value === "string" && OPAQUE_ID.test(value)
52
+ ? value
53
+ : CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
54
+ }
55
+ if (key === "targetRevision") {
56
+ return typeof value === "string" && REVISION.test(value)
57
+ ? value
58
+ : CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
59
+ }
60
+ return CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
61
+ }
62
+
63
+ /** Arguments are reduced without retaining raw content in temporary result objects. */
64
+ export function sanitizeHistoricalContextPersistenceArguments(value: unknown): Record<string, unknown> {
65
+ if (!isRecord(value)) return {};
66
+ const sanitized: Record<string, unknown> = {};
67
+ for (const [key, candidate] of Object.entries(value)) {
68
+ if (SAFE_ARGUMENT_KEYS.has(key)) sanitized[key] = safeArgumentValue(key, candidate);
69
+ else if (SENSITIVE_ARGUMENT_KEYS.has(key)) sanitized[key] = CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
70
+ else sanitized[key] = CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
71
+ }
72
+ return sanitized;
73
+ }
74
+
75
+ function sanitizeArgumentContainer(value: unknown): unknown {
76
+ if (typeof value === "string") {
77
+ try {
78
+ return JSON.stringify(sanitizeHistoricalContextPersistenceArguments(JSON.parse(value)));
79
+ } catch {
80
+ return JSON.stringify({ omitted: CONTEXT_PERSISTENCE_EGRESS_SENTINEL });
81
+ }
82
+ }
83
+ return sanitizeHistoricalContextPersistenceArguments(value);
84
+ }
85
+
86
+ /** Preserves only the fixed metadata grammar emitted by the V1 result renderer. */
87
+ export function sanitizeHistoricalContextPersistenceText(value: unknown): string {
88
+ if (typeof value !== "string" || value.length > 96 * 1024) return CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
89
+ const output: string[] = [];
90
+ for (const line of value.split("\n").slice(0, 205)) {
91
+ if (SAFE_SUMMARY_LINE.test(line)
92
+ || SAFE_METADATA_LINE.test(line)
93
+ || line === "Search incomplete; refine the query or use an exact opaque ID.") {
94
+ output.push(line);
95
+ continue;
96
+ }
97
+ const preview = PREVIEW_LINE.exec(line);
98
+ if (preview) {
99
+ output.push(`${preview[1]} ${preview[2]}: preview omitted by policy`);
100
+ continue;
101
+ }
102
+ if (/^(?:pins_|pin_|memory_)[a-z_]+ (?:ok|rejected|cancelled|unavailable|committed|committed_projection_pending|indeterminate)(?:: [a-z0-9-]{1,64})?\.$/u.test(line)) {
103
+ output.push(line);
104
+ continue;
105
+ }
106
+ output.push(CONTEXT_PERSISTENCE_EGRESS_SENTINEL);
107
+ }
108
+ return output.join("\n") || CONTEXT_PERSISTENCE_EGRESS_SENTINEL;
109
+ }
110
+
111
+ function toolName(record: Record<string, unknown>): string | undefined {
112
+ if (typeof record.name === "string") return record.name;
113
+ if (typeof record.toolName === "string") return record.toolName;
114
+ return undefined;
115
+ }
116
+
117
+ function identifiers(record: Record<string, unknown>): string[] {
118
+ return [
119
+ record.id,
120
+ record.call_id,
121
+ record.toolCallId,
122
+ record.tool_call_id,
123
+ record.toolUseId,
124
+ record.tool_use_id,
125
+ ].filter((value): value is string => typeof value === "string");
126
+ }
127
+
128
+ function collectToolCallIds(value: unknown, ids: Set<string>, seen: WeakSet<object>): void {
129
+ if (!value || typeof value !== "object" || seen.has(value)) return;
130
+ seen.add(value);
131
+ if (Array.isArray(value)) {
132
+ for (const child of value) collectToolCallIds(child, ids, seen);
133
+ return;
134
+ }
135
+ const record = value as Record<string, unknown>;
136
+ if (toolName(record) === CONTEXT_PERSISTENCE_TOOL_NAME) {
137
+ for (const id of identifiers(record)) ids.add(id);
138
+ }
139
+ for (const child of Object.values(record)) collectToolCallIds(child, ids, seen);
140
+ }
141
+
142
+ function sanitizeContent(value: unknown, safeText: string): unknown {
143
+ if (typeof value === "string") return safeText;
144
+ if (!Array.isArray(value)) return [{ type: "text", text: safeText }];
145
+ return [{ type: "text", text: safeText }];
146
+ }
147
+
148
+ function sanitizeNode(
149
+ value: unknown,
150
+ toolCallIds: ReadonlySet<string>,
151
+ seen: WeakMap<object, unknown>,
152
+ ): { value: unknown; changed: boolean } {
153
+ if (!value || typeof value !== "object") return { value, changed: false };
154
+ const prior = seen.get(value);
155
+ if (prior !== undefined) return { value: prior, changed: false };
156
+ if (Array.isArray(value)) {
157
+ const output: unknown[] = [];
158
+ seen.set(value, output);
159
+ let changed = false;
160
+ for (const child of value) {
161
+ const sanitized = sanitizeNode(child, toolCallIds, seen);
162
+ output.push(sanitized.value);
163
+ changed ||= sanitized.changed;
164
+ }
165
+ return changed ? { value: output, changed: true } : { value, changed: false };
166
+ }
167
+
168
+ const record = value as Record<string, unknown>;
169
+ const directToolCall = toolName(record) === CONTEXT_PERSISTENCE_TOOL_NAME;
170
+ const linkedResult = identifiers(record).some((id) => toolCallIds.has(id))
171
+ || (record.role === "toolResult" && record.toolName === CONTEXT_PERSISTENCE_TOOL_NAME);
172
+ let changed = false;
173
+ const output: Record<string, unknown> = {};
174
+ seen.set(value, output);
175
+
176
+ for (const [key, child] of Object.entries(record)) {
177
+ if (directToolCall && (key === "arguments" || key === "args" || key === "input")) {
178
+ output[key] = sanitizeArgumentContainer(child);
179
+ changed = true;
180
+ continue;
181
+ }
182
+ if (linkedResult && key === "details") {
183
+ const details = sanitizeHistoricalContextPersistenceDetails(child);
184
+ output[key] = details ?? {};
185
+ changed = true;
186
+ continue;
187
+ }
188
+ if (linkedResult && (key === "content" || key === "output")) {
189
+ const details = sanitizeHistoricalContextPersistenceDetails(record.details);
190
+ const text = details
191
+ ? renderHistoricalContextPersistenceResult(details)
192
+ : sanitizeHistoricalContextPersistenceText(
193
+ typeof child === "string"
194
+ ? child
195
+ : Array.isArray(child)
196
+ ? child.flatMap((block) => isRecord(block) && typeof block.text === "string" ? [block.text] : []).join("\n")
197
+ : undefined,
198
+ );
199
+ output[key] = sanitizeContent(child, text);
200
+ changed = true;
201
+ continue;
202
+ }
203
+ if (linkedResult && key === "response") {
204
+ const response = isRecord(child) ? child : {};
205
+ const responseKey = "error" in response ? "error" : "output";
206
+ output[key] = {
207
+ [responseKey]: sanitizeHistoricalContextPersistenceText(response[responseKey]),
208
+ };
209
+ changed = true;
210
+ continue;
211
+ }
212
+ const sanitized = sanitizeNode(child, toolCallIds, seen);
213
+ output[key] = sanitized.value;
214
+ changed ||= sanitized.changed;
215
+ }
216
+ return changed ? { value: output, changed: true } : { value, changed: false };
217
+ }
218
+
219
+ export function sanitizeContextPersistenceHistory<T>(value: T): { value: T; changed: boolean } {
220
+ const ids = new Set<string>();
221
+ collectToolCallIds(value, ids, new WeakSet());
222
+ const sanitized = sanitizeNode(value, ids, new WeakMap());
223
+ return { value: sanitized.value as T, changed: sanitized.changed };
224
+ }