pi-jev-compact 0.1.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.
@@ -0,0 +1,52 @@
1
+ # Contributing to pi-jev-compact
2
+
3
+ Thanks for considering a contribution!
4
+
5
+ ## Setup
6
+
7
+ ```sh
8
+ git clone https://github.com/019ec6e2/pi-jev-compact && cd pi-jev-compact
9
+ npm install
10
+ export TYPESAFE_API_KEY=... # optional; only for `npm run demo`
11
+ ```
12
+
13
+ No build step: pi loads extensions via jiti, so TypeScript source runs as-is.
14
+
15
+ ## Verify before opening a PR
16
+
17
+ ```sh
18
+ npm run typecheck # tsc --noEmit (TS 7, strict flags)
19
+ npm run lint # biome check (auto-fix: npm run lint:fix)
20
+ npm test # vitest, offline, no API key needed
21
+ ```
22
+
23
+ CI-ready chain: `npm run lint && npm run typecheck && npm test`.
24
+
25
+ ## Ground rules
26
+
27
+ - **The engine (`src/jev/`) is shared with upstream
28
+ [`fast-jev-compaction`](https://github.com/019ec6e2/fast-jev-compaction)** and its
29
+ behavior is pinned by the ported test suite (decisions, state fitting, batching,
30
+ request format). Change it only with tests updated accordingly, and note in the PR
31
+ description whether the change belongs upstream too.
32
+ - **Host code (`src/index.ts`, `src/adapter.ts`, `src/config.ts`)** must keep the
33
+ fallback contract: any error, missing key, or insufficient reduction returns
34
+ `undefined` so pi's built-in summary runs. Compaction must never hard-fail a session.
35
+ - **No API keys in code, tests, or fixtures.** Tests use a fake asker; the only live
36
+ network check is `npm run demo`, gated on the environment key.
37
+ - Keep the zero-build property: no syntax that requires codegen (erasable-syntax-only),
38
+ no bundler, `.ts` import specifiers.
39
+ - Formatting/lint is Biome; run `npm run lint:fix` rather than hand-formatting.
40
+
41
+ ## What's worth contributing
42
+
43
+ - The phase-2 `context`-event soft-pruning mode (`FAST_JEV_MODE=context`, see
44
+ `ADAPTATION.md` §3 Option B)
45
+ - Tokenizer-accurate size estimates to replace the heuristic estimator
46
+ - Live-session validation reports (threshold auto-compact, overflow/`willRetry` path)
47
+ - Docs: real-world configuration examples, provider notes
48
+
49
+ ## Commit style
50
+
51
+ Conventional-ish prefixes (`feat:`, `fix:`, `docs:`, `test:`, `chore:`), one logical
52
+ change per commit.
package/LICENSE ADDED
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 pi-jev-compact contributors
4
+
5
+ Engine ported from fast-jev-compaction (https://github.com/019ec6e2/fast-jev-compaction), MIT.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # pi-jev-compact
2
+
3
+ [![tests](https://img.shields.io/badge/tests-37%20passing-brightgreen)]() [![typescript](https://img.shields.io/badge/TypeScript-7-3178c6)]() [![pi](https://img.shields.io/badge/pi-extension-blueviolet)]()
4
+
5
+ **Verbatim context compaction for [pi](https://pi.dev)**, powered by the [TypeSafe Jev](https://api.typesafe.ai) model.
6
+
7
+ Replaces pi's LLM-written compaction summary with surgical pruning: every historical
8
+ tool call is scored in one fast Jev request — stale calls are dropped, results that are
9
+ no longer needed are truncated to a bounded head + note, and **everything kept stays
10
+ word-for-word**. No lossy summarization, ever.
11
+
12
+ Port of [`fast-jev-compaction`](https://github.com/019ec6e2/fast-jev-compaction)
13
+ (Claude Code plugin) to a pi extension. See [`ADAPTATION.md`](./ADAPTATION.md) for the
14
+ design mapping.
15
+
16
+ ## Why
17
+
18
+ Most context compaction asks an LLM to summarize old turns. A summary is lossy: a file
19
+ path, exact error, constraint, or command can disappear even when it matters later.
20
+ This extension never rewrites anything. It only deletes tool calls and results Jev says
21
+ are no longer needed, and it asks Jev while showing it the whole conversation. User and
22
+ assistant text stays verbatim and in order.
23
+
24
+ ## How it works
25
+
26
+ ```
27
+ pi auto-compact (threshold) ─┐
28
+ /compact ────────────────────┼─► session_before_compact ─► [fast-jev engine]
29
+ /jev-compact ────────────────┘ │
30
+
31
+ 1. Pair every tool call with its result
32
+ 2. Pin the kept tail (pi's firstKeptEntryId)
33
+ 3. Send the whole history (results omitted) as state,
34
+ fitted to 25k tokens in stages
35
+ 4. Ask Jev per call: keep the call? keep the result verbatim?
36
+ 5. Decide: keep · truncate result (head + note) · drop call+result
37
+ 6. Return the pruned transcript VERBATIM as the compaction summary
38
+
39
+ └─ not enough reduction / Jev failed / no key?
40
+ → pi's built-in LLM summary (graceful fallback)
41
+ ```
42
+
43
+ Dropped results keep their first `FAST_JEV_TRUNCATE_HEAD_CHARS` characters plus a
44
+ note (`[fast-jev-compaction truncated N chars of this tool result; re-run the tool if
45
+ needed]`), so the model knows it can re-run the tool. Repeated compactions chain:
46
+ the previous pruned history is prepended as `[Earlier compacted history]`.
47
+
48
+ ## Install
49
+
50
+ **From a checkout (dev, hot-reloadable):**
51
+
52
+ ```sh
53
+ git clone https://github.com/019ec6e2/pi-jev-compact
54
+ ln -s "$PWD/pi-jev-compact" ~/.pi/agent/extensions/pi-jev-compact
55
+ ```
56
+
57
+ **As a pi package (npm or git):**
58
+
59
+ ```sh
60
+ pi install git:github.com/019ec6e2/pi-jev-compact@v1
61
+ # or
62
+ pi install npm:pi-jev-compact
63
+ ```
64
+
65
+ Runtime dependency is `typebox` only; pi resolves it on install.
66
+
67
+ ## Setup
68
+
69
+ ```sh
70
+ export TYPESAFE_API_KEY=... # or FAST_JEV_API_KEY
71
+ ```
72
+
73
+ Without a key the extension stays passive — pi's built-in compaction runs untouched.
74
+
75
+ ## Usage
76
+
77
+ Nothing to do: pi's own auto-compaction threshold routes through fast-jev, as do
78
+ `/compact` and overflow recovery. Extras:
79
+
80
+ | Command | Effect |
81
+ |---|---|
82
+ | `/jev-compact` | Trigger compaction now (pi's flow, fast-jev decides) |
83
+ | `/jev-compact focus on the DB migration` | Optional instructions → used by the built-in summary if we fall back |
84
+
85
+ After each compaction you get a notification with the outcome and a footer widget:
86
+
87
+ ```
88
+ fast-jev: compaction done (fast-jev, extension-provided, manual) —
89
+ messages 4/10, calls 0 kept / 0 truncated / 3 dropped / 0 pinned —
90
+ state ~531 tokens (full), 1 request(s), 623ms
91
+ ```
92
+
93
+ Falls back with an explicit reason, e.g. `fast-jev: fallback to built-in summary
94
+ (below 25% minimum: …)`.
95
+
96
+ ## Configuration
97
+
98
+ Precedence: defaults < environment < project file.
99
+
100
+ | Variable | Default | Meaning |
101
+ |---|---|---|
102
+ | `FAST_JEV_API_KEY` / `TYPESAFE_API_KEY` | — | TypeSafe API key (extension is passive without it) |
103
+ | `FAST_JEV_MODEL` | `jev-latest` | Jev model |
104
+ | `FAST_JEV_KEEP_THRESHOLD` | `0.5` | Minimum keep probability for a call or result to stay |
105
+ | `FAST_JEV_MIN_REDUCTION` | `0.25` | Below this character reduction, fall back to the built-in summary |
106
+ | `FAST_JEV_PRESERVE_RECENT` | `2` | Newest messages *inside the summarized span* never touched (pi already keeps its own recent tail) |
107
+ | `FAST_JEV_MAX_STATE_TOKENS` | `25000` | Token ceiling for the state sent to Jev |
108
+ | `FAST_JEV_MAX_REQUEST_TOKENS` | `30000` | Ceiling for state plus one batch of questions |
109
+ | `FAST_JEV_TRUNCATE_HEAD_CHARS` | `300` | Characters of a truncated result retained |
110
+ | `FAST_JEV_GOAL` | last user prompts | Task hint included in the state |
111
+
112
+ Project-local overrides in `.pi/fast-jev.json` (read only for trusted projects), same
113
+ keys in camelCase (`apiKey`, `model`, `keepThreshold`, `minReductionRatio`,
114
+ `preserveRecentMessages`, `maxStateTokens`, `maxRequestTokens`, `truncateHeadChars`, `goal`).
115
+
116
+ ## Architecture
117
+
118
+ ```
119
+ src/
120
+ ├── index.ts entry: session_before_compact handler, /jev-compact, stats reporting
121
+ ├── adapter.ts pi AgentMessage ⇄ library Message (via pi's convertToLlm),
122
+ │ verbatim summary serializer with previousSummary chaining
123
+ ├── config.ts env + project-file config resolution
124
+ └── jev/ host-agnostic engine (ported from fast-jev-compaction):
125
+ types, request/client, state fitting, batching, decisions, rebuild
126
+ ```
127
+
128
+ pi loads extensions via jiti — no build step. `tsc` is type-checking only.
129
+
130
+ | Milestone tool | Role |
131
+ |---|---|
132
+ | TypeScript 7 | type checking only (`tsc --noEmit`), strictest flags |
133
+ | Biome 2 | lint + format + import organizing |
134
+ | Vitest 4 | unit tests (fake Jev asker — no network in tests) |
135
+
136
+ ## Development
137
+
138
+ ```sh
139
+ npm install
140
+ npm run typecheck # tsc --noEmit
141
+ npm run lint # biome check
142
+ npm test # vitest, offline
143
+ npm run demo # live Jev round-trip; skips safely without TYPESAFE_API_KEY
144
+ npm run dev # pi -e ./src/index.ts
145
+ ```
146
+
147
+ The test suite covers the ported engine (options, token estimation, call collection,
148
+ state-fitting stages, batching, decisions, HTTP client) plus pi-side adapter mapping,
149
+ verbatim serialization, config layering, and an end-to-end pi-transcript round trip.
150
+
151
+ ## Notes and limitations
152
+
153
+ - Pruned content is gone: Jev is a probability, not a guarantee. The assistant can
154
+ always re-run a tool.
155
+ - The summarized span becomes text — images and thinking blocks in that span are not
156
+ reproduced (pi's kept tail is untouched, images there survive).
157
+ - The full state is resent with each question batch (near the state ceiling that is
158
+ one request per handful of calls).
159
+ - Token sizes are estimates from character counts, not a tokenizer.
160
+ - API keys are read from the environment at session start; never commit one. A
161
+ project `.pi/fast-jev.json` is only honored for trusted projects.
162
+
163
+ ## Credits
164
+
165
+ Engine and algorithm by [`fast-jev-compaction`](https://github.com/019ec6e2/fast-jev-compaction)
166
+ (MIT). pi adaptation by this repository (MIT).
package/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Root entry point so pi's directory auto-discovery
3
+ * (`~/.pi/agent/extensions/stigmergy/` → `index.ts`) finds the extension,
4
+ * with the real implementation in `src/index.ts`.
5
+ */
6
+
7
+ export * from "./src/index.ts";
8
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pi-jev-compact",
3
+ "version": "0.1.0",
4
+ "description": "Verbatim context compaction for pi, powered by the TypeSafe Jev model",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "pi-extension",
11
+ "compaction",
12
+ "context-window"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/019ec6e2/pi-jev-compact.git"
17
+ },
18
+ "bugs": "https://github.com/019ec6e2/pi-jev-compact/issues",
19
+ "files": [
20
+ "index.ts",
21
+ "src",
22
+ "README.md",
23
+ "LICENSE",
24
+ "CONTRIBUTING.md"
25
+ ],
26
+ "pi": {
27
+ "extensions": [
28
+ "./src/index.ts"
29
+ ]
30
+ },
31
+ "exports": {
32
+ ".": "./src/index.ts"
33
+ },
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit",
36
+ "lint": "biome check .",
37
+ "lint:fix": "biome check --write .",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "demo": "tsx scripts/demo.ts",
41
+ "dev": "pi -e ./src/index.ts"
42
+ },
43
+ "dependencies": {},
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-coding-agent": "*",
46
+ "typebox": "*"
47
+ },
48
+ "peerDependenciesMeta": {
49
+ "@earendil-works/pi-coding-agent": {
50
+ "optional": true
51
+ }
52
+ },
53
+ "devDependencies": {
54
+ "@biomejs/biome": "^2.5.14",
55
+ "@earendil-works/pi-coding-agent": "^0.85.1",
56
+ "@types/node": "^26",
57
+ "tsx": "^4.23.13",
58
+ "typescript": "^7.0.2",
59
+ "vitest": "^4.0.18"
60
+ }
61
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Adapter between pi's AgentMessage[] and the library's Message[].
3
+ *
4
+ * pi custom message types (bashExecution, custom, branchSummary,
5
+ * compactionSummary) are normalized first with pi's own `convertToLlm`, so the
6
+ * mapper below only deals with plain user/assistant/toolResult messages.
7
+ */
8
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
9
+ import type { Message, ToolResult, ToolUse } from "./jev/types.ts";
10
+
11
+ /** Structurally derived so the extension doesn't import pi-agent-core directly. */
12
+ type AgentMessageLike = Parameters<typeof convertToLlm>[0][number];
13
+
14
+ function textOf(content: string | { type: string; text?: string; data?: string }[]): string {
15
+ if (typeof content === "string") return content;
16
+ return content
17
+ .map((block) => {
18
+ if (block.type === "text") return block.text ?? "";
19
+ if (block.type === "image") return "[image]";
20
+ return "";
21
+ })
22
+ .filter((text) => text.length > 0)
23
+ .join("\n");
24
+ }
25
+
26
+ /**
27
+ * Maps pi messages to library messages 1:1 (same order). Custom message types
28
+ * are already normalized to user/assistant/toolResult by `convertToLlm`.
29
+ */
30
+ export function toLibraryMessages(messages: readonly AgentMessageLike[]): Message[] {
31
+ const llm = convertToLlm([...messages]);
32
+ const lib: Message[] = [];
33
+ for (const message of llm) {
34
+ if (message.role === "user") {
35
+ lib.push({ role: "user", text: textOf(message.content), toolUses: [] });
36
+ } else if (message.role === "assistant") {
37
+ const toolUses: ToolUse[] = [];
38
+ let text = "";
39
+ for (const block of message.content) {
40
+ if (block.type === "text") {
41
+ if (block.text.length > 0) text = text ? `${text}\n${block.text}` : block.text;
42
+ } else if (block.type === "toolCall") {
43
+ toolUses.push({
44
+ tool_use_id: block.id,
45
+ tool: block.name,
46
+ input: (block.arguments ?? {}) as Record<string, unknown>,
47
+ });
48
+ }
49
+ }
50
+ lib.push({ role: "assistant", text, toolUses });
51
+ } else if (message.role === "toolResult") {
52
+ const toolResults: ToolResult[] = [
53
+ {
54
+ tool_use_id: message.toolCallId,
55
+ text: textOf(message.content),
56
+ isError: message.isError,
57
+ },
58
+ ];
59
+ lib.push({ role: "user", text: "", toolUses: [], toolResults });
60
+ }
61
+ }
62
+ return lib;
63
+ }
64
+
65
+ /**
66
+ * Serializes the pruned transcript as a verbatim summary. Dropped calls are
67
+ * absent; everything kept is word-for-word, including tool results (which may
68
+ * carry the library's bounded truncation note).
69
+ */
70
+ export function serializePruned(
71
+ previousSummary: string | undefined,
72
+ messages: readonly Message[],
73
+ ): string {
74
+ const parts: string[] = [];
75
+ if (previousSummary && previousSummary.trim().length > 0) {
76
+ parts.push(`[Earlier compacted history]\n${previousSummary.trim()}`);
77
+ }
78
+ for (const message of messages) {
79
+ const header = message.role === "user" ? "[User]" : "[Assistant]";
80
+ const hasCalls = message.toolUses.length > 0;
81
+ const hasResults = (message.toolResults ?? []).length > 0;
82
+ if (message.text.trim().length > 0) {
83
+ parts.push(`${header}: ${message.text}`);
84
+ }
85
+ for (const use of message.toolUses) {
86
+ let input: string;
87
+ try {
88
+ input = JSON.stringify(use.input);
89
+ } catch {
90
+ input = "[unserializable input]";
91
+ }
92
+ parts.push(`[Tool Call] ${use.tool} id=${use.tool_use_id} ${input}`);
93
+ }
94
+ for (const toolResult of message.toolResults ?? []) {
95
+ const status = toolResult.isError ? "error" : "ok";
96
+ parts.push(`[Tool Result] id=${toolResult.tool_use_id} ${status}:\n${toolResult.text}`);
97
+ }
98
+ if (message.text.trim().length === 0 && !hasCalls && !hasResults) continue;
99
+ }
100
+ return parts.join("\n\n");
101
+ }
102
+
103
+ /** One-line human summary of the compaction outcome, for notifications. */
104
+ export function describeStats(
105
+ stats: {
106
+ calls: number;
107
+ kept: number;
108
+ resultsDropped: number;
109
+ callsDropped: number;
110
+ pinned: number;
111
+ requests: number;
112
+ ms: number;
113
+ },
114
+ ratio: number,
115
+ ): string {
116
+ const percent = `${Math.round(ratio * 100)}%`;
117
+ const parts: string[] = [];
118
+ if (stats.kept > 0) parts.push(`${stats.kept} kept`);
119
+ if (stats.resultsDropped > 0) parts.push(`${stats.resultsDropped} results truncated`);
120
+ if (stats.callsDropped > 0) parts.push(`${stats.callsDropped} calls dropped`);
121
+ if (stats.pinned > 0) parts.push(`${stats.pinned} pinned`);
122
+ return `${percent} reduction; ${parts.join(", ") || "no tool calls"}; ${
123
+ stats.requests
124
+ } request(s), ${stats.ms}ms`;
125
+ }
package/src/config.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Configuration resolution for the fast-jev compaction extension.
3
+ *
4
+ * Order (later wins): built-in defaults < environment < project config file.
5
+ * The project file (`.pi/fast-jev.json`) is only read for trusted projects.
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { DEFAULT_MODEL } from "./jev/request.ts";
10
+ import type { CompactOptions } from "./jev/types.ts";
11
+
12
+ export const CONFIG_FILE = "fast-jev.json";
13
+
14
+ export interface FastJevConfig extends CompactOptions {
15
+ /** TypeSafe API key; empty means "not configured" (extension stays passive). */
16
+ apiKey: string;
17
+ /** Jev model name. */
18
+ model?: string;
19
+ /** Minimum character reduction to accept the pruned transcript over the built-in summary. */
20
+ minReductionRatio: number;
21
+ /** Newest messages inside the summarized span never touched by Jev. */
22
+ preserveRecentMessages: number;
23
+ }
24
+
25
+ const DEFAULTS: FastJevConfig = {
26
+ apiKey: "",
27
+ minReductionRatio: 0.25,
28
+ preserveRecentMessages: 2,
29
+ };
30
+
31
+ type Mutable<T> = { -readonly [K in keyof T]: T[K] };
32
+
33
+ function numberOr(value: unknown, fallback: number): number {
34
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
35
+ }
36
+
37
+ function stringOr(value: unknown): string | undefined {
38
+ return typeof value === "string" && value.length > 0 ? value : undefined;
39
+ }
40
+
41
+ /** Reads `FAST_JEV_*` / `TYPESAFE_API_KEY` environment variables. */
42
+ export function envConfig(env: NodeJS.ProcessEnv = process.env): Partial<FastJevConfig> {
43
+ const config: Partial<FastJevConfig> = {};
44
+ const key = stringOr(env["FAST_JEV_API_KEY"]) ?? stringOr(env["TYPESAFE_API_KEY"]);
45
+ if (key) config.apiKey = key;
46
+ const model = stringOr(env["FAST_JEV_MODEL"]);
47
+ if (model) config.model = model;
48
+ const threshold = env["FAST_JEV_KEEP_THRESHOLD"];
49
+ if (threshold !== undefined) {
50
+ const parsed = Number(threshold);
51
+ if (Number.isFinite(parsed)) config.keepThreshold = parsed;
52
+ }
53
+ const reduction = env["FAST_JEV_MIN_REDUCTION"];
54
+ if (reduction !== undefined) {
55
+ const parsed = Number(reduction);
56
+ if (Number.isFinite(parsed)) config.minReductionRatio = parsed;
57
+ }
58
+ const preserve = env["FAST_JEV_PRESERVE_RECENT"];
59
+ if (preserve !== undefined) {
60
+ const parsed = Number(preserve);
61
+ if (Number.isFinite(parsed)) config.preserveRecentMessages = Math.max(0, Math.floor(parsed));
62
+ }
63
+ const stateTokens = env["FAST_JEV_MAX_STATE_TOKENS"];
64
+ if (stateTokens !== undefined) {
65
+ const parsed = Number(stateTokens);
66
+ if (Number.isFinite(parsed)) config.maxStateTokens = parsed;
67
+ }
68
+ const requestTokens = env["FAST_JEV_MAX_REQUEST_TOKENS"];
69
+ if (requestTokens !== undefined) {
70
+ const parsed = Number(requestTokens);
71
+ if (Number.isFinite(parsed)) config.maxRequestTokens = parsed;
72
+ }
73
+ const head = env["FAST_JEV_TRUNCATE_HEAD_CHARS"];
74
+ if (head !== undefined) {
75
+ const parsed = Number(head);
76
+ if (Number.isFinite(parsed)) config.truncateHeadChars = Math.max(0, Math.floor(parsed));
77
+ }
78
+ const goal = stringOr(env["FAST_JEV_GOAL"]);
79
+ if (goal) config.goal = goal;
80
+ return config;
81
+ }
82
+
83
+ /**
84
+ * Reads the optional project-local config file (`.pi/fast-jev.json`). Only call
85
+ * this after confirming the project is trusted (`ctx.isProjectTrusted()`).
86
+ */
87
+ export function fileConfig(cwd: string, configDirName = ".pi"): Partial<FastJevConfig> {
88
+ try {
89
+ const raw = readFileSync(join(cwd, configDirName, CONFIG_FILE), "utf8");
90
+ const parsed: unknown = JSON.parse(raw);
91
+ if (parsed === null || typeof parsed !== "object") return {};
92
+ const out: Partial<FastJevConfig> = {};
93
+ const record = parsed as Record<string, unknown>;
94
+ const key = stringOr(record["apiKey"]);
95
+ if (key) out.apiKey = key;
96
+ const model = stringOr(record["model"]);
97
+ if (model) out.model = model;
98
+ for (const field of ["keepThreshold", "minReductionRatio"] as const) {
99
+ if (record[field] !== undefined) {
100
+ const parsed2 = numberOr(record[field], Number.NaN);
101
+ if (Number.isFinite(parsed2)) out[field] = parsed2;
102
+ }
103
+ }
104
+ for (const field of [
105
+ "preserveRecentMessages",
106
+ "maxStateTokens",
107
+ "maxRequestTokens",
108
+ "truncateHeadChars",
109
+ ] as const) {
110
+ if (record[field] !== undefined) {
111
+ const parsed2 = numberOr(record[field], Number.NaN);
112
+ if (Number.isFinite(parsed2)) out[field] = Math.max(0, Math.floor(parsed2));
113
+ }
114
+ }
115
+ const goal = stringOr(record["goal"]);
116
+ if (goal) out.goal = goal;
117
+ return out;
118
+ } catch {
119
+ return {};
120
+ }
121
+ }
122
+
123
+ export function resolveFastJevConfig(
124
+ ...layers: (Partial<FastJevConfig> | undefined)[]
125
+ ): FastJevConfig {
126
+ const merged: Mutable<FastJevConfig> = { ...DEFAULTS };
127
+ for (const layer of layers) {
128
+ if (!layer) continue;
129
+ for (const [key, value] of Object.entries(layer)) {
130
+ if (value !== undefined) (merged as Record<string, unknown>)[key] = value;
131
+ }
132
+ }
133
+ return merged;
134
+ }
135
+
136
+ export { DEFAULT_MODEL };