@chroxify/eunoe 1.2.1 → 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 +143 -5
- 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/serve.ts +2 -0
- 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 +259 -35
- package/src/context/recall/rerank.ts +0 -8
- package/src/{proxy → core}/compaction.ts +74 -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 +24 -137
- package/src/proxy/sessions.ts +1 -1
- package/src/proxy/sources.ts +35 -0
- package/src/proxy/state.ts +32 -21
- package/src/proxy/warm.ts +68 -0
- package/src/proxy/window.ts +3 -33
- package/src/transcript/entries.ts +114 -0
- package/src/transcript/format.ts +1 -2
- package/src/transcript/messages.ts +14 -11
- package/src/transcript/render-worker.ts +24 -0
- package/src/transcript/render.ts +12 -3
- package/src/transcript/search.ts +46 -5
- package/src/transcript/subagents.ts +55 -34
- /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 { 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}`,
|
|
@@ -50,6 +50,50 @@ function windows(lines: string[], size: number): string[][] {
|
|
|
50
50
|
return out
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
interface ChunkedText {
|
|
54
|
+
text: string
|
|
55
|
+
head: string
|
|
56
|
+
turn: number
|
|
57
|
+
chunks: RecallChunk[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CHUNK_CACHE_TERMS = 8_000_000
|
|
61
|
+
|
|
62
|
+
const chunked = new Map<string, ChunkedText>()
|
|
63
|
+
let chunkedTerms = 0
|
|
64
|
+
|
|
65
|
+
const termCount = (chunks: RecallChunk[]) => chunks.reduce((sum, chunk) => sum + chunk.terms.length, 0)
|
|
66
|
+
|
|
67
|
+
function forget(key: string) {
|
|
68
|
+
const known = chunked.get(key)
|
|
69
|
+
if (!known) return
|
|
70
|
+
chunked.delete(key)
|
|
71
|
+
chunkedTerms -= termCount(known.chunks)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function chunksOf(key: string | null, text: string, head: string, turn: number, build: () => RecallChunk[]): RecallChunk[] {
|
|
75
|
+
if (key === null) return build()
|
|
76
|
+
const known = chunked.get(key)
|
|
77
|
+
if (known && known.text === text && known.head === head) {
|
|
78
|
+
chunked.delete(key)
|
|
79
|
+
chunked.set(key, known)
|
|
80
|
+
if (known.turn !== turn) {
|
|
81
|
+
known.turn = turn
|
|
82
|
+
known.chunks = known.chunks.map((chunk) => ({ ...chunk, turn }))
|
|
83
|
+
}
|
|
84
|
+
return known.chunks
|
|
85
|
+
}
|
|
86
|
+
forget(key)
|
|
87
|
+
const chunks = build()
|
|
88
|
+
chunked.set(key, { text, head, turn, chunks })
|
|
89
|
+
chunkedTerms += termCount(chunks)
|
|
90
|
+
for (const oldest of chunked.keys()) {
|
|
91
|
+
if (chunkedTerms <= CHUNK_CACHE_TERMS || oldest === key) break
|
|
92
|
+
forget(oldest)
|
|
93
|
+
}
|
|
94
|
+
return chunks
|
|
95
|
+
}
|
|
96
|
+
|
|
53
97
|
export function recallChunks(messages: Message[], keptIds: Map<string, string>, options: RecallOptions = {}): RecallChunk[] {
|
|
54
98
|
const size = options.windowLines ?? 6
|
|
55
99
|
const weight = Math.max(1, options.callWeight ?? 1)
|
|
@@ -61,8 +105,12 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
|
|
|
61
105
|
const text = resultText(result)
|
|
62
106
|
if (keptIds.get(call.id) === text || !text.trim()) continue
|
|
63
107
|
const head = describeToolCall(call)
|
|
64
|
-
const
|
|
65
|
-
|
|
108
|
+
const key = typeof call.id === "string" && call.id ? `${size}|${weight}|${call.id}` : null
|
|
109
|
+
const built = chunksOf(key, text, head, turnIndex + 1, () => {
|
|
110
|
+
const headTerms = Array.from({ length: weight }, () => terms(head)).flat()
|
|
111
|
+
return windows(text.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: head, lines: window, terms: [...headTerms, ...terms(window.join("\n"))] }))
|
|
112
|
+
})
|
|
113
|
+
for (const chunk of built) chunks.push(chunk)
|
|
66
114
|
}
|
|
67
115
|
if (!(options.narrative ?? true)) return
|
|
68
116
|
const said = turn
|
|
@@ -74,34 +122,214 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
|
|
|
74
122
|
.map((b) => String(b.text ?? "").trim()),
|
|
75
123
|
)
|
|
76
124
|
.filter(Boolean)
|
|
77
|
-
|
|
125
|
+
.join("\n")
|
|
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")) })))
|
|
128
|
+
for (const chunk of built) chunks.push(chunk)
|
|
78
129
|
})
|
|
79
130
|
return chunks
|
|
80
131
|
}
|
|
81
132
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
133
|
+
interface RankedChunk {
|
|
134
|
+
index: number
|
|
135
|
+
score: number
|
|
136
|
+
matched: number
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface Postings {
|
|
140
|
+
chunks: RecallChunk[]
|
|
141
|
+
counts: number[]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
interface CorpusIndex {
|
|
145
|
+
members: Set<RecallChunk>
|
|
146
|
+
postings: Map<string, Postings>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const INDEXED_FROM = 2_000
|
|
150
|
+
const KEPT_INDEXES = 6
|
|
151
|
+
const SAMPLED = 32
|
|
152
|
+
|
|
153
|
+
const indexes: CorpusIndex[] = []
|
|
154
|
+
|
|
155
|
+
function addToIndex(index: CorpusIndex, chunk: RecallChunk) {
|
|
156
|
+
index.members.add(chunk)
|
|
157
|
+
const counts = new Map<string, number>()
|
|
158
|
+
for (const term of chunk.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
|
|
159
|
+
for (const [term, count] of counts) {
|
|
160
|
+
const postings = index.postings.get(term)
|
|
161
|
+
if (postings) {
|
|
162
|
+
postings.chunks.push(chunk)
|
|
163
|
+
postings.counts.push(count)
|
|
164
|
+
} else index.postings.set(term, { chunks: [chunk], counts: [count] })
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function overlap(index: CorpusIndex, chunks: RecallChunk[]): number {
|
|
169
|
+
const step = Math.max(1, Math.floor(chunks.length / SAMPLED))
|
|
170
|
+
let found = 0
|
|
171
|
+
let sampled = 0
|
|
172
|
+
for (let i = 0; i < chunks.length; i += step) {
|
|
173
|
+
sampled += 1
|
|
174
|
+
if (index.members.has(chunks[i])) found += 1
|
|
175
|
+
}
|
|
176
|
+
return found / Math.max(1, sampled)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function indexFor(chunks: RecallChunk[]): CorpusIndex {
|
|
180
|
+
let best: CorpusIndex | null = null
|
|
181
|
+
let bestOverlap = 0.5
|
|
182
|
+
for (const index of indexes) {
|
|
183
|
+
const shared = overlap(index, chunks)
|
|
184
|
+
if (shared >= bestOverlap) {
|
|
185
|
+
best = index
|
|
186
|
+
bestOverlap = shared
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
let present = 0
|
|
190
|
+
if (best) for (const chunk of chunks) if (best.members.has(chunk)) present += 1
|
|
191
|
+
if (!best || best.members.size - present > chunks.length / 2) {
|
|
192
|
+
if (best) indexes.splice(indexes.indexOf(best), 1)
|
|
193
|
+
best = { members: new Set(), postings: new Map() }
|
|
194
|
+
present = 0
|
|
195
|
+
} else indexes.splice(indexes.indexOf(best), 1)
|
|
196
|
+
indexes.push(best)
|
|
197
|
+
if (indexes.length > KEPT_INDEXES) indexes.shift()
|
|
198
|
+
if (present < chunks.length) for (const chunk of chunks) if (!best.members.has(chunk)) addToIndex(best, chunk)
|
|
199
|
+
return best
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let warming: { chunks: RecallChunk[]; index: CorpusIndex; next: number } | null = null
|
|
203
|
+
|
|
204
|
+
export function warmIndex(chunks: RecallChunk[], until: number): boolean {
|
|
205
|
+
if (chunks.length < INDEXED_FROM) return true
|
|
206
|
+
if (warming?.chunks !== chunks) {
|
|
207
|
+
const index: CorpusIndex = { members: new Set(), postings: new Map() }
|
|
208
|
+
indexes.push(index)
|
|
209
|
+
if (indexes.length > KEPT_INDEXES) indexes.shift()
|
|
210
|
+
warming = { chunks, index, next: 0 }
|
|
211
|
+
}
|
|
212
|
+
const { index } = warming
|
|
213
|
+
while (warming.next < chunks.length) {
|
|
214
|
+
const chunk = chunks[warming.next]
|
|
215
|
+
warming.next += 1
|
|
216
|
+
if (!index.members.has(chunk)) addToIndex(index, chunk)
|
|
217
|
+
if (warming.next < chunks.length && performance.now() > until) return false
|
|
218
|
+
}
|
|
219
|
+
warming = null
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function indexedAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] | null {
|
|
224
|
+
const position = new Map<RecallChunk, number>()
|
|
225
|
+
let total = 0
|
|
226
|
+
chunks.forEach((chunk, i) => {
|
|
227
|
+
position.set(chunk, i)
|
|
228
|
+
total += chunk.terms.length
|
|
229
|
+
})
|
|
230
|
+
if (position.size !== chunks.length) return null
|
|
231
|
+
const index = indexFor(chunks)
|
|
232
|
+
const average = total / Math.max(1, chunks.length)
|
|
233
|
+
const hitsByTerm = new Map<string, Array<[number, number]>>()
|
|
234
|
+
const hitsOf = (term: string) => {
|
|
235
|
+
const known = hitsByTerm.get(term)
|
|
236
|
+
if (known) return known
|
|
237
|
+
const hits: Array<[number, number]> = []
|
|
238
|
+
const postings = index.postings.get(term)
|
|
239
|
+
if (postings)
|
|
240
|
+
postings.chunks.forEach((chunk, k) => {
|
|
241
|
+
const at = position.get(chunk)
|
|
242
|
+
if (at !== undefined) hits.push([at, postings.counts[k]])
|
|
243
|
+
})
|
|
244
|
+
hitsByTerm.set(term, hits)
|
|
245
|
+
return hits
|
|
246
|
+
}
|
|
247
|
+
return queries.map((query) => {
|
|
248
|
+
const lists = [...new Set(query)].map(hitsOf)
|
|
249
|
+
const needed = Math.max(1, Math.min(minTerms, lists.filter((hits) => hits.length > 0).length))
|
|
250
|
+
const found = new Map<number, Array<[number, number]>>()
|
|
251
|
+
lists.forEach((hits, k) => {
|
|
252
|
+
for (const [at, f] of hits) {
|
|
253
|
+
const mine = found.get(at)
|
|
254
|
+
if (mine) mine.push([k, f])
|
|
255
|
+
else found.set(at, [[k, f]])
|
|
256
|
+
}
|
|
257
|
+
})
|
|
258
|
+
const scored: RankedChunk[] = []
|
|
259
|
+
for (const at of [...found.keys()].sort((a, b) => a - b)) {
|
|
260
|
+
const mine = found.get(at) as Array<[number, number]>
|
|
261
|
+
if (mine.length < needed) continue
|
|
262
|
+
const length = chunks[at].terms.length
|
|
91
263
|
let score = 0
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const f = tf.get(term) ?? 0
|
|
95
|
-
if (!f) continue
|
|
96
|
-
matched += 1
|
|
97
|
-
const n = df.get(term) ?? 0
|
|
264
|
+
for (const [k, f] of mine) {
|
|
265
|
+
const n = lists[k].length
|
|
98
266
|
const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
|
|
99
|
-
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B *
|
|
267
|
+
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
|
|
100
268
|
}
|
|
101
|
-
|
|
269
|
+
if (score > 0) scored.push({ index: at, score, matched: mine.length })
|
|
270
|
+
}
|
|
271
|
+
return scored.sort((a, b) => b.score - a.score)
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function rankAll(queries: string[][], chunks: RecallChunk[], minTerms = 2): RankedChunk[][] {
|
|
276
|
+
return (chunks.length >= INDEXED_FROM && indexedAll(queries, chunks, minTerms)) || scanAll(queries, chunks, minTerms)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function scanAll(queries: string[][], chunks: RecallChunk[], minTerms: number): RankedChunk[][] {
|
|
280
|
+
const ids = new Map<string, number>()
|
|
281
|
+
for (const query of queries) for (const term of query) if (!ids.has(term)) ids.set(term, ids.size)
|
|
282
|
+
const df = new Int32Array(ids.size)
|
|
283
|
+
const counts = new Int32Array(ids.size)
|
|
284
|
+
const touched: number[] = []
|
|
285
|
+
const hits: Array<{ index: number; found: Array<[number, number]> }> = []
|
|
286
|
+
let total = 0
|
|
287
|
+
chunks.forEach((chunk, index) => {
|
|
288
|
+
total += chunk.terms.length
|
|
289
|
+
touched.length = 0
|
|
290
|
+
for (const term of chunk.terms) {
|
|
291
|
+
const id = ids.get(term)
|
|
292
|
+
if (id === undefined) continue
|
|
293
|
+
if (counts[id] === 0) touched.push(id)
|
|
294
|
+
counts[id] += 1
|
|
295
|
+
}
|
|
296
|
+
if (touched.length === 0) return
|
|
297
|
+
const found = touched.map((id): [number, number] => {
|
|
298
|
+
const f = counts[id]
|
|
299
|
+
counts[id] = 0
|
|
300
|
+
df[id] += 1
|
|
301
|
+
return [id, f]
|
|
102
302
|
})
|
|
103
|
-
.
|
|
104
|
-
|
|
303
|
+
hits.push({ index, found })
|
|
304
|
+
})
|
|
305
|
+
const average = total / Math.max(1, chunks.length)
|
|
306
|
+
return queries.map((query) => {
|
|
307
|
+
const asked = [...new Set(query)].map((term) => ids.get(term) ?? -1)
|
|
308
|
+
const position = new Int32Array(ids.size).fill(-1)
|
|
309
|
+
asked.forEach((id, i) => {
|
|
310
|
+
position[id] = i
|
|
311
|
+
})
|
|
312
|
+
const needed = Math.max(1, Math.min(minTerms, asked.filter((id) => df[id] > 0).length))
|
|
313
|
+
const scored: RankedChunk[] = []
|
|
314
|
+
for (const { index, found } of hits) {
|
|
315
|
+
const mine = found.filter(([id]) => position[id] >= 0)
|
|
316
|
+
if (mine.length < needed) continue
|
|
317
|
+
if (mine.length > 1) mine.sort((a, b) => position[a[0]] - position[b[0]])
|
|
318
|
+
const length = chunks[index].terms.length
|
|
319
|
+
let score = 0
|
|
320
|
+
for (const [id, f] of mine) {
|
|
321
|
+
const n = df[id]
|
|
322
|
+
const idf = Math.log(1 + (chunks.length - n + 0.5) / (n + 0.5))
|
|
323
|
+
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * length) / average))
|
|
324
|
+
}
|
|
325
|
+
if (score > 0) scored.push({ index, score, matched: mine.length })
|
|
326
|
+
}
|
|
327
|
+
return scored.sort((a, b) => b.score - a.score)
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function rank(query: string[], chunks: RecallChunk[], minTerms = 2): RankedChunk[] {
|
|
332
|
+
return rankAll([query], chunks, minTerms)[0]
|
|
105
333
|
}
|
|
106
334
|
|
|
107
335
|
function excerpt(chunk: RecallChunk, query: Set<string>, salient: boolean): string[] {
|
|
@@ -136,27 +364,23 @@ function merged(hits: Array<{ query: string[]; indices: number[] }>): RecallPick
|
|
|
136
364
|
return [...picked.entries()].map(([index, query]) => ({ index, query }))
|
|
137
365
|
}
|
|
138
366
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
367
|
+
function rankedLines(prompt: string, chunks: RecallChunk[], options: RecallOptions, width: number) {
|
|
368
|
+
const lines = queriesOf(prompt)
|
|
369
|
+
const queries = lines.map(terms)
|
|
370
|
+
const results = rankAll(queries, chunks, options.minTerms)
|
|
371
|
+
return lines.map((line, i) => ({ line, query: queries[i], indices: results[i].slice(0, width).map((hit) => hit.index) }))
|
|
372
|
+
}
|
|
143
373
|
|
|
144
374
|
function pickRecall(prompt: string, chunks: RecallChunk[], options: RecallOptions): RecallPick[] {
|
|
145
|
-
return merged(
|
|
146
|
-
queriesOf(prompt).map((line) => {
|
|
147
|
-
const query = terms(line)
|
|
148
|
-
return { query, indices: ranked(query, chunks, options, options.perQuery ?? 12) }
|
|
149
|
-
}),
|
|
150
|
-
)
|
|
375
|
+
return merged(rankedLines(prompt, chunks, options, options.perQuery ?? 12))
|
|
151
376
|
}
|
|
152
377
|
|
|
153
378
|
export const identityReranker: Reranker = async (_query, _chunks, candidates) => candidates
|
|
154
379
|
|
|
155
380
|
export async function pickRecallWith(prompt: string, chunks: RecallChunk[], rerank: Reranker, options: RecallOptions = {}): Promise<RecallPick[]> {
|
|
156
381
|
const hits: Array<{ query: string[]; indices: number[] }> = []
|
|
157
|
-
for (const line of
|
|
158
|
-
const
|
|
159
|
-
const reordered = await rerank(line, chunks, ranked(query, chunks, options, options.candidates ?? 30))
|
|
382
|
+
for (const { line, query, indices } of rankedLines(prompt, chunks, options, options.candidates ?? 30)) {
|
|
383
|
+
const reordered = await rerank(line, chunks, indices)
|
|
160
384
|
hits.push({ query, indices: reordered.slice(0, options.perQuery ?? 12) })
|
|
161
385
|
}
|
|
162
386
|
return merged(hits)
|