@chroxify/eunoe 1.2.2 → 1.3.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/README.md +139 -2
- package/dist/core/index.js +2235 -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 +14 -0
- package/dist/types/context/prompts.d.ts +24 -0
- package/dist/types/context/recall/index.d.ts +44 -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 +33 -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 +8 -0
- package/dist/types/core/rewrite.d.ts +63 -0
- package/dist/types/core/state.d.ts +22 -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 +7 -0
- package/dist/types/core/window.d.ts +5 -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 +55 -10
- package/src/context/fold/turns.ts +22 -2
- package/src/context/prompts.ts +4 -3
- package/src/context/recall/index.ts +2 -3
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +56 -24
- package/src/core/config.ts +31 -0
- package/src/core/fold.ts +69 -0
- package/src/core/forward.ts +192 -0
- package/src/{proxy → core}/handoff.ts +23 -23
- package/src/core/index.ts +14 -0
- package/src/{proxy → core}/recall.ts +18 -20
- package/src/core/rewrite.ts +189 -0
- package/src/core/state.ts +40 -0
- package/src/core/subagents.ts +28 -0
- package/src/core/transcript.ts +44 -0
- package/src/{proxy → core}/usage.ts +34 -1
- package/src/core/window.ts +39 -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 → core}/estimate.ts +0 -0
- /package/src/{proxy → core}/fingerprint.ts +0 -0
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 { hasContent, isHarness, isStaleNote, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "./turns"
|
|
4
4
|
|
|
5
5
|
export interface CompactOptions extends ReduceOptions {
|
|
6
6
|
keepTurns: number
|
|
@@ -8,26 +8,70 @@ 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
|
+
const pinnedBy =
|
|
15
|
+
({ pinned }: CompactOptions) =>
|
|
16
|
+
(turn: Message[]) =>
|
|
17
|
+
pinned !== undefined && pinned.size > 0 && pinned.has(turnFingerprint(turn))
|
|
18
|
+
|
|
19
|
+
export const countPinned = (turns: Message[][], pinned: ReadonlySet<string>) => (pinned.size > 0 ? turns.filter((turn) => pinned.has(turnFingerprint(turn))).length : 0)
|
|
20
|
+
|
|
13
21
|
export function trimMessages(messages: Message[], options: ReduceOptions = {}): Message[] {
|
|
14
22
|
const { preamble, turns } = splitTurns(messages)
|
|
15
23
|
if (turns.length < 2) return messages
|
|
16
24
|
return [...preamble, ...turns.slice(0, -1).flatMap((turn) => reduceTurn(turn, options)), ...(turns.at(-1) ?? [])]
|
|
17
25
|
}
|
|
18
26
|
|
|
27
|
+
export const CARRIED_CHARS = 80_000
|
|
28
|
+
|
|
29
|
+
function carriedHarness(removed: Message[]): Block[] {
|
|
30
|
+
const texts = removed
|
|
31
|
+
.filter((m) => isHarness(m) && hasContent(m))
|
|
32
|
+
.flatMap((m) => blocks(m).filter((b) => b.type === "text"))
|
|
33
|
+
.map((b) => String(b.text ?? ""))
|
|
34
|
+
.filter((text) => text.trim() && !isStaleNote(text))
|
|
35
|
+
const kept: string[] = []
|
|
36
|
+
const seen = new Set<string>()
|
|
37
|
+
let used = 0
|
|
38
|
+
for (const text of texts.toReversed()) {
|
|
39
|
+
if (seen.has(text) || used + text.length > CARRIED_CHARS) continue
|
|
40
|
+
seen.add(text)
|
|
41
|
+
kept.push(text)
|
|
42
|
+
used += text.length
|
|
43
|
+
}
|
|
44
|
+
return kept.toReversed().map((text) => ({ type: "text", text: `<system-reminder>\n${text}\n</system-reminder>` }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const foldsOpening = (opening: Message[], keptFrom: number) => keptFrom === 1 && opening.length > 1
|
|
48
|
+
|
|
19
49
|
export function compactMessages(messages: Message[], cutIndex: number, options: CompactOptions): Message[] {
|
|
20
50
|
const { preamble, turns } = splitTurns(messages)
|
|
21
51
|
if (cutIndex <= 0 || cutIndex >= turns.length) return messages
|
|
22
52
|
const reduce = options.reduce ?? reduceTurn
|
|
53
|
+
const isPinned = pinnedBy(options)
|
|
23
54
|
const keptFrom = Math.max(1, cutIndex - options.keepTurns)
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
55
|
+
const [first, ...rest] = turns[0]
|
|
56
|
+
const openingPinned = isPinned(turns[0])
|
|
57
|
+
const openingFolded = !openingPinned && foldsOpening(turns[0], keptFrom)
|
|
58
|
+
const dropped = turns.slice(1, keptFrom)
|
|
59
|
+
const whole = dropped.filter(isPinned)
|
|
60
|
+
const pinned = whole.length + turns.slice(keptFrom, cutIndex).filter(isPinned).length + (openingPinned ? 1 : 0)
|
|
61
|
+
const kept = cutIndex - keptFrom + (openingFolded ? 1 : 0)
|
|
62
|
+
const notice = compactionNotice({ omitted: dropped.length - whole.length + (options.omittedBefore ?? 0), kept, pinned, guide: options.guide })
|
|
63
|
+
const removed = [...(openingPinned || openingFolded ? [] : rest), ...dropped.filter((turn) => !isPinned(turn)).flat()]
|
|
64
|
+
const shown = (turn: Message[]) => (isPinned(turn) ? turn.map(clean) : reduce(turn, options))
|
|
65
|
+
return [
|
|
66
|
+
...preamble,
|
|
67
|
+
first,
|
|
68
|
+
...(openingPinned ? rest.map(clean) : []),
|
|
69
|
+
{ role: "user", content: [{ type: "text", text: notice }, ...carriedHarness(removed)] },
|
|
70
|
+
...(openingFolded ? reduce(turns[0], options).slice(1) : []),
|
|
71
|
+
...whole.flatMap((turn) => turn.map(clean)),
|
|
72
|
+
...turns.slice(keptFrom, cutIndex).flatMap(shown),
|
|
73
|
+
...turns.slice(cutIndex).flat(),
|
|
74
|
+
]
|
|
31
75
|
}
|
|
32
76
|
|
|
33
77
|
export function capTurns(messages: Message[], options: CompactOptions): Message[] {
|
|
@@ -36,7 +80,8 @@ export function capTurns(messages: Message[], options: CompactOptions): Message[
|
|
|
36
80
|
const { preamble, turns } = splitTurns(messages)
|
|
37
81
|
if (turns.length < 3) return messages
|
|
38
82
|
const live = turns.length - 1
|
|
39
|
-
const
|
|
83
|
+
const isPinned = pinnedBy(options)
|
|
84
|
+
const over = (turn: Message[], i: number) => i > 0 && i < live && !isPinned(turn) && JSON.stringify(turn).length > capChars
|
|
40
85
|
if (!turns.some(over)) return messages
|
|
41
86
|
const reduce = options.reduce ?? reduceTurn
|
|
42
87
|
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
|
|
|
@@ -39,7 +48,18 @@ export function hasContent(message: Message) {
|
|
|
39
48
|
const hasText = (block: Block) => block.type === "text" && String(block.text ?? "").trim() !== ""
|
|
40
49
|
|
|
41
50
|
function mergedHarness(turn: Message[]): Message[] {
|
|
42
|
-
const
|
|
51
|
+
const seen = new Set<string>()
|
|
52
|
+
const fresh = (block: Block) => {
|
|
53
|
+
if (block.type !== "text") return true
|
|
54
|
+
const text = String(block.text ?? "")
|
|
55
|
+
if (isStaleNote(text) || seen.has(text)) return false
|
|
56
|
+
seen.add(text)
|
|
57
|
+
return true
|
|
58
|
+
}
|
|
59
|
+
const content = turn
|
|
60
|
+
.filter((m) => isHarness(m) && hasContent(m))
|
|
61
|
+
.flatMap((m) => blocks(m).map(withoutCacheControl))
|
|
62
|
+
.filter(fresh)
|
|
43
63
|
return content.length > 0 ? [{ role: "system", content }] : []
|
|
44
64
|
}
|
|
45
65
|
|
|
@@ -56,7 +76,7 @@ function interrupted(turn: Message[]): string {
|
|
|
56
76
|
function carriedText(messages: Message[]): Block[] {
|
|
57
77
|
return messages
|
|
58
78
|
.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))))
|
|
79
|
+
.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
80
|
.map((b) => ({ type: "text", text: b.text }))
|
|
61
81
|
}
|
|
62
82
|
|
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}`,
|
|
@@ -123,9 +123,8 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
|
|
|
123
123
|
)
|
|
124
124
|
.filter(Boolean)
|
|
125
125
|
.join("\n")
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
)
|
|
126
|
+
if (!said) return
|
|
127
|
+
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
128
|
for (const chunk of built) chunks.push(chunk)
|
|
130
129
|
})
|
|
131
130
|
return chunks
|
|
@@ -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 CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
|
|
1
|
+
import { type Body, type Message, quickHash } from "../context/content"
|
|
2
|
+
import { type CompactOptions, capTurns, clearToolResults, compactMessages, countPinned, countToolResults, foldsOpening } from "../context/fold/strategies"
|
|
4
3
|
import { type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } 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 type { RewriteNote } from "./rewrite"
|
|
7
8
|
import { resetCut, 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 {
|
|
@@ -50,13 +51,15 @@ interface Rolling {
|
|
|
50
51
|
|
|
51
52
|
const PROTECTED_OUTPUTS = 5
|
|
52
53
|
|
|
54
|
+
const STUCK_STEP = 0.1
|
|
55
|
+
|
|
53
56
|
function settledCut(turns: Message[][], index: number) {
|
|
54
57
|
let cut = index
|
|
55
58
|
while (cut > 1 && JSON.stringify(turns[cut][0].content).includes("[Request interrupted")) cut -= 1
|
|
56
59
|
return cut
|
|
57
60
|
}
|
|
58
61
|
|
|
59
|
-
function resolveCut(turns: Message[][], state: ThreadState, config:
|
|
62
|
+
function resolveCut(turns: Message[][], state: ThreadState, config: CoreConfig, rollingKeep: number | undefined) {
|
|
60
63
|
let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
|
|
61
64
|
let stateChanged = false
|
|
62
65
|
if (state.cut && cutIndex === -1) {
|
|
@@ -89,17 +92,47 @@ function dueRoll(turns: Message[][], cutIndex: number, rolling: Rolling, size: E
|
|
|
89
92
|
return next >= 1 && next > cutIndex && backlog > rolling.budget ? next : null
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void) {
|
|
95
|
+
function shrinkToTarget(over: () => boolean, step: () => string, actions: string[], rebuild: () => void): boolean {
|
|
93
96
|
while (over()) {
|
|
94
97
|
const action = step()
|
|
95
98
|
actions.push(action)
|
|
96
|
-
if (action === "stuck") return
|
|
99
|
+
if (action === "stuck") return true
|
|
97
100
|
rebuild()
|
|
98
101
|
}
|
|
102
|
+
return false
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const holdsStuck = (state: ThreadState, estimate: number, window: number | null) => window !== null && state.stuck !== undefined && estimate <= state.stuck + STUCK_STEP * window
|
|
106
|
+
|
|
107
|
+
export type StuckSizes = ReturnType<typeof stuckSizes>
|
|
108
|
+
|
|
109
|
+
const headLength = (preamble: Message[], first: Message[], state: ThreadState) => preamble.length + 1 + (countPinned([first], new Set(state.pins)) > 0 ? first.length : 1)
|
|
110
|
+
|
|
111
|
+
function stuckSizes(messages: Message[], headEnd: number, liveFrom: number, size: Estimator) {
|
|
112
|
+
const of = (list: Message[]) => size.of(list) - size.fixed
|
|
113
|
+
return { fixed: size.fixed, head: of(messages.slice(0, headEnd)), folded: of(messages.slice(headEnd, liveFrom)), live: of(messages.slice(liveFrom)) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cutNote(state: ThreadState, turns: Message[][], cutIndex: number, configuredKeep: number, actions: string[]) {
|
|
117
|
+
const pins = new Set(state.pins)
|
|
118
|
+
const keptFrom = cutIndex > 0 ? Math.max(1, cutIndex - (state.keep ?? configuredKeep)) : 0
|
|
119
|
+
const omitted = cutIndex > 0 ? keptFrom - 1 - countPinned(turns.slice(1, keptFrom), pins) : 0
|
|
120
|
+
return {
|
|
121
|
+
action: cutIndex > 0 ? ("compact" as const) : ("full" as const),
|
|
122
|
+
compacted: actions.includes("cut") || actions.includes("roll"),
|
|
123
|
+
actions: actions.length > 0 ? actions : undefined,
|
|
124
|
+
cutTurn: cutIndex > 0 ? cutIndex : null,
|
|
125
|
+
omitted,
|
|
126
|
+
folded: cutIndex > 0 ? cutIndex - keptFrom - countPinned(turns.slice(keptFrom, cutIndex), pins) + (foldsOpening(turns[0], keptFrom) && countPinned([turns[0]], pins) === 0 ? 1 : 0) : 0,
|
|
127
|
+
pinned: cutIndex > 0 ? countPinned(turns.slice(0, cutIndex), pins) : 0,
|
|
128
|
+
host: state.host,
|
|
129
|
+
keep: state.keep,
|
|
130
|
+
cleared: state.cleared,
|
|
131
|
+
}
|
|
99
132
|
}
|
|
100
133
|
|
|
101
134
|
export function compact({ body, config, state, window, guide, transcriptDir, mayCompact, omittedBefore = 0, decorate = (m) => m, reduce }: CompactInput): CompactResult {
|
|
102
|
-
const { turns } = splitTurns(body.messages)
|
|
135
|
+
const { preamble, turns } = splitTurns(body.messages)
|
|
103
136
|
const rolling = config.eval?.rolling ?? (config.mode === "default" ? { keep: config.keep, budget: config.budget } : undefined)
|
|
104
137
|
const resolved = resolveCut(turns, state, config, rolling?.keep)
|
|
105
138
|
let cutIndex = resolved.cutIndex
|
|
@@ -107,7 +140,7 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
107
140
|
|
|
108
141
|
const configuredKeep = config.keepTurns === "all" ? Number.MAX_SAFE_INTEGER : config.keepTurns
|
|
109
142
|
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 }
|
|
143
|
+
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
144
|
const liveStart = (list: Message[]) =>
|
|
112
145
|
list.length -
|
|
113
146
|
splitTurns(list)
|
|
@@ -151,12 +184,16 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
151
184
|
if (mayCompact && estimate > limit) {
|
|
152
185
|
const current = turns.length - 1
|
|
153
186
|
if (current > 0 && current > cutIndex) moveCut(current, "cut")
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
187
|
+
if (!holdsStuck(state, estimate, window)) {
|
|
188
|
+
const stuck = shrinkToTarget(
|
|
189
|
+
() => estimate > target,
|
|
190
|
+
() => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
|
|
191
|
+
actions,
|
|
192
|
+
rebuild,
|
|
193
|
+
)
|
|
194
|
+
if (stuck) state.stuck = estimate
|
|
195
|
+
else delete state.stuck
|
|
196
|
+
}
|
|
160
197
|
stateChanged = true
|
|
161
198
|
}
|
|
162
199
|
|
|
@@ -164,13 +201,8 @@ export function compact({ body, config, state, window, guide, transcriptDir, may
|
|
|
164
201
|
messages,
|
|
165
202
|
stateChanged,
|
|
166
203
|
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,
|
|
204
|
+
...cutNote(state, turns, cutIndex, configuredKeep, actions),
|
|
205
|
+
stuck: state.stuck === undefined ? undefined : stuckSizes(messages, headLength(preamble, turns[0], state), liveStart(messages), size),
|
|
174
206
|
turns: turns.length,
|
|
175
207
|
messagesIn: body.messages.length,
|
|
176
208
|
messagesOut: messages.length,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Mode = "off" | "compact" | "default"
|
|
2
|
+
|
|
3
|
+
interface EvalConfig {
|
|
4
|
+
recallOptions?: { maxChars?: number; maxCharsMulti?: 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
|
+
}
|
package/src/core/fold.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Message } from "../context/content"
|
|
2
|
+
import { splitTurns, turnFingerprint } from "../context/fold/turns"
|
|
3
|
+
import { threadKeyOf } from "./rewrite"
|
|
4
|
+
import { resetCut, type ThreadState, type ThreadStore } from "./state"
|
|
5
|
+
|
|
6
|
+
export interface FoldTarget {
|
|
7
|
+
store: ThreadStore
|
|
8
|
+
threadId: string
|
|
9
|
+
messages: Message[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface FoldOptions {
|
|
13
|
+
at?: number | "latest"
|
|
14
|
+
pin?: number[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Folded {
|
|
18
|
+
threadKey: string
|
|
19
|
+
cutTurn: number | null
|
|
20
|
+
pinned: number[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function located({ store, threadId, messages }: FoldTarget) {
|
|
24
|
+
const threadKey = threadKeyOf(threadId, messages)
|
|
25
|
+
if (!threadKey) throw new RangeError("eunoe can only fold a thread that has at least one user turn")
|
|
26
|
+
return { threadKey, turns: splitTurns(messages).turns, state: store.read(threadKey) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function checkedTurn(turn: number, turns: Message[][], from: number): number {
|
|
30
|
+
if (turns.length <= from) throw new RangeError(`this thread has ${turns.length} turn${turns.length === 1 ? "" : "s"}, so there is no turn ${turn} to fold at`)
|
|
31
|
+
if (!Number.isInteger(turn) || turn < from || turn >= turns.length) throw new RangeError(`turn ${turn} is outside turns ${from}..${turns.length - 1}`)
|
|
32
|
+
return turn
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function folded(threadKey: string, turns: Message[][], state: ThreadState): Folded {
|
|
36
|
+
const cutTurn = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
|
|
37
|
+
const pins = new Set(state.pins)
|
|
38
|
+
return { threadKey, cutTurn: cutTurn > 0 ? cutTurn : null, pinned: turns.flatMap((turn, i) => (pins.has(turnFingerprint(turn)) ? [i] : [])) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withPins(state: ThreadState, turns: Message[][], indices: number[], pinned: boolean) {
|
|
42
|
+
const pins = new Set(state.pins)
|
|
43
|
+
for (const index of indices) {
|
|
44
|
+
const fingerprint = turnFingerprint(turns[checkedTurn(index, turns, 0)])
|
|
45
|
+
if (pinned) pins.add(fingerprint)
|
|
46
|
+
else pins.delete(fingerprint)
|
|
47
|
+
}
|
|
48
|
+
if (pins.size > 0) state.pins = [...pins].sort()
|
|
49
|
+
else delete state.pins
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function foldThread(target: FoldTarget, { at = "latest", pin = [] }: FoldOptions = {}): Folded {
|
|
53
|
+
const { threadKey, turns, state } = located(target)
|
|
54
|
+
const cut = checkedTurn(at === "latest" ? turns.length - 1 : at, turns, 1)
|
|
55
|
+
withPins(state, turns, pin, true)
|
|
56
|
+
resetCut(state)
|
|
57
|
+
state.cut = turnFingerprint(turns[cut])
|
|
58
|
+
state.keep = cut - 1
|
|
59
|
+
state.host = true
|
|
60
|
+
target.store.save(threadKey, state)
|
|
61
|
+
return folded(threadKey, turns, state)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function pinTurns(target: FoldTarget, indices: number[], pinned = true): Folded {
|
|
65
|
+
const { threadKey, turns, state } = located(target)
|
|
66
|
+
withPins(state, turns, indices, pinned)
|
|
67
|
+
target.store.save(threadKey, state)
|
|
68
|
+
return folded(threadKey, turns, state)
|
|
69
|
+
}
|