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.
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +23 -0
- package/README.md +166 -0
- package/index.ts +8 -0
- package/package.json +61 -0
- package/src/adapter.ts +125 -0
- package/src/config.ts +136 -0
- package/src/index.ts +162 -0
- package/src/jev/client.ts +50 -0
- package/src/jev/compact.ts +298 -0
- package/src/jev/index.ts +6 -0
- package/src/jev/messages.ts +14 -0
- package/src/jev/request.ts +73 -0
- package/src/jev/state.ts +309 -0
- package/src/jev/types.ts +202 -0
package/CONTRIBUTING.md
ADDED
|
@@ -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
|
+
[]() []() []()
|
|
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 };
|