@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.
Files changed (63) hide show
  1. package/README.md +139 -2
  2. package/dist/core/index.js +2235 -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 +14 -0
  8. package/dist/types/context/prompts.d.ts +24 -0
  9. package/dist/types/context/recall/index.d.ts +44 -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 +33 -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 +8 -0
  20. package/dist/types/core/rewrite.d.ts +63 -0
  21. package/dist/types/core/state.d.ts +22 -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 +7 -0
  25. package/dist/types/core/window.d.ts +5 -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 +55 -10
  37. package/src/context/fold/turns.ts +22 -2
  38. package/src/context/prompts.ts +4 -3
  39. package/src/context/recall/index.ts +2 -3
  40. package/src/context/recall/rerank.ts +0 -8
  41. package/src/{proxy → core}/compaction.ts +56 -24
  42. package/src/core/config.ts +31 -0
  43. package/src/core/fold.ts +69 -0
  44. package/src/core/forward.ts +192 -0
  45. package/src/{proxy → core}/handoff.ts +23 -23
  46. package/src/core/index.ts +14 -0
  47. package/src/{proxy → core}/recall.ts +18 -20
  48. package/src/core/rewrite.ts +189 -0
  49. package/src/core/state.ts +40 -0
  50. package/src/core/subagents.ts +28 -0
  51. package/src/core/transcript.ts +44 -0
  52. package/src/{proxy → core}/usage.ts +34 -1
  53. package/src/core/window.ts +39 -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 → core}/estimate.ts +0 -0
  63. /package/src/{proxy → core}/fingerprint.ts +0 -0
@@ -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 { 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 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()]
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 over = (turn: Message[], i: number) => i > 0 && i < live && JSON.stringify(turn).length > capChars
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 content = turn.filter((m) => isHarness(m) && hasContent(m)).flatMap((m) => blocks(m).map(withoutCacheControl))
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
 
@@ -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}`,
@@ -123,9 +123,8 @@ export function recallChunks(messages: Message[], keptIds: Map<string, string>,
123
123
  )
124
124
  .filter(Boolean)
125
125
  .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
- )
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 { Config } from "../config"
2
- import type { Body, Message } from "../context/content"
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: 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 {
@@ -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: Config, rollingKeep: number | undefined) {
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
- shrinkToTarget(
155
- () => estimate > target,
156
- () => shrinkOnce(state, cutIndex, configuredKeep, liveResults),
157
- actions,
158
- rebuild,
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
- 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,
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
+ }
@@ -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
+ }