@chroxify/eunoe 1.2.2 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -6
- package/dist/core/index.js +2354 -0
- package/dist/types/context/content.d.ts +31 -0
- package/dist/types/context/fold/evidence.d.ts +20 -0
- package/dist/types/context/fold/index.d.ts +11 -0
- package/dist/types/context/fold/strategies.d.ts +20 -0
- package/dist/types/context/fold/turns.d.ts +16 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +47 -0
- package/dist/types/context/recall/rerank.d.ts +3 -0
- package/dist/types/core/compaction.d.ts +32 -0
- package/dist/types/core/config.d.ts +34 -0
- package/dist/types/core/estimate.d.ts +9 -0
- package/dist/types/core/fingerprint.d.ts +10 -0
- package/dist/types/core/fold.d.ts +18 -0
- package/dist/types/core/forward.d.ts +24 -0
- package/dist/types/core/handoff.d.ts +10 -0
- package/dist/types/core/index.d.ts +14 -0
- package/dist/types/core/recall.d.ts +9 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +25 -0
- package/dist/types/core/subagents.d.ts +10 -0
- package/dist/types/core/transcript.d.ts +9 -0
- package/dist/types/core/usage.d.ts +8 -0
- package/dist/types/core/window.d.ts +8 -0
- package/dist/types/transcript/format.d.ts +16 -0
- package/dist/types/transcript/parse.d.ts +45 -0
- package/package.json +15 -2
- package/src/cli/commands/mode.ts +2 -1
- package/src/cli/commands/status.ts +1 -1
- package/src/cli/proxy/stats.ts +1 -1
- package/src/cli/replay/index.ts +3 -3
- package/src/cli/sessions/index.ts +2 -2
- package/src/config/index.ts +4 -27
- package/src/context/content.ts +64 -1
- package/src/context/fold/strategies.ts +63 -10
- package/src/context/fold/turns.ts +34 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +8 -4
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +85 -28
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +71 -0
- package/src/core/forward.ts +194 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/core/recall.ts +80 -0
- package/src/core/rewrite.ts +201 -0
- package/src/core/state.ts +50 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +43 -1
- package/src/core/window.ts +69 -0
- package/src/proxy/rewrite.ts +22 -139
- package/src/proxy/server.ts +21 -159
- package/src/proxy/sessions.ts +1 -1
- package/src/proxy/sources.ts +35 -0
- package/src/proxy/state.ts +2 -20
- package/src/proxy/window.ts +3 -33
- package/src/transcript/format.ts +1 -2
- package/src/transcript/subagents.ts +3 -3
- package/src/proxy/recall.ts +0 -58
- /package/src/{proxy → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
|
@@ -2,8 +2,8 @@ import { spawnSync } from "node:child_process"
|
|
|
2
2
|
import { claudeConfigDirs, readConfig } from "../../config"
|
|
3
3
|
import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../../context/fold"
|
|
4
4
|
import { recallFor } from "../../context/recall"
|
|
5
|
-
import { DEFAULT_RATIO } from "../../
|
|
6
|
-
import { continueToken } from "../../
|
|
5
|
+
import { DEFAULT_RATIO } from "../../core/estimate"
|
|
6
|
+
import { continueToken } from "../../core/handoff"
|
|
7
7
|
import { seenSessions } from "../../proxy/sessions"
|
|
8
8
|
import { isSessionId } from "../../transcript/locate"
|
|
9
9
|
import { subagentChunks } from "../../transcript/subagents"
|
package/src/config/index.ts
CHANGED
|
@@ -1,29 +1,13 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import path from "node:path"
|
|
4
|
+
import { CORE_DEFAULTS, type CoreConfig, type Mode } from "../core/config"
|
|
4
5
|
import { EUNOE_DIR, paths } from "./paths"
|
|
5
6
|
|
|
6
|
-
export type Mode = "off" | "compact" | "default"
|
|
7
7
|
export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
|
|
8
8
|
type Scope = "all" | "sessions"
|
|
9
9
|
|
|
10
|
-
interface
|
|
11
|
-
recallOptions?: { maxChars?: number; maxCharsMulti?: number; perQuery?: number }
|
|
12
|
-
strategy?: "tail" | "guide" | "trim"
|
|
13
|
-
rolling?: { keep: number; budget: number }
|
|
14
|
-
forceCut?: boolean
|
|
15
|
-
trustHeaders?: boolean
|
|
16
|
-
evidence?: boolean
|
|
17
|
-
recall?: boolean
|
|
18
|
-
rerank?: { model: string; candidates?: number; endpoint?: string }
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface Tunables {
|
|
22
|
-
compactAt: number
|
|
23
|
-
keepTurns: number | "all"
|
|
24
|
-
keep: number
|
|
25
|
-
budget: number
|
|
26
|
-
turnCap: number | "off"
|
|
10
|
+
export interface Tunables extends Pick<CoreConfig, "compactAt" | "keepTurns" | "keep" | "budget" | "turnCap"> {
|
|
27
11
|
search: SearchMode
|
|
28
12
|
rerank: string
|
|
29
13
|
}
|
|
@@ -34,27 +18,20 @@ interface SessionSettings extends Partial<Tunables> {
|
|
|
34
18
|
mode?: Mode
|
|
35
19
|
}
|
|
36
20
|
|
|
37
|
-
export interface Config extends Tunables {
|
|
38
|
-
mode: Mode
|
|
21
|
+
export interface Config extends CoreConfig, Tunables {
|
|
39
22
|
scope: Scope
|
|
40
23
|
sessions: Record<string, SessionSettings>
|
|
41
24
|
port: number
|
|
42
25
|
upstream: string
|
|
43
26
|
claudeConfigDirs: string[]
|
|
44
27
|
window?: number
|
|
45
|
-
eval?: EvalConfig
|
|
46
28
|
}
|
|
47
29
|
|
|
48
30
|
export const DEFAULTS: Config = {
|
|
49
|
-
|
|
31
|
+
...CORE_DEFAULTS,
|
|
50
32
|
scope: "all",
|
|
51
33
|
sessions: {},
|
|
52
34
|
port: 38663,
|
|
53
|
-
compactAt: 0.9,
|
|
54
|
-
keepTurns: "all",
|
|
55
|
-
keep: 3,
|
|
56
|
-
budget: 100_000,
|
|
57
|
-
turnCap: 100_000,
|
|
58
35
|
search: "markdown",
|
|
59
36
|
rerank: "jev-preview",
|
|
60
37
|
upstream: "https://api.anthropic.com",
|
package/src/context/content.ts
CHANGED
|
@@ -45,8 +45,71 @@ export function clean(message: Message): Message {
|
|
|
45
45
|
return { role: message.role, content: blocks(message).map(withoutCacheControl) }
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const SHA256_ROUNDS = new Uint32Array([
|
|
49
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6,
|
|
50
|
+
0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb,
|
|
51
|
+
0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee,
|
|
52
|
+
0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
const utf8 = new TextEncoder()
|
|
56
|
+
|
|
57
|
+
const rotate = (x: number, n: number) => (x >>> n) | (x << (32 - n))
|
|
58
|
+
|
|
59
|
+
export function sha256(text: string): string {
|
|
60
|
+
const data = utf8.encode(text)
|
|
61
|
+
const size = ((data.length + 72) >> 6) << 6
|
|
62
|
+
const bytes = new Uint8Array(size)
|
|
63
|
+
bytes.set(data)
|
|
64
|
+
bytes[data.length] = 0x80
|
|
65
|
+
const view = new DataView(bytes.buffer)
|
|
66
|
+
view.setUint32(size - 8, Math.floor(data.length / 0x20000000))
|
|
67
|
+
view.setUint32(size - 4, (data.length << 3) >>> 0)
|
|
68
|
+
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
|
|
69
|
+
const w = new Uint32Array(64)
|
|
70
|
+
for (let offset = 0; offset < size; offset += 64) {
|
|
71
|
+
for (let i = 0; i < 16; i += 1) w[i] = view.getUint32(offset + i * 4)
|
|
72
|
+
for (let i = 16; i < 64; i += 1) w[i] = (w[i - 16] + (rotate(w[i - 15], 7) ^ rotate(w[i - 15], 18) ^ (w[i - 15] >>> 3)) + w[i - 7] + (rotate(w[i - 2], 17) ^ rotate(w[i - 2], 19) ^ (w[i - 2] >>> 10))) | 0
|
|
73
|
+
let [a, b, c, d, e, f, g, k] = h
|
|
74
|
+
for (let i = 0; i < 64; i += 1) {
|
|
75
|
+
const t1 = (k + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_ROUNDS[i] + w[i]) | 0
|
|
76
|
+
const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) | 0
|
|
77
|
+
k = g
|
|
78
|
+
g = f
|
|
79
|
+
f = e
|
|
80
|
+
e = (d + t1) | 0
|
|
81
|
+
d = c
|
|
82
|
+
c = b
|
|
83
|
+
b = a
|
|
84
|
+
a = (t1 + t2) | 0
|
|
85
|
+
}
|
|
86
|
+
h[0] += a
|
|
87
|
+
h[1] += b
|
|
88
|
+
h[2] += c
|
|
89
|
+
h[3] += d
|
|
90
|
+
h[4] += e
|
|
91
|
+
h[5] += f
|
|
92
|
+
h[6] += g
|
|
93
|
+
h[7] += k
|
|
94
|
+
}
|
|
95
|
+
return Array.from(h, (x) => x.toString(16).padStart(8, "0")).join("")
|
|
96
|
+
}
|
|
97
|
+
|
|
48
98
|
export function shortHash(json: string, chars: number): string {
|
|
49
|
-
return
|
|
99
|
+
return sha256(json).slice(0, chars)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function quickHash(text: string): string {
|
|
103
|
+
let a = 0xdeadbeef ^ text.length
|
|
104
|
+
let b = 0x41c6ce57 ^ text.length
|
|
105
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
106
|
+
const code = text.charCodeAt(i)
|
|
107
|
+
a = Math.imul(a ^ code, 2654435761)
|
|
108
|
+
b = Math.imul(b ^ code, 1597334677)
|
|
109
|
+
}
|
|
110
|
+
const high = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909)
|
|
111
|
+
const low = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(high ^ (high >>> 13), 3266489909)
|
|
112
|
+
return `${(low >>> 0).toString(36)}${(high >>> 0).toString(36)}`
|
|
50
113
|
}
|
|
51
114
|
|
|
52
115
|
export function truncate(text: string, max: number): string {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { type Block, type Body, blocks, type Message, truncate, withoutCacheControl } from "../content"
|
|
1
|
+
import { type Block, type Body, blocks, clean, type Message, truncate, withoutCacheControl } from "../content"
|
|
2
2
|
import { clearedInput, compactionNotice, truncationNotice } from "../prompts"
|
|
3
|
-
import { hasContent, isHarness, type ReduceOptions, reduceTurn, splitTurns } from "./turns"
|
|
3
|
+
import { fingerprintOfKey, hasContent, isHarness, isStaleNote, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint, turnKeys } from "./turns"
|
|
4
4
|
|
|
5
5
|
export interface CompactOptions extends ReduceOptions {
|
|
6
6
|
keepTurns: number
|
|
@@ -8,26 +8,78 @@ export interface CompactOptions extends ReduceOptions {
|
|
|
8
8
|
guide: string | null
|
|
9
9
|
omittedBefore?: number
|
|
10
10
|
reduce?: (turn: Message[], options: ReduceOptions) => Message[]
|
|
11
|
+
pinned?: ReadonlySet<string>
|
|
11
12
|
}
|
|
12
13
|
|
|
14
|
+
function pinnedAt(turns: Message[][], { pinned }: CompactOptions): (index: number) => boolean {
|
|
15
|
+
if (pinned === undefined || pinned.size === 0) return () => false
|
|
16
|
+
const keys = turnKeys(turns)
|
|
17
|
+
return (index) => pinned.has(keys[index])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pinnedPrompt({ pinned }: CompactOptions): (turn: Message[]) => boolean {
|
|
21
|
+
if (pinned === undefined || pinned.size === 0) return () => false
|
|
22
|
+
const fingerprints = new Set([...pinned].map(fingerprintOfKey))
|
|
23
|
+
return (turn) => fingerprints.has(turnFingerprint(turn))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const countPinned = (keys: string[], pinned: ReadonlySet<string>) => (pinned.size > 0 ? keys.filter((key) => pinned.has(key)).length : 0)
|
|
27
|
+
|
|
13
28
|
export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
|
|
14
29
|
const { preamble, turns } = splitTurns(messages)
|
|
15
30
|
if (turns.length < 2) return messages
|
|
16
31
|
return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...(turns.at(-1) ?? [])]
|
|
17
32
|
}
|
|
18
33
|
|
|
34
|
+
export const CARRIED_CHARS = 80_000
|
|
35
|
+
|
|
36
|
+
function carriedHarness(removed: Message[]): Block[] {
|
|
37
|
+
const texts = removed
|
|
38
|
+
.filter((m) => isHarness(m) && hasContent(m))
|
|
39
|
+
.flatMap((m) => blocks(m).filter((b) => b.type === "text"))
|
|
40
|
+
.map((b) => String(b.text ?? ""))
|
|
41
|
+
.filter((text) => text.trim() && !isStaleNote(text))
|
|
42
|
+
const kept: string[] = []
|
|
43
|
+
const seen = new Set<string>()
|
|
44
|
+
let used = 0
|
|
45
|
+
for (const text of texts.toReversed()) {
|
|
46
|
+
if (seen.has(text) || used + text.length > CARRIED_CHARS) continue
|
|
47
|
+
seen.add(text)
|
|
48
|
+
kept.push(text)
|
|
49
|
+
used += text.length
|
|
50
|
+
}
|
|
51
|
+
return kept.toReversed().map((text) => ({ type: "text", text: `<system-reminder>\n${text}\n</system-reminder>` }))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const foldsOpening = (opening: Message[], keptFrom: number) => keptFrom === 1 && opening.length > 1
|
|
55
|
+
|
|
19
56
|
export function compactMessages(messages: Message[], cutIndex: number, options: CompactOptions): Message[] {
|
|
20
57
|
const { preamble, turns } = splitTurns(messages)
|
|
21
58
|
if (cutIndex <= 0 || cutIndex >= turns.length) return messages
|
|
22
59
|
const reduce = options.reduce ?? reduceTurn
|
|
60
|
+
const isPinned = pinnedAt(turns, options)
|
|
61
|
+
const indexed = turns.map((turn, index) => ({ turn, pinned: isPinned(index) }))
|
|
23
62
|
const keptFrom = Math.max(1, cutIndex - options.keepTurns)
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
const [first, ...rest] = turns[0]
|
|
64
|
+
const openingPinned = indexed[0].pinned
|
|
65
|
+
const openingFolded = !openingPinned && foldsOpening(turns[0], keptFrom)
|
|
66
|
+
const dropped = indexed.slice(1, keptFrom)
|
|
67
|
+
const whole = dropped.filter((t) => t.pinned).map((t) => t.turn)
|
|
68
|
+
const pinned = whole.length + indexed.slice(keptFrom, cutIndex).filter((t) => t.pinned).length + (openingPinned ? 1 : 0)
|
|
69
|
+
const kept = cutIndex - keptFrom + (openingFolded ? 1 : 0)
|
|
70
|
+
const notice = compactionNotice({ omitted: dropped.length - whole.length + (options.omittedBefore ?? 0), kept, pinned, guide: options.guide })
|
|
71
|
+
const removed = [...(openingPinned || openingFolded ? [] : rest), ...dropped.filter((t) => !t.pinned).flatMap((t) => t.turn)]
|
|
72
|
+
const shown = (t: { turn: Message[]; pinned: boolean }) => (t.pinned ? t.turn.map(clean) : reduce(t.turn, options))
|
|
73
|
+
return [
|
|
74
|
+
...preamble,
|
|
75
|
+
first,
|
|
76
|
+
...(openingPinned ? rest.map(clean) : []),
|
|
77
|
+
{ role: "user", content: [{ type: "text", text: notice }, ...carriedHarness(removed)] },
|
|
78
|
+
...(openingFolded ? reduce(turns[0], options).slice(1) : []),
|
|
79
|
+
...whole.flatMap((turn) => turn.map(clean)),
|
|
80
|
+
...indexed.slice(keptFrom, cutIndex).flatMap(shown),
|
|
81
|
+
...turns.slice(cutIndex).flat(),
|
|
82
|
+
]
|
|
31
83
|
}
|
|
32
84
|
|
|
33
85
|
export function capTurns(messages: Message[], options: CompactOptions): Message[] {
|
|
@@ -36,7 +88,8 @@ export function capTurns(messages: Message[], options: CompactOptions): Message[
|
|
|
36
88
|
const { preamble, turns } = splitTurns(messages)
|
|
37
89
|
if (turns.length < 3) return messages
|
|
38
90
|
const live = turns.length - 1
|
|
39
|
-
const
|
|
91
|
+
const isPinned = pinnedPrompt(options)
|
|
92
|
+
const over = (turn: Message[], i: number) => i > 0 && i < live && !isPinned(turn) && JSON.stringify(turn).length > capChars
|
|
40
93
|
if (!turns.some(over)) return messages
|
|
41
94
|
const reduce = options.reduce ?? reduceTurn
|
|
42
95
|
return [...preamble, ...turns.flatMap((turn, i) => (over(turn, i) ? reduce(turn, options) : turn))]
|
|
@@ -6,9 +6,18 @@ export interface ReduceOptions {
|
|
|
6
6
|
evidence?: boolean
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
const IMAGE_NOTE = /^\s*\[Image: original \d+x\d+, displayed at \d+x\d+\.[^\]]*\]\s*$/
|
|
10
|
+
|
|
11
|
+
const STALE_NOTE = /changed on disk since you last read it|was modified, either by the user or by a linter/
|
|
12
|
+
|
|
13
|
+
export const isStaleNote = (text: unknown) => STALE_NOTE.test(String(text ?? ""))
|
|
14
|
+
|
|
15
|
+
const isImageNote = (content: Block[]) => content.length > 0 && content.every((block) => block.type === "text" && IMAGE_NOTE.test(String(block.text ?? "")))
|
|
16
|
+
|
|
9
17
|
export function isPrompt(message: Message) {
|
|
10
18
|
if (message.role !== "user") return false
|
|
11
19
|
const content = blocks(message)
|
|
20
|
+
if (isImageNote(content)) return false
|
|
12
21
|
return !content.some((block) => block.type === "tool_result") && content.some((block) => block.type === "text" || block.type === "image" || block.type === "document")
|
|
13
22
|
}
|
|
14
23
|
|
|
@@ -28,6 +37,18 @@ export function turnFingerprint(turn: Message[]): string {
|
|
|
28
37
|
return shortHash(JSON.stringify(blocks(turn[0]).map(withoutCacheControl)), 24)
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
export function turnKeys(turns: Message[][]): string[] {
|
|
41
|
+
const copies = new Map<string, number>()
|
|
42
|
+
return turns.map((turn) => {
|
|
43
|
+
const fingerprint = turnFingerprint(turn)
|
|
44
|
+
const earlier = copies.get(fingerprint) ?? 0
|
|
45
|
+
copies.set(fingerprint, earlier + 1)
|
|
46
|
+
return earlier === 0 ? fingerprint : `${fingerprint}~${earlier}`
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const fingerprintOfKey = (key: string) => key.split("~")[0]
|
|
51
|
+
|
|
31
52
|
export function isHarness(message: Message) {
|
|
32
53
|
return message.role === "system"
|
|
33
54
|
}
|
|
@@ -39,7 +60,18 @@ export function hasContent(message: Message) {
|
|
|
39
60
|
const hasText = (block: Block) => block.type === "text" && String(block.text ?? "").trim() !== ""
|
|
40
61
|
|
|
41
62
|
function mergedHarness(turn: Message[]): Message[] {
|
|
42
|
-
const
|
|
63
|
+
const seen = new Set<string>()
|
|
64
|
+
const fresh = (block: Block) => {
|
|
65
|
+
if (block.type !== "text") return true
|
|
66
|
+
const text = String(block.text ?? "")
|
|
67
|
+
if (isStaleNote(text) || seen.has(text)) return false
|
|
68
|
+
seen.add(text)
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
const content = turn
|
|
72
|
+
.filter((m) => isHarness(m) && hasContent(m))
|
|
73
|
+
.flatMap((m) => blocks(m).map(withoutCacheControl))
|
|
74
|
+
.filter(fresh)
|
|
43
75
|
return content.length > 0 ? [{ role: "system", content }] : []
|
|
44
76
|
}
|
|
45
77
|
|
|
@@ -56,7 +88,7 @@ function interrupted(turn: Message[]): string {
|
|
|
56
88
|
function carriedText(messages: Message[]): Block[] {
|
|
57
89
|
return messages
|
|
58
90
|
.filter((m) => m.role === "user")
|
|
59
|
-
.flatMap((m) => blocks(m).filter((b) => hasText(b) && !/^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/.test(String(b.text))))
|
|
91
|
+
.flatMap((m) => blocks(m).filter((b) => hasText(b) && !IMAGE_NOTE.test(String(b.text)) && !/^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/.test(String(b.text))))
|
|
60
92
|
.map((b) => ({ type: "text", text: b.text }))
|
|
61
93
|
}
|
|
62
94
|
|
package/src/context/prompts.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type { TranscriptRef } from "../transcript/search"
|
|
2
1
|
import { truncate } from "./content"
|
|
3
2
|
|
|
4
3
|
interface NoticeCounts {
|
|
5
4
|
omitted: number
|
|
6
5
|
kept: number
|
|
6
|
+
pinned?: number
|
|
7
7
|
guide: string | null
|
|
8
8
|
}
|
|
9
9
|
|
|
@@ -68,7 +68,7 @@ export function clearedInput(kept: string, cleared: number): string {
|
|
|
68
68
|
return `${kept}… [${cleared} chars cleared; in the transcript]`
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
export function compactionNotice({ omitted, kept, guide }: NoticeCounts): string {
|
|
71
|
+
export function compactionNotice({ omitted, kept, pinned = 0, guide }: NoticeCounts): string {
|
|
72
72
|
return [
|
|
73
73
|
"<context-compacted>",
|
|
74
74
|
"Your context was compacted here, so tool calls and their output from before this point were removed.",
|
|
@@ -77,6 +77,7 @@ export function compactionNotice({ omitted, kept, guide }: NoticeCounts): string
|
|
|
77
77
|
: kept > 0
|
|
78
78
|
? "Every earlier turn is shown below as the user's message and your final reply."
|
|
79
79
|
: "",
|
|
80
|
+
pinned > 0 ? `${pinned === 1 ? "One earlier turn was" : `${pinned} earlier turns were`} kept whole, with their tool calls and output, because they matter to what comes next.` : "",
|
|
80
81
|
"The turn that was in progress when the context filled up follows in full. Continue from it.",
|
|
81
82
|
"Instructions and preferences the user gave in earlier turns still apply: how they want things done, and what to do or not do on your own. Look back over their messages before you act.",
|
|
82
83
|
"For an exact detail from before this point (an ID, a path, a command, an error, a value, what a tool printed), look it up instead of going from memory. A lookup is cheap; a wrong detail is not. Never say you don't know about something earlier in this session without searching for it first, and if what you remember contradicts what the user says, search before telling them they are wrong.",
|
|
@@ -137,7 +138,7 @@ export function searchOnlyInstructions(guide: string): string {
|
|
|
137
138
|
].join("\n")
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
export function searchGuide(ref:
|
|
141
|
+
export function searchGuide(ref: { mode: string; jsonl: string; dir: string }): string {
|
|
141
142
|
if (ref.mode === "jsonl") {
|
|
142
143
|
return [
|
|
143
144
|
`The full transcript is Claude Code's session file: ${ref.jsonl}`,
|
|
@@ -17,6 +17,8 @@ export interface RecallOptions {
|
|
|
17
17
|
minTerms?: number
|
|
18
18
|
maxChars?: number
|
|
19
19
|
maxCharsMulti?: number
|
|
20
|
+
maxCharsTotal?: number
|
|
21
|
+
charsInUse?: number
|
|
20
22
|
narrative?: boolean
|
|
21
23
|
callWeight?: number
|
|
22
24
|
salientExcerpt?: boolean
|
|
@@ -30,6 +32,8 @@ export interface RecallPick {
|
|
|
30
32
|
|
|
31
33
|
export type Reranker = (query: string, chunks: RecallChunk[], candidates: number[]) => Promise<number[]>
|
|
32
34
|
|
|
35
|
+
export const RECALL_CHARS_TOTAL = 30_000
|
|
36
|
+
|
|
33
37
|
const SHOWN_LINES = 8
|
|
34
38
|
const BM25_K1 = 1.2
|
|
35
39
|
const BM25_B = 0.75
|
|
@@ -123,9 +127,8 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
|
|
|
123
127
|
)
|
|
124
128
|
.filter(Boolean)
|
|
125
129
|
.join("\n")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
130
|
+
if (!said) return
|
|
131
|
+
const built = chunksOf(`${size}|said|${turnIndex}|${said}`, said, "agent", turnIndex + 1, () => windows(said.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })))
|
|
129
132
|
for (const chunk of built) chunks.push(chunk)
|
|
130
133
|
})
|
|
131
134
|
return chunks
|
|
@@ -389,7 +392,8 @@ export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rera
|
|
|
389
392
|
|
|
390
393
|
export function renderRecall(prompt: string, chunks: RecallChunk[], picks: RecallPick[], options: RecallOptions = {}): string | null {
|
|
391
394
|
if (picks.length === 0) return null
|
|
392
|
-
const
|
|
395
|
+
const own = queriesOf(prompt).length > 1 ? (options.maxCharsMulti ?? 30_000) : (options.maxChars ?? 18_000)
|
|
396
|
+
const budget = Math.min(own, (options.maxCharsTotal ?? RECALL_CHARS_TOTAL) - (options.charsInUse ?? 0))
|
|
393
397
|
const entries: string[] = []
|
|
394
398
|
let used = 0
|
|
395
399
|
for (const { index, query } of [...picks].sort((a, b) => chunks[a.index].turn - chunks[b.index].turn || a.index - b.index)) {
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
import { paths } from "../../config/paths"
|
|
3
1
|
import { rerankQuestion } from "../prompts"
|
|
4
2
|
import type { RecallChunk, Reranker } from "."
|
|
5
3
|
|
|
@@ -9,12 +7,6 @@ interface RerankAnswer {
|
|
|
9
7
|
|
|
10
8
|
const TIMEOUT_MS = 4_000
|
|
11
9
|
|
|
12
|
-
export function rerankKey(): string | null {
|
|
13
|
-
const env = process.env.TYPESAFE_API_KEY?.trim()
|
|
14
|
-
if (env) return env
|
|
15
|
-
return existsSync(paths.typesafeKey) ? readFileSync(paths.typesafeKey, "utf8").trim() : null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
10
|
function rerankState(chunks: RecallChunk[], candidates: number[]): string {
|
|
19
11
|
return candidates
|
|
20
12
|
.map((index, n) => {
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type
|
|
3
|
-
import { type
|
|
4
|
-
import { type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
1
|
+
import { type Body, type Message, quickHash } from "../context/content"
|
|
2
|
+
import { type CompactOptions, capTurns, clearToolResults, compactMessages, countPinned, countToolResults, foldsOpening } from "../context/fold/strategies"
|
|
3
|
+
import { type ReduceOptions, reduceTurn, splitTurns, turnKeys } from "../context/fold/turns"
|
|
5
4
|
import { clearedOutput } from "../context/prompts"
|
|
5
|
+
import type { CoreConfig } from "./config"
|
|
6
6
|
import { DEFAULT_RATIO, type Estimator, estimator, targetSize } from "./estimate"
|
|
7
|
-
import {
|
|
7
|
+
import type { RewriteNote } from "./rewrite"
|
|
8
|
+
import { resetCut, retireRecalls, type ThreadState } from "./state"
|
|
8
9
|
|
|
9
10
|
export interface CompactInput {
|
|
10
11
|
body: Body
|
|
11
|
-
config:
|
|
12
|
+
config: CoreConfig
|
|
12
13
|
state: ThreadState
|
|
13
14
|
window: number | null
|
|
14
15
|
guide: string | null
|
|
@@ -24,7 +25,7 @@ const FOLDED_TURNS = 4096
|
|
|
24
25
|
const folded = new Map<string, Message[]>()
|
|
25
26
|
|
|
26
27
|
function reduceRemembered(turn: Message[], options: ReduceOptions): Message[] {
|
|
27
|
-
const key = `${options.evidence ? 1 : 0}:${
|
|
28
|
+
const key = `${options.evidence ? 1 : 0}:${quickHash(JSON.stringify(turn))}`
|
|
28
29
|
const known = folded.get(key)
|
|
29
30
|
if (known) {
|
|
30
31
|
folded.delete(key)
|
|
@@ -40,7 +41,7 @@ function reduceRemembered(turn: Message[], options: ReduceOptions): Message[] {
|
|
|
40
41
|
export interface CompactResult {
|
|
41
42
|
messages: Message[]
|
|
42
43
|
stateChanged: boolean
|
|
43
|
-
note:
|
|
44
|
+
note: RewriteNote
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
interface Rolling {
|
|
@@ -56,8 +57,8 @@ function settledCut(turns: Message[][], index: number) {
|
|
|
56
57
|
return cut
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function resolveCut(turns: Message[][], state: ThreadState, config:
|
|
60
|
-
let cutIndex = state.cut ?
|
|
60
|
+
function resolveCut(turns: Message[][], keys: string[], state: ThreadState, config: CoreConfig, rollingKeep: number | undefined) {
|
|
61
|
+
let cutIndex = state.cut ? keys.indexOf(state.cut) : -1
|
|
61
62
|
let stateChanged = false
|
|
62
63
|
if (state.cut && cutIndex === -1) {
|
|
63
64
|
resetCut(state)
|
|
@@ -65,7 +66,8 @@ function resolveCut(turns: Message[][], state: ThreadState, config: Config, roll
|
|
|
65
66
|
}
|
|
66
67
|
if (cutIndex <= 0 && config.eval?.forceCut && turns.length >= 3) {
|
|
67
68
|
cutIndex = settledCut(turns, Math.max(1, turns.length - 1 - (rollingKeep ?? 1)))
|
|
68
|
-
state.cut =
|
|
69
|
+
state.cut = keys[cutIndex]
|
|
70
|
+
retireRecalls(state, turns.slice(0, cutIndex))
|
|
69
71
|
stateChanged = true
|
|
70
72
|
}
|
|
71
73
|
return { cutIndex, stateChanged }
|
|
@@ -98,16 +100,77 @@ function shrinkToTarget(over: () => boolean, step: () => string, actions: string
|
|
|
98
100
|
}
|
|
99
101
|
}
|
|
100
102
|
|
|
103
|
+
interface Shrink {
|
|
104
|
+
estimate: () => number
|
|
105
|
+
step: () => string
|
|
106
|
+
rebuild: () => void
|
|
107
|
+
target: number
|
|
108
|
+
limit: number
|
|
109
|
+
window: number | null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shrinkOrHold(state: ThreadState, actions: string[], shrink: Shrink) {
|
|
113
|
+
const overWindow = (tokens: number) => shrink.window !== null && tokens > shrink.window
|
|
114
|
+
const held = state.stuck === undefined ? null : { keep: state.keep, cleared: state.cleared, actions: actions.length, overWindow: overWindow(shrink.estimate()) }
|
|
115
|
+
shrinkToTarget(() => shrink.estimate() > shrink.target, shrink.step, actions, shrink.rebuild)
|
|
116
|
+
const helped = (estimate: number) => estimate <= shrink.limit || (held?.overWindow === true && !overWindow(estimate))
|
|
117
|
+
if (held && !helped(shrink.estimate())) {
|
|
118
|
+
restoreShrink(state, held)
|
|
119
|
+
actions.length = held.actions
|
|
120
|
+
shrink.rebuild()
|
|
121
|
+
}
|
|
122
|
+
if (shrink.estimate() > shrink.limit) state.stuck = shrink.estimate()
|
|
123
|
+
else {
|
|
124
|
+
delete state.stuck
|
|
125
|
+
if (actions.at(-1) === "stuck") actions.pop()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function restoreShrink(state: ThreadState, held: Pick<ThreadState, "keep" | "cleared">) {
|
|
130
|
+
if (held.keep === undefined) delete state.keep
|
|
131
|
+
else state.keep = held.keep
|
|
132
|
+
if (held.cleared === undefined) delete state.cleared
|
|
133
|
+
else state.cleared = held.cleared
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type StuckSizes = ReturnType<typeof stuckSizes>
|
|
137
|
+
|
|
138
|
+
const headLength = (preamble: Message[], first: Message[], firstKey: string, state: ThreadState) => preamble.length + 1 + (countPinned([firstKey], new Set(state.pins)) > 0 ? first.length : 1)
|
|
139
|
+
|
|
140
|
+
function stuckSizes(messages: Message[], headEnd: number, liveFrom: number, size: Estimator) {
|
|
141
|
+
const of = (list: Message[]) => size.of(list) - size.fixed
|
|
142
|
+
return { fixed: size.fixed, head: of(messages.slice(0, headEnd)), folded: of(messages.slice(headEnd, liveFrom)), live: of(messages.slice(liveFrom)) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cutNote(state: ThreadState, turns: Message[][], keys: string[], cutIndex: number, configuredKeep: number, actions: string[]) {
|
|
146
|
+
const pins = new Set(state.pins)
|
|
147
|
+
const keptFrom = cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) : 0
|
|
148
|
+
const omitted = cutIndex > 0 ? keptFrom - 1 - countPinned(keys.slice(1, keptFrom), pins) : 0
|
|
149
|
+
return {
|
|
150
|
+
action: cutIndex > 0 ? ("compact" as const) : ("full" as const),
|
|
151
|
+
compacted: actions.includes("cut") || actions.includes("roll"),
|
|
152
|
+
actions: actions.length > 0 ? actions : undefined,
|
|
153
|
+
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
154
|
+
omitted,
|
|
155
|
+
folded: cutIndex > 0 ? cutIndex - keptFrom - countPinned(keys.slice(keptFrom, cutIndex), pins) + (foldsOpening(turns[0], keptFrom) && countPinned([keys[0]], pins) === 0 ? 1 : 0) : 0,
|
|
156
|
+
pinned: cutIndex > 0 ? countPinned(keys.slice(0, cutIndex), pins) : 0,
|
|
157
|
+
host: state.host,
|
|
158
|
+
keep: state.keep,
|
|
159
|
+
cleared: state.cleared,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
101
163
|
export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
|
|
102
|
-
const { turns } = splitTurns(body.messages)
|
|
164
|
+
const { preamble, turns } = splitTurns(body.messages)
|
|
103
165
|
const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
|
|
104
|
-
const
|
|
166
|
+
const keys = turnKeys(turns)
|
|
167
|
+
const resolved = resolveCut(turns, keys, state, config, rolling?.keep)
|
|
105
168
|
let cutIndex = resolved.cutIndex
|
|
106
169
|
let stateChanged = resolved.stateChanged
|
|
107
170
|
|
|
108
171
|
const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
|
|
109
172
|
const ratio = state.ratio ?? DEFAULT_RATIO
|
|
110
|
-
const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce: reduce ?? reduceRemembered, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio }
|
|
173
|
+
const options = { guide, omittedBefore, evidence: config.eval?.evidence ?? true, reduce: reduce ?? reduceRemembered, capChars: config.turnCap === "off" ? undefined : config.turnCap / ratio, pinned: new Set(state.pins) }
|
|
111
174
|
const liveStart = (list: Message[]) =>
|
|
112
175
|
list.length -
|
|
113
176
|
splitTurns(list)
|
|
@@ -137,7 +200,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
137
200
|
const moveCut = (index: number, action: string) => {
|
|
138
201
|
cutIndex = index
|
|
139
202
|
resetCut(state)
|
|
140
|
-
state.cut =
|
|
203
|
+
state.cut = keys[index]
|
|
204
|
+
retireRecalls(state, turns.slice(0, index))
|
|
141
205
|
actions.push(action)
|
|
142
206
|
rebuild()
|
|
143
207
|
}
|
|
@@ -151,12 +215,10 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
151
215
|
if (mayCompact && estimate > limit) {
|
|
152
216
|
const current = turns.length - 1
|
|
153
217
|
if (current > 0 && current > cutIndex) moveCut(current, "cut")
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
rebuild,
|
|
159
|
-
)
|
|
218
|
+
shrinkOrHold(state, actions, { estimate: () => estimate, step: () => shrinkOnce(state, cutIndex, configuredKeep, liveResults), rebuild, target, limit, window })
|
|
219
|
+
stateChanged = true
|
|
220
|
+
} else if (state.stuck !== undefined) {
|
|
221
|
+
delete state.stuck
|
|
160
222
|
stateChanged = true
|
|
161
223
|
}
|
|
162
224
|
|
|
@@ -164,13 +226,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
164
226
|
messages,
|
|
165
227
|
stateChanged,
|
|
166
228
|
note: {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
actions: actions.length > 0 ? actions : undefined,
|
|
170
|
-
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
171
|
-
omitted: cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) - 1 : 0,
|
|
172
|
-
keep: state.keep,
|
|
173
|
-
cleared: state.cleared,
|
|
229
|
+
...cutNote(state, turns, keys, cutIndex, configuredKeep, actions),
|
|
230
|
+
stuck: state.stuck === undefined ? undefined : stuckSizes(messages, headLength(preamble, turns[0], keys[0], state), liveStart(messages), size),
|
|
174
231
|
turns: turns.length,
|
|
175
232
|
messagesIn: body.messages.length,
|
|
176
233
|
messagesOut: messages.length,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Mode = "off" | "compact" | "default"
|
|
2
|
+
|
|
3
|
+
interface EvalConfig {
|
|
4
|
+
recallOptions?: { maxChars?: number; maxCharsMulti?: number; maxCharsTotal?: number; perQuery?: number }
|
|
5
|
+
strategy?: "tail" | "guide" | "trim"
|
|
6
|
+
rolling?: { keep: number; budget: number }
|
|
7
|
+
forceCut?: boolean
|
|
8
|
+
trustHeaders?: boolean
|
|
9
|
+
evidence?: boolean
|
|
10
|
+
recall?: boolean
|
|
11
|
+
rerank?: { model: string; candidates?: number; endpoint?: string }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CoreConfig {
|
|
15
|
+
mode: Mode
|
|
16
|
+
compactAt: number
|
|
17
|
+
keepTurns: number | "all"
|
|
18
|
+
keep: number
|
|
19
|
+
budget: number
|
|
20
|
+
turnCap: number | "off"
|
|
21
|
+
eval?: EvalConfig
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const CORE_DEFAULTS: CoreConfig = {
|
|
25
|
+
mode: "default",
|
|
26
|
+
compactAt: 0.9,
|
|
27
|
+
keepTurns: "all",
|
|
28
|
+
keep: 3,
|
|
29
|
+
budget: 100_000,
|
|
30
|
+
turnCap: 100_000,
|
|
31
|
+
}
|