@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.
Files changed (64) hide show
  1. package/README.md +150 -6
  2. package/dist/core/index.js +2354 -0
  3. package/dist/types/context/content.d.ts +31 -0
  4. package/dist/types/context/fold/evidence.d.ts +20 -0
  5. package/dist/types/context/fold/index.d.ts +11 -0
  6. package/dist/types/context/fold/strategies.d.ts +20 -0
  7. package/dist/types/context/fold/turns.d.ts +16 -0
  8. package/dist/types/context/prompts.d.ts +24 -0
  9. package/dist/types/context/recall/index.d.ts +47 -0
  10. package/dist/types/context/recall/rerank.d.ts +3 -0
  11. package/dist/types/core/compaction.d.ts +32 -0
  12. package/dist/types/core/config.d.ts +34 -0
  13. package/dist/types/core/estimate.d.ts +9 -0
  14. package/dist/types/core/fingerprint.d.ts +10 -0
  15. package/dist/types/core/fold.d.ts +18 -0
  16. package/dist/types/core/forward.d.ts +24 -0
  17. package/dist/types/core/handoff.d.ts +10 -0
  18. package/dist/types/core/index.d.ts +14 -0
  19. package/dist/types/core/recall.d.ts +9 -0
  20. package/dist/types/core/rewrite.d.ts +63 -0
  21. package/dist/types/core/state.d.ts +25 -0
  22. package/dist/types/core/subagents.d.ts +10 -0
  23. package/dist/types/core/transcript.d.ts +9 -0
  24. package/dist/types/core/usage.d.ts +8 -0
  25. package/dist/types/core/window.d.ts +8 -0
  26. package/dist/types/transcript/format.d.ts +16 -0
  27. package/dist/types/transcript/parse.d.ts +45 -0
  28. package/package.json +15 -2
  29. package/src/cli/commands/mode.ts +2 -1
  30. package/src/cli/commands/status.ts +1 -1
  31. package/src/cli/proxy/stats.ts +1 -1
  32. package/src/cli/replay/index.ts +3 -3
  33. package/src/cli/sessions/index.ts +2 -2
  34. package/src/config/index.ts +4 -27
  35. package/src/context/content.ts +64 -1
  36. package/src/context/fold/strategies.ts +63 -10
  37. package/src/context/fold/turns.ts +34 -2
  38. package/src/context/prompts.ts +4 -3
  39. package/src/context/recall/index.ts +8 -4
  40. package/src/context/recall/rerank.ts +0 -8
  41. package/src/{proxy → core}/compaction.ts +85 -28
  42. package/src/core/config.ts +31 -0
  43. package/src/core/fold.ts +71 -0
  44. package/src/core/forward.ts +194 -0
  45. package/src/{proxy → core}/handoff.ts +23 -23
  46. package/src/core/index.ts +14 -0
  47. package/src/core/recall.ts +80 -0
  48. package/src/core/rewrite.ts +201 -0
  49. package/src/core/state.ts +50 -0
  50. package/src/core/subagents.ts +28 -0
  51. package/src/core/transcript.ts +44 -0
  52. package/src/{proxy → core}/usage.ts +43 -1
  53. package/src/core/window.ts +69 -0
  54. package/src/proxy/rewrite.ts +22 -139
  55. package/src/proxy/server.ts +21 -159
  56. package/src/proxy/sessions.ts +1 -1
  57. package/src/proxy/sources.ts +35 -0
  58. package/src/proxy/state.ts +2 -20
  59. package/src/proxy/window.ts +3 -33
  60. package/src/transcript/format.ts +1 -2
  61. package/src/transcript/subagents.ts +3 -3
  62. package/src/proxy/recall.ts +0 -58
  63. /package/src/{proxy → core}/estimate.ts +0 -0
  64. /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 "../../proxy/estimate"
6
- import { continueToken } from "../../proxy/handoff"
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"
@@ -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 EvalConfig {
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
- mode: "default",
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",
@@ -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 new Bun.CryptoHasher("sha256").update(json).digest("hex").slice(0, chars)
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 notice = compactionNotice({ omitted: keptFrom - 1 + (options.omittedBefore ?? 0), kept: cutIndex - keptFrom, guide: options.guide })
25
- const removed = [...turns[0].slice(1), ...turns.slice(1, keptFrom).flat()]
26
- const carried = removed
27
- .filter((m) => isHarness(m) && hasContent(m))
28
- .flatMap((m) => blocks(m).filter((b) => b.type === "text"))
29
- .map((b) => ({ type: "text", text: `<system-reminder>\n${b.text}\n</system-reminder>` }))
30
- return [...preamble, turns[0][0], { role: "user", content: [{ type: "text", text: notice }, ...carried] }, ...turns.slice(keptFrom, cutIndex).flatMap((turn) => reduce(turn, options)), ...turns.slice(cutIndex).flat()]
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 over = (turn: Message[], i: number) => i > 0 && i < live && JSON.stringify(turn).length > capChars
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 content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
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
 
@@ -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: TranscriptRef): string {
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
- const built = chunksOf(said ? `${size}|said|${turnIndex}|${said}` : null, said, "agent", turnIndex + 1, () =>
127
- windows(said.split("\n"), size).map((window) => ({ turn: turnIndex + 1, call: "agent", lines: window, terms: terms(window.join("\n")) })),
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 budget = queriesOf(prompt).length > 1 ? (options.maxCharsMulti ?? 30_000) : (options.maxChars ?? 18_000)
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 { Config } from "../config"
2
- import type { Body, Message } from "../context/content"
3
- import { type CompactOptions, capTurns, clearToolResults, compactMessages, countToolResults } from "../context/fold/strategies"
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 { resetCut, type ThreadState } from "./state"
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: 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}:${Bun.hash(JSON.stringify(turn))}`
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: Record<string, unknown>
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: Config, rollingKeep: number | undefined) {
60
- let cutIndex = state.cut ? turns.findIndex((turn) => turnFingerprint(turn) === state.cut) : -1
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 = turnFingerprint(turns[cutIndex])
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 resolved = resolveCut(turns, state, config, rolling?.keep)
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 = turnFingerprint(turns[index])
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
- shrinkToTarget(
155
- () => estimate > target,
156
- () => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
157
- actions,
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
- action: cutIndex > 0 ? "compact" : "full",
168
- compacted: actions.includes("cut") || actions.includes("roll"),
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
+ }