ds4-context-engine 0.3.1 → 0.3.3

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.
@@ -13,7 +13,7 @@ DS4 intercepts Pi's `session_before_compact` event but preserves Pi's cut-point
13
13
  7. The highest input classification is wrapped around each generated node, then all nodes are persisted atomically as one `prepared` graph batch. Usage is summed across every segment and aggregate request; Pi receives only the final root text and still appends exactly one canonical `CompactionEntry` with `fromHook: true`.
14
14
  8. `session_compact` commits all nodes and associates the active root with the Pi entry; failure marks the complete prepared batch `failed`.
15
15
 
16
- Fan-out and fan-in are bounded to 32 segment requests, 64 aggregate requests, and 16 aggregate passes. Transport replay is independently bounded to three attempts per segment or aggregate call and does not retry input, usage, rate, authentication, validation, or output-limit failures. A base prompt, individual message, atomic tool exchange, pair of child summaries, or total operation that cannot fit within those limits fails closed. Any mapping, budget, model, output-limit, validation, abort, or storage error returns `undefined` from the hook, allowing Pi's default compaction to run.
16
+ Fan-out and fan-in are bounded to 32 segment requests, 64 aggregate requests, and 16 aggregate passes. Transport replay mirrors Pi's assistant retry policy: `compaction.transport.maxAttempts` (default 3) total attempts and `compaction.transport.baseDelayMs` (default 2000 ms, capped at 60 s) backoff, doubling per attempt, abort-aware. Replay never applies to input, usage, rate, authentication, validation, or output-limit failures. A base prompt, individual message, atomic tool exchange, pair of child summaries, or total operation that cannot fit within those limits fails closed. Any mapping, budget, model, output-limit, validation, abort, or storage error returns `undefined` from the hook, allowing Pi's default compaction to run.
17
17
 
18
18
  ## Required summary contract
19
19
 
@@ -78,6 +78,26 @@ Semantics:
78
78
  - `compaction.summary.thinking` defaults to `off` and applies only to summary requests: `off` keeps the pre-existing request shape (no thinking fields), while other levels map per API (`thinkingEnabled`/`effort` for `anthropic-messages`, `samplingParams.reasoning_effort` for OpenAI-compatible APIs) and are ignored for unsupported providers;
79
79
  - `context.maxSummaryTokens` remains a session-level limit and does not rise for the dedicated model; the minimum with the model's `maxTokens` still applies.
80
80
 
81
+ ## Transport retry policy
82
+
83
+ Summary requests are replayed only for transport-classified failures (thrown transport errors or `stopReason: "error"` responses whose message matches network/timeout patterns). The replay policy defaults to Pi's assistant retry policy and can be tuned per deployment:
84
+
85
+ ```json
86
+ {
87
+ "compaction": {
88
+ "transport": {
89
+ "maxAttempts": 3,
90
+ "baseDelayMs": 2000
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ - `compaction.transport.maxAttempts`: total attempts per segment or aggregate call, integer 1–10, default 3. With 1, no transport failure is retried.
97
+ - `compaction.transport.baseDelayMs`: base backoff before the first replay, integer 0–60000, default 2000. The delay doubles per attempt (2000, 4000, 8000, …) and is capped at 60 s.
98
+ - Replays use a fresh routing session per attempt; diagnostics expose only stage, failed/next attempt, max attempts, and delay.
99
+ - Aborts (including during backoff) never trigger replay; non-transport failures are never retried; usage is summed across replayed responses.
100
+
81
101
  ## Proactive trigger
82
102
 
83
103
  After a settled turn, DS4 computes:
@@ -63,7 +63,17 @@ Validation-only CI is recorded below with the release commit. Exact registry ver
63
63
 
64
64
  ## Registry evidence
65
65
 
66
- To be completed after publication.
66
+ Exact post-publication verification passed for all three `0.3.1` packages on 2026-09-04. The clean registry consumer verified exact adapter/core dependencies, public core exports, compiled reference-adapter conformance, packaged quality corpus, packaged Pi extension startup, and the installed `ds4-context-storage` CLI declaration, shim, and usage probe.
67
+
68
+ Published package shasums:
69
+
70
+ ```text
71
+ ds4-context-core: 94607bb735653da5ce533a502d45edd5b323242e
72
+ ds4-context-reference-adapter: ff0257ccb7614f7cf068b9b0dff912a91594f66d
73
+ ds4-context-engine: 623beaac038053e48dcb0f2e3f49587e48206490
74
+ ```
75
+
76
+ For all three packages, npm `latest` now resolves to `0.3.1`, `beta` remains `0.3.0-beta.3`, `alpha` remains `0.3.0-alpha.5`, and `rc` remains `0.2.0-rc.1`.
67
77
 
68
78
  ## Documentation
69
79
 
@@ -0,0 +1,79 @@
1
+ # DS4 Context Engine 0.3.2
2
+
3
+ Status: published 2026-09-04 as stable, printed under the npm `latest` dist-tag.
4
+
5
+ This release adds the `/context config` local command family on top of the 0.3.1 stable candidate. It carries forward 0.3.1 without changing canonical records, SQLite schema, runtime contracts, retention limits, privacy policy, compaction validation, or fallback semantics.
6
+
7
+ ## Added since 0.3.1
8
+
9
+ - **`/context config` command family** (view / set / unset):
10
+ - `view` prints the effective DS4 configuration as a typed table: every key of `ds4-context-config-v1` with its path, active value, fallback value, kind, allowed enums, and optional marker; plus the global and project file paths, which files were loaded, and any configuration warnings;
11
+ - `set <path> <value> [--global]` writes a typed value to the project file `.pi/ds4-context.json` by default, or to the agent directory global file with `--global`; all values are catalog-driven and converted to the correct type (boolean / integer / number / string / enum / arrays / maps / object), so invalid edits are rejected before writing with a clear message; project edits require a trusted project, untrusted projects must use `--global`;
12
+ - `unset <path> [--global]` removes an explicit key and falls back to the default:
13
+ - `validateConfigFile` rejects any configuration that would fail the loader; warnings are reported but never block the write;
14
+ - boolean flags are supported in the command parser (`--global` without a value);
15
+ - the change applies from the next Pi session; the active session keeps the previous configuration and the command says so explicitly;
16
+ - machine-readable catalog in `packages/core/src/config/config-catalog.ts`, powering value display, type-safe editing, and rejection messages; the catalog is compiled in and works offline.
17
+ - Coordinated package versions moved to `0.3.2`.
18
+
19
+ There are no functional storage-format or provider-path changes relative to 0.3.1.
20
+
21
+ ## Safety and compatibility
22
+
23
+ Unchanged from 0.3.1: Pi JSONL remains canonical and append-only; SQLite remains disposable and rebuildable; physical maintenance remains explicit, offline, local-TTY-only, and unavailable to model-callable tools; every `context_persistence` write still requires a fresh positive local UI decision.
24
+
25
+ Compatibility remains unchanged:
26
+
27
+ - SQLite schema: `15`;
28
+ - configuration: `ds4-context-config-v1` (no new keys; only authoring tools added);
29
+ - runtime adapter: `runtime-adapter-v1`;
30
+ - persistence tool: `ds4-context-persistence-tool-v1`;
31
+ - persistence result: `ds4-context-persistence-result-v1`;
32
+ - Pi: `0.84.3`;
33
+ - Node.js: `>=22.19.0`.
34
+
35
+ ## Package/version policy
36
+
37
+ The coordinated version is `0.3.2` for:
38
+
39
+ ```text
40
+ ds4-context-core
41
+ ds4-context-reference-adapter
42
+ ds4-context-engine
43
+ ```
44
+
45
+ Both adapters depend exactly on `ds4-context-core@0.3.2`. Publication uses npm's default `latest` tag for all three packages: `latest` now resolves to `0.3.2` (previously `0.3.1`), `beta` remains `0.3.0-beta.3`, `alpha` remains `0.3.0-alpha.5`, and `rc` remains `0.2.0-rc.1`. GitHub Actions remains validation-only with OIDC and package-write permissions denied.
46
+
47
+ ## Candidate validation
48
+
49
+ Local candidate verification on Node.js `26.5.1`:
50
+
51
+ - `npm run check`: 71 files and 364 tests passed (including the config-catalog unit tests, extended config-loader tests, and the `/context config` integration coverage).
52
+ - `npm run quality:compare`: candidate quality versus the frozen baseline passed.
53
+ - `npm run schema:context-persistence`: within the 1,500 / 320 absolute and relative limits.
54
+ - `npm run latency:check` against the frozen baseline passed.
55
+ - `npm run pack:check` and `npm pack --dry-run --json` for all three packages passed with no forbidden local/session/storage paths.
56
+ - `git diff --check` passed; the only pre-existing untracked path is `.serena/`, which is excluded from commits and package inventories.
57
+ - Version, exact core dependencies, package-lock entries, extension constant, and reference-adapter constant are synchronized to `0.3.2`.
58
+
59
+ Validation-only CI is recorded below with the release commit. Exact registry verification and the annotated tag are recorded after execution.
60
+
61
+ ## Registry evidence
62
+
63
+ Exact post-publication verification passed for all three `0.3.2` packages on 2026-09-04. The clean registry consumer verified exact adapter/core dependencies, public core exports, compiled reference-adapter conformance, packaged quality corpus, packaged Pi extension startup, and the installed `ds4-context-storage` CLI declaration, shim, and usage probe.
64
+
65
+ Published package shasums:
66
+
67
+ ```text
68
+ ds4-context-core: ff2501ffc1c12713591d97989e708733b7b8735e
69
+ ds4-context-reference-adapter: f7d6e29393e248b89417d44b4c19e8c80cb684c2
70
+ ds4-context-engine: fd77ea3f0cc2885eff34b2702951d6dd2d4c3c14
71
+ ```
72
+
73
+ For all three packages, npm `latest` now resolves to `0.3.2`, `beta` remains `0.3.0-beta.3`, `alpha` remains `0.3.0-alpha.5`, and `rc` remains `0.2.0-rc.1`.
74
+
75
+ ## Documentation
76
+
77
+ - [`../COMPACTION.md`](../COMPACTION.md)
78
+ - [`../RELEASING.md`](../RELEASING.md)
79
+ - [`0.3.1.md`](0.3.1.md)
@@ -0,0 +1,60 @@
1
+ # DS4 Context Engine 0.3.3
2
+
3
+ Status: candidate, not yet published.
4
+
5
+ This release adds a configurable transport retry policy for compaction summary requests on top of the 0.3.2 stable candidate. It carries forward 0.3.2 without changing canonical records, SQLite schema, runtime contracts, retention limits, privacy policy, compaction validation, or fallback semantics.
6
+
7
+ ## Added since 0.3.2
8
+
9
+ - **Configurable compaction transport retry policy, mirroring Pi's assistant retry settings**:
10
+ - `compaction.transport.maxAttempts` (default `3`) and `compaction.transport.baseDelayMs` (default `2000` ms), with exponential backoff doubling per attempt, capped at 60 s per wait, and abort-aware: replay stops promptly when the request is cancelled;
11
+ - policy defaults mirror Pi (`retry.maxRetries` 3, `retry.baseDelayMs` 2000, exponential backoff);
12
+ - validated at config load: `maxAttempts` in `1..10`, `baseDelayMs` in `0..60000` ms; invalid values are rejected before use;
13
+ - `summary-generator` exposes `effectiveTransportPolicy` and `transportRetryDelayMs`, uses the configured `maxAttempts` in retry loops and diagnostics;
14
+ - `compaction-coordinator` threads `config.compaction.transport` through the summary request path;
15
+ - machine-readable catalog fields in `config-catalog.ts`, so the new keys are visible to `/context config view`;
16
+ - tests: unit coverage for policy defaults, backoff sequence, abort awareness, attempt counting, usage aggregation, and config validation; integration retry tests run with `baseDelayMs: 1` plus a new configurable-`maxAttempts` case; the frozen 0.2-line compatibility golden was updated to include the new transport defaults;
17
+ - `docs/COMPACTION.md`: new retry-policy section and revised bounds paragraph.
18
+ - Coordinated package versions moved to `0.3.3`.
19
+
20
+ There are no functional storage-format or provider-path changes relative to 0.3.2.
21
+
22
+ ## Safety and compatibility
23
+
24
+ Unchanged from 0.3.2: Pi JSONL remains canonical and append-only; SQLite remains disposable and rebuildable; physical maintenance remains explicit, offline, local-TTY-only, and unavailable to model-callable tools; every `context_persistence` write still requires a fresh positive local UI decision.
25
+
26
+ Compatibility remains unchanged:
27
+
28
+ - SQLite schema: `15`;
29
+ - configuration: `ds4-context-config-v1` (new keys added: `compaction.transport.maxAttempts`, `compaction.transport.baseDelayMs`);
30
+ - runtime adapter: `runtime-adapter-v1`;
31
+ - persistence tool: `ds4-context-persistence-tool-v1`;
32
+ - persistence result: `ds4-context-persistence-result-v1`;
33
+ - Pi: `0.84.3`;
34
+ - Node.js: `>=22.19.0`.
35
+
36
+ ## Package/version policy
37
+
38
+ The coordinated version is `0.3.3` for:
39
+
40
+ ```text
41
+ ds4-context-core
42
+ ds4-context-reference-adapter
43
+ ds4-context-engine
44
+ ```
45
+
46
+ Both adapters depend exactly on `ds4-context-core@0.3.3`. Publication uses npm's default `latest` tag for all three packages. GitHub Actions remains validation-only with OIDC and package-write permissions denied.
47
+
48
+ ## Candidate validation
49
+
50
+ Local candidate verification on Node.js `26.5.1`:
51
+
52
+ - `npm run check`: 72 files and 376 tests passed (including the new transport-policy unit coverage, the configurable-`maxAttempts` integration case, and the updated golden freeze).
53
+ - `npm run quality:compare`: candidate quality versus the frozen baseline passed.
54
+ - `npm run schema:context-persistence`: within the 1,500 / 320 absolute and relative limits.
55
+ - `npm run latency:check` against the frozen baseline passed (`regressionRatio` `1.040189`, maximum `1.1`).
56
+ - `npm run pack:check` and `npm pack --dry-run --json` for all three packages passed with no forbidden local/session/storage paths.
57
+ - `git diff --check` passed; the only pre-existing untracked path is `.serena/`, which is excluded from commits and package inventories.
58
+ - Version, exact core dependencies, package-lock entries, extension constant, and reference-adapter constant are synchronized to `0.3.3`.
59
+
60
+ Validation-only CI is recorded below with the release commit. Exact registry verification and the annotated tag are recorded after execution.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ds4-context-engine",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Non-destructive, provider-independent context management for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -62,7 +62,7 @@
62
62
  ]
63
63
  },
64
64
  "dependencies": {
65
- "ds4-context-core": "0.3.1"
65
+ "ds4-context-core": "0.3.3"
66
66
  },
67
67
  "peerDependencies": {
68
68
  "@earendil-works/pi-ai": "0.84.3",
@@ -4,7 +4,10 @@ import {
4
4
  type PrivacyClassification,
5
5
  } from "ds4-context-core/privacy/privacy-policy";
6
6
  import type { StorageDiagnostics } from "ds4-context-core/persistence/storage-diagnostics";
7
+ import { DEFAULT_CONFIG } from "ds4-context-core/config/config";
8
+ import { CONFIG_FIELD_DOCS, getConfigValue } from "ds4-context-core/config/config-catalog";
7
9
  import type {
10
+ ConfigSnapshot,
8
11
  Ds4ContextRuntime,
9
12
  RuntimeDiagnostics,
10
13
  SummaryGraphDiagnostics,
@@ -13,6 +16,7 @@ import type {
13
16
  const NUMBER_FORMAT = new Intl.NumberFormat("en-US");
14
17
  const SUBCOMMANDS = [
15
18
  "status",
19
+ "config",
16
20
  "adapter",
17
21
  "tokens",
18
22
  "manifest",
@@ -65,6 +69,49 @@ function bytes(value: number | undefined): string {
65
69
  return `${scaled.toFixed(digits)} ${units[unit]}`;
66
70
  }
67
71
 
72
+ function describeConfigValue(value: unknown): string {
73
+ if (value === undefined) return "unset";
74
+ const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value);
75
+ return text.length > 26 ? `${text.slice(0, 25)}…` : text;
76
+ }
77
+
78
+ function formatConfig(snapshot: ConfigSnapshot): string {
79
+ const lines = [
80
+ `DS4 Context Configuration (${snapshot.contractVersion})`,
81
+ "",
82
+ `Enabled: ${snapshot.config.enabled ? "yes" : "no"}`,
83
+ `Global file: ${snapshot.globalPath}${snapshot.loadedFiles.includes(snapshot.globalPath) ? " (loaded)" : ""}`,
84
+ `Project file: ${snapshot.projectPath || "unavailable"}${snapshot.projectPath && snapshot.loadedFiles.includes(snapshot.projectPath) ? " (loaded)" : ""}`,
85
+ `Configuration warnings: ${snapshot.warnings.length}`,
86
+ ...(snapshot.warnings.length > 0 ? snapshot.warnings.map((warning) => ` - ${warning}`) : []),
87
+ "",
88
+ ];
89
+ let section = "";
90
+ for (const doc of CONFIG_FIELD_DOCS) {
91
+ const head = doc.path.split(".")[0] ?? doc.path;
92
+ if (head !== section) {
93
+ section = head;
94
+ lines.push(`[${section}]`);
95
+ }
96
+ const active = getConfigValue(snapshot.config, doc.path);
97
+ const fallback = getConfigValue(DEFAULT_CONFIG, doc.path);
98
+ const hints: string[] = [];
99
+ if (doc.values && doc.values.length > 0) hints.push(doc.values.join("|"));
100
+ hints.push(doc.kind);
101
+ if (doc.optional) hints.push("optional");
102
+ lines.push(
103
+ `${doc.path.padEnd(48)} ${describeConfigValue(active).padEnd(28)} ${describeConfigValue(fallback).padEnd(28)} ${hints.join(" ")}`,
104
+ );
105
+ }
106
+ lines.push(
107
+ "",
108
+ "Set: /context config set <path> <value> [--global]",
109
+ "Unset: /context config unset <path> [--global]",
110
+ "JSON values must be quoted, e.g. /context config set compaction.model '{\"provider\":\"openai-codex\",\"id\":\"gpt-5.4-mini\"}'",
111
+ );
112
+ return lines.join("\n");
113
+ }
114
+
68
115
  function present(ctx: ExtensionCommandContext, message: string, level: "info" | "warning" | "error" = "info"): void {
69
116
  if (ctx.hasUI) ctx.ui.notify(message, level);
70
117
  else console.log(message);
@@ -133,7 +180,10 @@ function parseCommandArgs(value: string): ParsedCommandArgs {
133
180
  }
134
181
  const name = token.slice(2).toLowerCase();
135
182
  const next = tokens[index + 1];
136
- if (!next || next.startsWith("--")) throw new Error(`Option --${name} requires a value`);
183
+ if (!next || next.startsWith("--")) {
184
+ options.set(name, "true");
185
+ continue;
186
+ }
137
187
  options.set(name, next);
138
188
  index++;
139
189
  }
@@ -830,6 +880,59 @@ export function registerContextCommand(pi: ExtensionAPI, runtime: Ds4ContextRunt
830
880
  return;
831
881
  }
832
882
 
883
+ if (subcommand === "config") {
884
+ const nested = splitCommand(subcommandArgs, "show");
885
+ if (nested.command === "set") {
886
+ const parsed = parseCommandArgs(nested.args);
887
+ assertOptions(parsed.options, ["global"]);
888
+ const path = parsed.positionals[0];
889
+ if (!path || parsed.positionals.length < 2) {
890
+ throw new Error("Usage: /context config set <path> <value> [--global]");
891
+ }
892
+ const rawValue = parsed.positionals.slice(1).join(" ");
893
+ const result = runtime.setConfigValue(
894
+ path,
895
+ rawValue,
896
+ parsed.options.has("global") ? "global" : "project",
897
+ { projectTrusted: ctx.isProjectTrusted(), cwd: ctx.cwd },
898
+ );
899
+ present(ctx, [
900
+ `Set ${path} = ${result.value} in ${result.file} (${result.global ? "global" : "project"} configuration).`,
901
+ "The active session keeps the previous configuration; the change applies when the next Pi session starts.",
902
+ ...(result.warnings.length > 0
903
+ ? ["", ...result.warnings.map((warning) => `Warning: ${warning}`)]
904
+ : []),
905
+ ].join("\n"));
906
+ return;
907
+ }
908
+ if (nested.command === "unset") {
909
+ const parsed = parseCommandArgs(nested.args);
910
+ assertOptions(parsed.options, ["global"]);
911
+ const path = parsed.positionals[0];
912
+ if (!path || parsed.positionals.length > 1) {
913
+ throw new Error("Usage: /context config unset <path> [--global]");
914
+ }
915
+ const result = runtime.unsetConfigValue(
916
+ path,
917
+ parsed.options.has("global") ? "global" : "project",
918
+ { projectTrusted: ctx.isProjectTrusted(), cwd: ctx.cwd },
919
+ );
920
+ present(ctx, [
921
+ `Unset ${path} in ${result.file} (${result.global ? "global" : "project"} configuration).`,
922
+ "The active session keeps the previous configuration; the change applies when the next Pi session starts.",
923
+ ...(result.warnings.length > 0
924
+ ? ["", ...result.warnings.map((warning) => `Warning: ${warning}`)]
925
+ : []),
926
+ ].join("\n"));
927
+ return;
928
+ }
929
+ if (nested.command !== "show") {
930
+ throw new Error("Usage: /context config [show|set|unset] (show is the default)");
931
+ }
932
+ present(ctx, formatConfig(runtime.configSnapshot()));
933
+ return;
934
+ }
935
+
833
936
  if (subcommand === "adapter") {
834
937
  present(ctx, formatAdapter(runtime.diagnostics(ctx)));
835
938
  return;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, realpathSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join, parse, resolve } from "node:path";
4
+ import { dirname, join, parse, resolve } from "node:path";
5
5
  import type {
6
6
  Api,
7
7
  AssistantMessage,
@@ -47,9 +47,15 @@ import {
47
47
  loadConfig,
48
48
  resolveDatabasePath,
49
49
  resolveRankingModelPath,
50
+ validateConfigFile,
50
51
  type LoadedConfig,
51
52
  } from "ds4-context-core/config/config-loader";
52
- import { createDefaultConfig, type Ds4ContextConfig } from "ds4-context-core/config/config";
53
+ import { CONFIG_SCHEMA_VERSION, createDefaultConfig, type Ds4ContextConfig } from "ds4-context-core/config/config";
54
+ import {
55
+ applyConfigValue,
56
+ findConfigField,
57
+ removeConfigValue,
58
+ } from "ds4-context-core/config/config-catalog";
53
59
  import { calculateContextBudget, type ContextBudget } from "ds4-context-core/core/budget-manager";
54
60
  import {
55
61
  modelProfileKey,
@@ -334,6 +340,24 @@ export interface RankingTrainingResult {
334
340
  warnings: string[];
335
341
  }
336
342
 
343
+ export interface ConfigSnapshot {
344
+ contractVersion: string;
345
+ config: Ds4ContextConfig;
346
+ globalPath: string;
347
+ projectPath: string;
348
+ loadedFiles: string[];
349
+ warnings: string[];
350
+ }
351
+
352
+ export interface ConfigMutationResult {
353
+ file: string;
354
+ global: boolean;
355
+ value?: string;
356
+ warnings: string[];
357
+ }
358
+
359
+ export type ConfigTarget = "project" | "global";
360
+
337
361
  export interface RuntimeDiagnostics {
338
362
  extensionVersion: string;
339
363
  supportedPiVersion: string;
@@ -366,6 +390,16 @@ export interface RuntimeDiagnostics {
366
390
  lastError?: string;
367
391
  }
368
392
 
393
+ function isConfigRecord(value: unknown): value is Record<string, unknown> {
394
+ return value !== null && typeof value === "object" && !Array.isArray(value);
395
+ }
396
+
397
+ function describeConfigValue(value: unknown): string {
398
+ if (typeof value === "string") return JSON.stringify(value);
399
+ if (value === undefined) return "unset";
400
+ return JSON.stringify(value);
401
+ }
402
+
369
403
  export class Ds4ContextRuntime {
370
404
  private phase: RuntimePhase = "idle";
371
405
  private config: Ds4ContextConfig = createDefaultConfig();
@@ -2962,6 +2996,60 @@ export class Ds4ContextRuntime {
2962
2996
  ?? unavailableStorageDiagnostics();
2963
2997
  }
2964
2998
 
2999
+ configSnapshot(): ConfigSnapshot {
3000
+ return {
3001
+ contractVersion: CONFIG_SCHEMA_VERSION,
3002
+ config: this.config,
3003
+ globalPath: this.loadedConfig?.globalPath
3004
+ ?? join(this.dependencies.agentDir, "ds4-context.json"),
3005
+ projectPath: this.loadedConfig?.projectPath ?? "",
3006
+ loadedFiles: this.loadedConfig?.loadedFiles ?? [],
3007
+ warnings: this.loadedConfig?.warnings ?? [],
3008
+ };
3009
+ }
3010
+
3011
+ setConfigValue(
3012
+ path: string,
3013
+ rawValue: string,
3014
+ target: ConfigTarget,
3015
+ options: { projectTrusted: boolean; cwd: string },
3016
+ ): ConfigMutationResult {
3017
+ const doc = findConfigField(path);
3018
+ if (!doc) {
3019
+ throw new Error(`Unknown configuration key: ${path}. See /context config for the full list.`);
3020
+ }
3021
+ const filePath = this.resolveConfigTarget(target, options);
3022
+ const content = this.readConfigFile(filePath);
3023
+ const value = applyConfigValue(content, path, rawValue, doc);
3024
+ const { warnings } = validateConfigFile(content);
3025
+ this.writeConfigFile(filePath, content);
3026
+ return {
3027
+ file: filePath,
3028
+ global: target === "global",
3029
+ value: describeConfigValue(value),
3030
+ warnings,
3031
+ };
3032
+ }
3033
+
3034
+ unsetConfigValue(
3035
+ path: string,
3036
+ target: ConfigTarget,
3037
+ options: { projectTrusted: boolean; cwd: string },
3038
+ ): ConfigMutationResult {
3039
+ const doc = findConfigField(path);
3040
+ if (!doc) {
3041
+ throw new Error(`Unknown configuration key: ${path}. See /context config for the full list.`);
3042
+ }
3043
+ const filePath = this.resolveConfigTarget(target, options);
3044
+ const content = this.readConfigFile(filePath);
3045
+ if (!removeConfigValue(content, path)) {
3046
+ throw new Error(`${path} is not set in ${filePath}`);
3047
+ }
3048
+ const { warnings } = validateConfigFile(content);
3049
+ this.writeConfigFile(filePath, content);
3050
+ return { file: filePath, global: target === "global", warnings };
3051
+ }
3052
+
2965
3053
  shutdown(ctx?: ExtensionContext): void {
2966
3054
  if (ctx) this.syncSessionIndex(ctx);
2967
3055
  this.flushContextQuality();
@@ -2972,6 +3060,37 @@ export class Ds4ContextRuntime {
2972
3060
  this.logger.debug("runtime.closed", { sessionId: this.session?.sessionId });
2973
3061
  }
2974
3062
 
3063
+ private resolveConfigTarget(target: ConfigTarget, options: { projectTrusted: boolean; cwd: string }): string {
3064
+ if (target === "global") {
3065
+ return this.loadedConfig?.globalPath
3066
+ ?? join(this.dependencies.agentDir, "ds4-context.json");
3067
+ }
3068
+ if (!options.projectTrusted) {
3069
+ throw new Error("Project configuration requires a trusted project; use /context config set/unset --global instead");
3070
+ }
3071
+ return this.loadedConfig?.projectPath
3072
+ ?? join(options.cwd, this.dependencies.configDirName ?? ".pi", "ds4-context.json");
3073
+ }
3074
+
3075
+ private readConfigFile(path: string): Record<string, unknown> {
3076
+ if (!existsSync(path)) return {};
3077
+ let parsed: unknown;
3078
+ try {
3079
+ parsed = JSON.parse(readFileSync(path, "utf8"));
3080
+ } catch {
3081
+ throw new Error(`Configuration file is malformed JSON: ${path}`);
3082
+ }
3083
+ if (!isConfigRecord(parsed)) {
3084
+ throw new Error(`Configuration file must contain a JSON object: ${path}`);
3085
+ }
3086
+ return parsed;
3087
+ }
3088
+
3089
+ private writeConfigFile(path: string, content: Record<string, unknown>): void {
3090
+ mkdirSync(dirname(path), { recursive: true });
3091
+ writeFileSync(path, `${JSON.stringify(content, null, 2)}\n`, "utf8");
3092
+ }
3093
+
2975
3094
  private getCompactionDiagnostics(ctx: ExtensionContext): CompactionDiagnostics {
2976
3095
  return this.compaction?.diagnostics(ctx) ?? defaultCompactionDiagnostics(this.config);
2977
3096
  }
@@ -1005,6 +1005,7 @@ export class CompactionCoordinator {
1005
1005
  ...input,
1006
1006
  validate: this.dependencies.config.compaction.validate,
1007
1007
  maxSummaryTokens: this.dependencies.config.context.maxSummaryTokens,
1008
+ transport: this.dependencies.config.compaction.transport,
1008
1009
  now: this.dependencies.now,
1009
1010
  onTransportRetry: (diagnostic) => {
1010
1011
  this.state.transportRetries = (this.state.transportRetries ?? 0) + 1;
@@ -13,8 +13,39 @@ import {
13
13
  type SummaryValidationResult,
14
14
  } from "ds4-context-core/compaction/summary-contract";
15
15
 
16
- export const COMPACTION_TRANSPORT_MAX_ATTEMPTS = 3;
17
- const COMPACTION_TRANSPORT_RETRY_DELAYS_MS = [200, 500] as const;
16
+ export const DEFAULT_COMPACTION_TRANSPORT_MAX_ATTEMPTS = 3;
17
+ export const DEFAULT_COMPACTION_TRANSPORT_BASE_DELAY_MS = 2000;
18
+ export const COMPACTION_TRANSPORT_MAX_DELAY_MS = 60_000;
19
+
20
+ /**
21
+ * Transport retry policy for compaction summary requests. Defaults mirror Pi's
22
+ * assistant retry settings (`retry.maxRetries` 3, `retry.baseDelayMs` 2000,
23
+ * exponential backoff, abort-aware).
24
+ */
25
+ export interface CompactionTransportPolicy {
26
+ /** Total attempts for transport-classified failures. Default: 3. */
27
+ maxAttempts?: number;
28
+ /** Base backoff delay in ms, doubled per attempt. Default: 2000. */
29
+ baseDelayMs?: number;
30
+ }
31
+
32
+ export function effectiveTransportPolicy(
33
+ policy: CompactionTransportPolicy | undefined,
34
+ ): { maxAttempts: number; baseDelayMs: number } {
35
+ const maxAttempts = Math.min(
36
+ 10,
37
+ Math.max(1, policy?.maxAttempts ?? DEFAULT_COMPACTION_TRANSPORT_MAX_ATTEMPTS),
38
+ );
39
+ const baseDelayMs = Math.min(
40
+ COMPACTION_TRANSPORT_MAX_DELAY_MS,
41
+ Math.max(0, policy?.baseDelayMs ?? DEFAULT_COMPACTION_TRANSPORT_BASE_DELAY_MS),
42
+ );
43
+ return { maxAttempts, baseDelayMs };
44
+ }
45
+
46
+ export function transportRetryDelayMs(baseDelayMs: number, failedAttempt: number): number {
47
+ return Math.min(COMPACTION_TRANSPORT_MAX_DELAY_MS, baseDelayMs * 2 ** (failedAttempt - 1));
48
+ }
18
49
 
19
50
  export interface CompactionTransportRetryDiagnostic {
20
51
  stage: "segment" | "aggregate";
@@ -38,6 +69,8 @@ export interface GenerateValidatedSummaryInput {
38
69
  model?: Model<Api>;
39
70
  /** Reasoning level for the summary request; `off` (default) keeps the pre-existing request shape. */
40
71
  thinking?: CompactionThinkingLevel;
72
+ /** Transport retry policy; defaults mirror Pi's assistant retry settings. */
73
+ transport?: CompactionTransportPolicy;
41
74
  now: () => number;
42
75
  onTransportRetry?: (diagnostic: CompactionTransportRetryDiagnostic) => void;
43
76
  }
@@ -162,6 +195,7 @@ export async function generateValidatedSummary(
162
195
  const model = input.model ?? input.ctx.model;
163
196
  if (!model) throw new Error("Compaction summary generation requires an active model");
164
197
  const maxTokens = Math.max(1, Math.min(input.maxSummaryTokens, model.maxTokens ?? input.maxSummaryTokens));
198
+ const { maxAttempts, baseDelayMs } = effectiveTransportPolicy(input.transport);
165
199
  const retryUsages: Usage[] = [];
166
200
  let response: Awaited<ReturnType<typeof input.ctx.modelRegistry.complete>>;
167
201
  let attempt = 0;
@@ -189,18 +223,18 @@ export async function generateValidatedSummary(
189
223
  } catch (error) {
190
224
  if (input.event.signal.aborted) throw abortedError();
191
225
  const category = providerFailureCategory(error);
192
- if (category !== "transport" || attempt >= COMPACTION_TRANSPORT_MAX_ATTEMPTS) {
226
+ if (category !== "transport" || attempt >= maxAttempts) {
193
227
  throw new Error(
194
228
  `Compaction ${input.stage} request failed (${transportFailureSuffix(category, attempt)})`,
195
229
  );
196
230
  }
197
- const delayMs = COMPACTION_TRANSPORT_RETRY_DELAYS_MS[attempt - 1] ?? 500;
231
+ const delayMs = transportRetryDelayMs(baseDelayMs, attempt);
198
232
  await waitForTransportRetry(input.event.signal, delayMs);
199
233
  input.onTransportRetry?.({
200
234
  stage: input.stage,
201
235
  failedAttempt: attempt,
202
236
  nextAttempt: attempt + 1,
203
- maxAttempts: COMPACTION_TRANSPORT_MAX_ATTEMPTS,
237
+ maxAttempts,
204
238
  delayMs,
205
239
  });
206
240
  continue;
@@ -209,15 +243,15 @@ export async function generateValidatedSummary(
209
243
  const stopReason = responseStopReason(response);
210
244
  if (stopReason === "error") {
211
245
  const category = providerFailureCategory(responseErrorMessage(response));
212
- if (category === "transport" && attempt < COMPACTION_TRANSPORT_MAX_ATTEMPTS) {
246
+ if (category === "transport" && attempt < maxAttempts) {
213
247
  retryUsages.push(response.usage);
214
- const delayMs = COMPACTION_TRANSPORT_RETRY_DELAYS_MS[attempt - 1] ?? 500;
248
+ const delayMs = transportRetryDelayMs(baseDelayMs, attempt);
215
249
  await waitForTransportRetry(input.event.signal, delayMs);
216
250
  input.onTransportRetry?.({
217
251
  stage: input.stage,
218
252
  failedAttempt: attempt,
219
253
  nextAttempt: attempt + 1,
220
- maxAttempts: COMPACTION_TRANSPORT_MAX_ATTEMPTS,
254
+ maxAttempts,
221
255
  delayMs,
222
256
  });
223
257
  continue;
@@ -1,4 +1,4 @@
1
- export const EXTENSION_VERSION = "0.3.1";
1
+ export const EXTENSION_VERSION = "0.3.3";
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";