@chroxify/eunoe 1.0.0 → 1.1.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 (85) hide show
  1. package/README.md +55 -9
  2. package/package.json +18 -2
  3. package/src/cli/{types.ts → command.ts} +0 -4
  4. package/src/cli/commands/index.ts +11 -1
  5. package/src/cli/commands/load.ts +44 -0
  6. package/src/cli/commands/mode.ts +41 -33
  7. package/src/cli/commands/peek.ts +22 -0
  8. package/src/cli/commands/replay.ts +194 -0
  9. package/src/cli/commands/serve.ts +22 -7
  10. package/src/cli/commands/sessions.ts +70 -0
  11. package/src/cli/commands/settings.ts +79 -60
  12. package/src/cli/commands/start.ts +27 -18
  13. package/src/cli/commands/status.ts +98 -55
  14. package/src/cli/commands/stop.ts +8 -6
  15. package/src/cli/commands/uninstall.ts +10 -12
  16. package/src/cli/commands/watch.ts +16 -0
  17. package/src/cli/logo/art.ts +99 -0
  18. package/src/cli/logo/index.ts +12 -18
  19. package/src/cli/main.ts +3 -3
  20. package/src/cli/proxy/claude-settings.ts +59 -0
  21. package/src/cli/proxy/inspect.ts +67 -0
  22. package/src/cli/proxy/launchd.ts +53 -0
  23. package/src/cli/proxy/service.ts +53 -0
  24. package/src/cli/{stats.ts → proxy/stats.ts} +23 -15
  25. package/src/cli/proxy/watch.ts +60 -0
  26. package/src/cli/replay/index.ts +141 -0
  27. package/src/cli/replay/worker.ts +15 -0
  28. package/src/cli/sessions/browse.ts +88 -0
  29. package/src/cli/sessions/index.ts +76 -0
  30. package/src/cli/sessions/source.ts +74 -0
  31. package/src/cli/{help.ts → terminal/help.ts} +24 -8
  32. package/src/cli/terminal/prompt.ts +114 -0
  33. package/src/cli/{ui.ts → terminal/ui.ts} +55 -24
  34. package/src/config/index.ts +99 -34
  35. package/src/config/paths.ts +17 -0
  36. package/src/context/content.ts +40 -14
  37. package/src/context/fold/evidence.ts +145 -0
  38. package/src/context/fold/index.ts +65 -0
  39. package/src/context/fold/strategies.ts +94 -0
  40. package/src/context/fold/turns.ts +76 -0
  41. package/src/context/prompts.ts +141 -22
  42. package/src/context/recall/index.ts +214 -0
  43. package/src/context/recall/rerank.ts +71 -0
  44. package/src/proxy/canonical.ts +17 -9
  45. package/src/proxy/compaction.ts +106 -53
  46. package/src/proxy/estimate.ts +14 -9
  47. package/src/proxy/fingerprint.ts +23 -9
  48. package/src/proxy/handoff.ts +71 -0
  49. package/src/proxy/log.ts +2 -2
  50. package/src/proxy/recall.ts +58 -0
  51. package/src/proxy/rewrite.ts +141 -119
  52. package/src/proxy/server.ts +108 -51
  53. package/src/proxy/sessions.ts +32 -14
  54. package/src/proxy/state.ts +17 -3
  55. package/src/proxy/usage.ts +50 -34
  56. package/src/proxy/window.ts +32 -34
  57. package/src/transcript/format.ts +88 -25
  58. package/src/transcript/locate.ts +38 -17
  59. package/src/transcript/messages.ts +3 -14
  60. package/src/transcript/parse.ts +137 -56
  61. package/src/transcript/render.ts +44 -17
  62. package/src/transcript/search.ts +20 -56
  63. package/src/transcript/subagents.ts +150 -0
  64. package/src/cli/claude-settings.ts +0 -42
  65. package/src/cli/constants.ts +0 -52
  66. package/src/cli/inspect.ts +0 -41
  67. package/src/cli/launchd.ts +0 -39
  68. package/src/cli/logo/pixels.ts +0 -34
  69. package/src/cli/prompt.ts +0 -91
  70. package/src/cli/service.ts +0 -33
  71. package/src/cli/sessions.ts +0 -25
  72. package/src/config/constants.ts +0 -39
  73. package/src/config/types.ts +0 -50
  74. package/src/context/constants.ts +0 -42
  75. package/src/context/evidence.ts +0 -92
  76. package/src/context/recall.ts +0 -155
  77. package/src/context/rerank.ts +0 -44
  78. package/src/context/strategies.ts +0 -89
  79. package/src/context/turns.ts +0 -87
  80. package/src/context/types.ts +0 -66
  81. package/src/proxy/constants.ts +0 -29
  82. package/src/proxy/types.ts +0 -82
  83. package/src/transcript/constants.ts +0 -15
  84. package/src/transcript/types.ts +0 -43
  85. /package/src/cli/{args.ts → terminal/args.ts} +0 -0
@@ -1,12 +1,14 @@
1
1
  import { homedir } from "node:os"
2
- import { TYPO_DISTANCE } from "./constants"
3
2
 
4
3
  const colorFor = (stream: NodeJS.WriteStream) => Boolean(stream.isTTY) && !process.env.NO_COLOR && process.env.TERM !== "dumb"
5
4
  export const useColor = colorFor(process.stdout)
6
5
  export const trueColor = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? "")
7
6
  const useColorErr = colorFor(process.stderr)
8
7
 
9
- const paint = (open: number, close: number, enabled = useColor) => (text: string) => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text)
8
+ const paint =
9
+ (open: number, close: number, enabled = useColor) =>
10
+ (text: string) =>
11
+ enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text
10
12
 
11
13
  export const c = {
12
14
  bold: paint(1, 22),
@@ -27,15 +29,15 @@ export function line(text = "") {
27
29
  }
28
30
 
29
31
  export function ok(text: string) {
30
- console.log(`${c.green("✓")} ${text}`)
32
+ line(`${c.green("✓")} ${text}`)
31
33
  }
32
34
 
33
35
  export function note(text: string) {
34
- console.log(`${c.dim("·")} ${text}`)
36
+ line(`${c.dim("·")} ${text}`)
35
37
  }
36
38
 
37
39
  export function warn(text: string) {
38
- console.log(`${c.yellow("!")} ${text}`)
40
+ line(`${c.yellow("!")} ${text}`)
39
41
  }
40
42
 
41
43
  export function fail(text: string, hint?: string): never {
@@ -50,18 +52,38 @@ export function listFiles(files: string[]) {
50
52
 
51
53
  export function rows(entries: Array<[string, string]>, indent = 2) {
52
54
  const width = Math.max(...entries.map(([label]) => label.length))
53
- for (const [label, value] of entries) console.log(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
55
+ for (const [label, value] of entries) line(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
54
56
  }
55
57
 
56
- const visible = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "")
58
+ const ESC = "\x1b"
59
+ const ANSI_STYLE = new RegExp(`${ESC}\\[[0-9;]*m`, "g")
60
+
61
+ const visible = (text: string) => text.replace(ANSI_STYLE, "")
57
62
 
58
63
  export function table(entries: string[][], indent = 4, gap = 2) {
59
64
  const columns = Math.max(...entries.map((row) => row.length))
60
65
  const widths = Array.from({ length: columns }, (_, column) => Math.max(...entries.map((row) => visible(row[column] ?? "").length)))
61
66
  for (const row of entries) {
62
67
  const cells = widths.map((width, column) => (row[column] ?? "") + " ".repeat(Math.max(0, width - visible(row[column] ?? "").length)))
63
- console.log(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
68
+ line(`${" ".repeat(indent)}${cells.join(" ".repeat(gap)).trimEnd()}`)
69
+ }
70
+ }
71
+
72
+ export function wrap(text: string, width: number): string[] {
73
+ const out: string[] = []
74
+ let current = ""
75
+ for (const word of text.split(" ")) {
76
+ if (current && current.length + 1 + word.length > width) {
77
+ out.push(current)
78
+ current = word
79
+ } else current = current ? `${current} ${word}` : word
64
80
  }
81
+ if (current) out.push(current)
82
+ return out
83
+ }
84
+
85
+ export function plural(count: number, word: string) {
86
+ return `${count} ${word}${count === 1 ? "" : "s"}`
65
87
  }
66
88
 
67
89
  export function percent(share: number) {
@@ -74,8 +96,16 @@ export function tilde(file: string) {
74
96
  }
75
97
 
76
98
  export function tokens(count: number) {
77
- if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`
78
- if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}k`
99
+ for (const [scale, suffix] of [
100
+ [1e12, "T"],
101
+ [1e9, "B"],
102
+ [1e6, "M"],
103
+ [1e3, "k"],
104
+ ] as const) {
105
+ const scaled = count / scale
106
+ const shown = scaled.toFixed(scaled >= 10 ? 0 : 1)
107
+ if (Number(shown) >= 1) return `${shown}${suffix}`
108
+ }
79
109
  return String(count)
80
110
  }
81
111
 
@@ -87,20 +117,21 @@ export function ago(iso: string) {
87
117
  return `${Math.round(seconds / 86_400)}d ago`
88
118
  }
89
119
 
90
- export function closest(input: string, options: string[]) {
91
- const distance = (a: string, b: string) => {
92
- const row = Array.from({ length: b.length + 1 }, (_, i) => i)
93
- for (let i = 1; i <= a.length; i += 1) {
94
- let previous = row[0]
95
- row[0] = i
96
- for (let j = 1; j <= b.length; j += 1) {
97
- const current = row[j]
98
- row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
99
- previous = current
100
- }
120
+ function editDistance(a: string, b: string) {
121
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i)
122
+ for (let i = 1; i <= a.length; i += 1) {
123
+ let previous = row[0]
124
+ row[0] = i
125
+ for (let j = 1; j <= b.length; j += 1) {
126
+ const current = row[j]
127
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1))
128
+ previous = current
101
129
  }
102
- return row[b.length]
103
130
  }
104
- const [best] = options.map((option) => [option, distance(input, option)] as const).sort((x, y) => x[1] - y[1])
105
- return best && best[1] <= TYPO_DISTANCE ? best[0] : null
131
+ return row[b.length]
132
+ }
133
+
134
+ export function closest(input: string, options: string[]) {
135
+ const [best] = options.map((option) => [option, editDistance(input, option)] as const).sort((x, y) => x[1] - y[1])
136
+ return best && best[1] <= 2 ? best[0] : null
106
137
  }
@@ -1,47 +1,114 @@
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 { DEFAULTS, EUNOE_DIR, MODES, paths, SCOPES, SEARCH_MODES, TUNABLES } from "./constants"
5
- import type { Config, Mode, SessionSettings, Tunable, Tunables } from "./types"
4
+ import { EUNOE_DIR, paths } from "./paths"
6
5
 
7
- const VALID: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
8
- compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
9
- keepTurns: (value): value is number | "all" => value === "all" || (Number.isInteger(value) && (value as number) >= 0),
10
- keep: (value): value is number => Number.isInteger(value) && (value as number) >= 1,
11
- budget: (value): value is number => Number.isInteger(value) && (value as number) >= 1_000,
12
- search: (value): value is Tunables["search"] => SEARCH_MODES.includes(value as Tunables["search"]),
13
- rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
6
+ export type Mode = "off" | "compact" | "default"
7
+ export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
8
+ type Scope = "all" | "sessions"
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"
27
+ search: SearchMode
28
+ rerank: string
29
+ }
30
+
31
+ export type Tunable = keyof Tunables
32
+
33
+ interface SessionSettings extends Partial<Tunables> {
34
+ mode?: Mode
35
+ }
36
+
37
+ export interface Config extends Tunables {
38
+ mode: Mode
39
+ scope: Scope
40
+ sessions: Record<string, SessionSettings>
41
+ port: number
42
+ upstream: string
43
+ claudeConfigDirs: string[]
44
+ window?: number
45
+ eval?: EvalConfig
14
46
  }
15
47
 
48
+ export const DEFAULTS: Config = {
49
+ mode: "default",
50
+ scope: "all",
51
+ sessions: {},
52
+ port: 38663,
53
+ compactAt: 0.9,
54
+ keepTurns: "all",
55
+ keep: 3,
56
+ budget: 100_000,
57
+ turnCap: 100_000,
58
+ search: "markdown",
59
+ rerank: "jev-preview",
60
+ upstream: "https://api.anthropic.com",
61
+ claudeConfigDirs: [],
62
+ }
63
+
64
+ export const MODES: Mode[] = ["default", "compact", "off"]
65
+ export const TUNABLES: Tunable[] = ["keep", "budget", "turnCap", "compactAt", "keepTurns", "search", "rerank"]
66
+
67
+ const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
16
68
  const TEXT_TUNABLES: Tunable[] = ["search", "rerank"]
17
69
 
70
+ const isWhole = (value: unknown, min: number): value is number => Number.isInteger(value) && (value as number) >= min
71
+
72
+ const validators: { [K in Tunable]: (value: unknown) => value is Tunables[K] } = {
73
+ compactAt: (value): value is number => typeof value === "number" && value > 0 && value <= 1,
74
+ keepTurns: (value): value is number | "all" => value === "all" || isWhole(value, 0),
75
+ keep: (value): value is number => isWhole(value, 1),
76
+ budget: (value): value is number => isWhole(value, 1_000),
77
+ turnCap: (value): value is number | "off" => value === "off" || isWhole(value, 1_000),
78
+ search: (value): value is SearchMode => SEARCH_MODES.includes(value as SearchMode),
79
+ rerank: (value): value is string => typeof value === "string" && /^[\w.-]+$/.test(value),
80
+ }
81
+
18
82
  const isMode = (value: unknown): value is Mode => MODES.includes(value as Mode)
19
83
 
84
+ const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value) && typeof value === "object" && !Array.isArray(value)
85
+
20
86
  export function parseTunable<K extends Tunable>(key: K, raw: string): Tunables[K] | null {
21
- const value = TEXT_TUNABLES.includes(key) || raw === "all" ? raw : Number(raw.replace(/[_,]/g, ""))
22
- return VALID[key](value) ? value as Tunables[K] : null
87
+ const value = TEXT_TUNABLES.includes(key) || raw === "all" || raw === "off" ? raw : Number(raw.replace(/[_,]/g, ""))
88
+ return validators[key](value) ? (value as Tunables[K]) : null
23
89
  }
24
90
 
25
91
  function normalizeSession(value: unknown): SessionSettings | null {
26
92
  if (value === null) return {}
27
93
  if (isMode(value)) return { mode: value }
28
- if (!value || typeof value !== "object" || Array.isArray(value)) return null
29
- const raw = value as Record<string, unknown>
30
- const settings: SessionSettings = isMode(raw.mode) ? { mode: raw.mode } : {}
31
- for (const key of TUNABLES) if (VALID[key](raw[key])) Object.assign(settings, { [key]: raw[key] })
94
+ if (!isRecord(value)) return null
95
+ const settings: SessionSettings = isMode(value.mode) ? { mode: value.mode } : {}
96
+ for (const key of TUNABLES) if (validators[key](value[key])) Object.assign(settings, { [key]: value[key] })
32
97
  return settings
33
98
  }
34
99
 
35
- export function normalizeConfig(raw: Record<string, any>): Config {
36
- const config: Config = { ...DEFAULTS, ...raw }
100
+ export function normalizeConfig(raw: Record<string, unknown>): Config {
101
+ const config = { ...DEFAULTS, ...raw } as Config
37
102
  if (!isMode(config.mode)) config.mode = DEFAULTS.mode
38
- if (!SCOPES.includes(config.scope)) config.scope = DEFAULTS.scope
39
- for (const key of TUNABLES) if (!VALID[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
40
- const sessions = config.sessions && typeof config.sessions === "object" && !Array.isArray(config.sessions) ? config.sessions : {}
41
- config.sessions = Object.fromEntries(Object.entries(sessions).flatMap(([id, value]) => {
42
- const settings = normalizeSession(value)
43
- return settings ? [[id, settings]] : []
44
- }))
103
+ if (config.scope !== "all" && config.scope !== "sessions") config.scope = DEFAULTS.scope
104
+ for (const key of TUNABLES) if (!validators[key](config[key])) Object.assign(config, { [key]: DEFAULTS[key] })
105
+ const sessions = isRecord(config.sessions) ? config.sessions : {}
106
+ config.sessions = Object.fromEntries(
107
+ Object.entries(sessions).flatMap(([id, value]) => {
108
+ const settings = normalizeSession(value)
109
+ return settings ? [[id, settings]] : []
110
+ }),
111
+ )
45
112
  if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
46
113
  if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
47
114
  return config
@@ -49,7 +116,7 @@ export function normalizeConfig(raw: Record<string, any>): Config {
49
116
 
50
117
  let cached: { mtimeMs: number; config: Config } | null = null
51
118
 
52
- export function loadConfig(): Config {
119
+ export function readConfig(): Config {
53
120
  let mtimeMs: number
54
121
  try {
55
122
  mtimeMs = statSync(paths.config).mtimeMs
@@ -60,9 +127,7 @@ export function loadConfig(): Config {
60
127
  let config = DEFAULTS
61
128
  try {
62
129
  config = normalizeConfig(JSON.parse(readFileSync(paths.config, "utf8")))
63
- } catch (error) {
64
- console.error(`[eunoe] ${paths.config} is not valid JSON; using defaults`, error)
65
- }
130
+ } catch {}
66
131
  cached = { mtimeMs, config }
67
132
  return config
68
133
  }
@@ -70,7 +135,7 @@ export function loadConfig(): Config {
70
135
  export function saveConfig(patch: Partial<Config>) {
71
136
  mkdirSync(EUNOE_DIR, { recursive: true })
72
137
  const current = existsSync(paths.config) ? JSON.parse(readFileSync(paths.config, "utf8")) : {}
73
- writeFileSync(paths.config, JSON.stringify({ ...current, ...patch }, null, 2) + "\n")
138
+ writeFileSync(paths.config, `${JSON.stringify({ ...current, ...patch }, null, 2)}\n`)
74
139
  }
75
140
 
76
141
  export function claudeConfigDirs(config: Config): string[] {
@@ -85,14 +150,14 @@ export function modeFor(config: Config, sessionId: string | null): Mode {
85
150
  return config.scope === "all" ? config.mode : "off"
86
151
  }
87
152
 
153
+ export function compactionOwner(config: Config): Mode {
154
+ return modeFor(config, null)
155
+ }
156
+
88
157
  export function overrides(settings: SessionSettings | undefined): Partial<Tunables> {
89
- return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings![key]]))
158
+ return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings?.[key]]))
90
159
  }
91
160
 
92
161
  export function settingsFor(config: Config, sessionId: string | null): Config {
93
162
  return { ...config, ...overrides(sessionId ? config.sessions[sessionId] : undefined), mode: modeFor(config, sessionId) }
94
163
  }
95
-
96
- export function compactionOwner(config: Config): Mode {
97
- return config.scope === "all" ? config.mode : "off"
98
- }
@@ -0,0 +1,17 @@
1
+ import { homedir } from "node:os"
2
+ import path from "node:path"
3
+
4
+ export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
5
+
6
+ export const paths = {
7
+ config: path.join(EUNOE_DIR, "config.json"),
8
+ requests: path.join(EUNOE_DIR, "requests.jsonl"),
9
+ threads: path.join(EUNOE_DIR, "threads"),
10
+ transcripts: path.join(EUNOE_DIR, "transcripts"),
11
+ models: path.join(EUNOE_DIR, "models.json"),
12
+ sessions: path.join(EUNOE_DIR, "sessions.json"),
13
+ proxyLog: path.join(EUNOE_DIR, "proxy.log"),
14
+ typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
15
+ loaded: path.join(EUNOE_DIR, "loaded"),
16
+ watch: path.join(EUNOE_DIR, "watch.json"),
17
+ }
@@ -1,14 +1,40 @@
1
- import { SUMMARY_ARGUMENT_CHARS, SUMMARY_COMMAND_CHARS } from "./constants"
2
- import type { Block, Message } from "./types"
1
+ export type Block = { type: string; [key: string]: any }
2
+
3
+ export type Message = { role: string; content: string | Block[] }
4
+
5
+ export type Body = {
6
+ model?: string
7
+ system?: string | Block[]
8
+ messages: Message[]
9
+ tools?: unknown[]
10
+ metadata?: { user_id?: string }
11
+ [key: string]: any
12
+ }
3
13
 
4
14
  export function contentBlocks(content: unknown): Block[] {
5
- return typeof content === "string" ? [{ type: "text", text: content }] : Array.isArray(content) ? content : []
15
+ if (typeof content === "string") return [{ type: "text", text: content }]
16
+ return Array.isArray(content) ? content : []
6
17
  }
7
18
 
8
19
  export function blocks(message: Message): Block[] {
9
20
  return contentBlocks(message.content)
10
21
  }
11
22
 
23
+ function joinedText(list: Block[]): string {
24
+ return list
25
+ .filter((b) => b.type === "text")
26
+ .map((b) => String(b.text ?? ""))
27
+ .join("\n")
28
+ }
29
+
30
+ export function textOf(message: Message): string {
31
+ return joinedText(blocks(message))
32
+ }
33
+
34
+ export function resultText(block: Block): string {
35
+ return typeof block.content === "string" ? block.content : joinedText(contentBlocks(block.content))
36
+ }
37
+
12
38
  export function withoutCacheControl(block: Block): Block {
13
39
  if (!("cache_control" in block)) return block
14
40
  const { cache_control: _, ...rest } = block
@@ -19,28 +45,28 @@ export function clean(message: Message): Message {
19
45
  return { role: message.role, content: blocks(message).map(withoutCacheControl) }
20
46
  }
21
47
 
48
+ export function shortHash(json: string, chars: number): string {
49
+ return new Bun.CryptoHasher("sha256").update(json).digest("hex").slice(0, chars)
50
+ }
51
+
22
52
  export function truncate(text: string, max: number): string {
23
53
  const cut = text.slice(0, max)
24
54
  return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
25
55
  }
26
56
 
27
- export function wellFormed(_key: string, value: unknown): unknown {
28
- return typeof value === "string" ? value.toWellFormed() : value
29
- }
30
-
31
57
  export function clip(value: unknown, max: number): string {
32
58
  const text = String(value).replace(/\s+/g, " ").trim()
33
59
  return text.length > max ? `${truncate(text, max)}…` : text
34
60
  }
35
61
 
62
+ export function wellFormed(_key: string, value: unknown): unknown {
63
+ return typeof value === "string" ? value.toWellFormed() : value
64
+ }
65
+
36
66
  export function describeToolCall(block: Block): string {
37
67
  const input = block.input ?? {}
38
- if (typeof input.command === "string") return `${block.name} \`${clip(input.command, SUMMARY_COMMAND_CHARS)}\``
68
+ if (typeof input.command === "string") return `${block.name} \`${clip(input.command, 100)}\``
39
69
  if (typeof input.file_path === "string") return `${block.name} ${input.file_path}`
40
- if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, SUMMARY_ARGUMENT_CHARS)}"`
41
- return `${block.name} ${clip(JSON.stringify(input), SUMMARY_ARGUMENT_CHARS)}`
42
- }
43
-
44
- export function resultText(block: Block): string {
45
- return typeof block.content === "string" ? block.content : contentBlocks(block.content).filter((b) => b.type === "text").map((b) => String(b.text ?? "")).join("\n")
70
+ if (typeof input.pattern === "string") return `${block.name} "${clip(input.pattern, 60)}"`
71
+ return `${block.name} ${clip(JSON.stringify(input), 60)}`
46
72
  }
@@ -0,0 +1,145 @@
1
+ import { type Block, blocks, clip, describeToolCall, type Message, resultText } from "../content"
2
+ import { toolEvidence } from "../prompts"
3
+
4
+ export interface EvidenceOptions {
5
+ linesPerCall?: number
6
+ lineChars?: number
7
+ turnChars?: number
8
+ scanLines?: number
9
+ minScore?: number
10
+ allTools?: boolean
11
+ ranked?: boolean
12
+ labels?: boolean
13
+ callFloor?: number
14
+ }
15
+
16
+ interface Scored {
17
+ index: number
18
+ line: string
19
+ score: number
20
+ }
21
+
22
+ interface Entry {
23
+ head: string
24
+ lines: string[]
25
+ }
26
+
27
+ export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
28
+
29
+ const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
30
+
31
+ const ERROR_CODE =
32
+ /\b[A-Z][A-Z0-9]*_[A-Z0-9_]{2,}\b|\bSIG[A-Z]{2,}\d*\b|\b[A-Z]{2,}\d{2,}\b|\bE(?:NOENT|ACCES|PERM|EXIST|CONN(?:REFUSED|RESET|ABORTED)|PIPE|ADDRINUSE|TIMEDOUT|NOTFOUND|INVAL|BUSY|MFILE|AGAIN|HOSTUNREACH|NOTDIR|ISDIR|BADF|NOSPC|AI_[A-Z]+)\b/
33
+
34
+ const VALUE_TOKEN =
35
+ /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{4,}\b|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\b\d+(?:\.\d+)?\s?(?:ms|s|[KMGT]i?B|kB|%)(?![a-z])|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+|\b\d+ (?:pass(?:ed|ing)?|fail(?:ed|ing)?|tests?|files? changed|insertions?|deletions?|rows?|errors?|warnings?)\b|[$€£]\d+(?:[.,]\d+)?|\b(?:status|HTTP\/\d(?:\.\d)?)\s*[:=]?\s*[45]\d{2}\b|\bexit(?: code| status)?\s*[:=]?\s*(?!0\b)\d{1,3}\b/i
36
+
37
+ const ID_TOKEN = /\b[a-z]{2,}[_-](?=[a-z0-9]*\d)[a-z0-9]{6,}\b|\b(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{7,}\b|https?:\/\/[^\s)"']+|\b\d{6,}_[a-z0-9_]{3,}\b|\bv?\d+\.\d+\.\d+\b|\bsha\d*:[0-9a-f]+|@[a-z][\w-]+|--[a-z][\w-]*=\S+/i
38
+
39
+ const KEY_VALUE = /^[\w .\-/()]{2,48}[:=]\s*\S/
40
+
41
+ const VALUE_LABEL = /\b(?:ids?|hash|sha(?:256)?|checksum|trace|deploy(?:ment)?|preview|url|version|rows|p9[59]|exit(?: code)?|signal|request[_-]?id|job|migration|applied|bundle|build|status|took|updated|created)\b/i
42
+
43
+ function resolved(options: EvidenceOptions) {
44
+ return {
45
+ linesPerCall: options.linesPerCall ?? 12,
46
+ lineChars: options.lineChars ?? 220,
47
+ turnChars: options.turnChars ?? 4_000,
48
+ scanLines: options.scanLines ?? 1_000_000,
49
+ minScore: options.minScore ?? 2,
50
+ ranked: options.ranked ?? true,
51
+ labels: options.labels ?? true,
52
+ callFloor: options.callFloor ?? 2,
53
+ }
54
+ }
55
+
56
+ const isError = (line: string) => ERROR_LINE.test(line) || ERROR_CODE.test(line)
57
+
58
+ export function isSalient(line: string): boolean {
59
+ return isError(line) || VALUE_TOKEN.test(line)
60
+ }
61
+
62
+ function scoreLines(text: string, options: EvidenceOptions): Scored[] {
63
+ const { scanLines, lineChars, minScore, labels } = resolved(options)
64
+ const scored = text
65
+ .split("\n")
66
+ .slice(0, scanLines)
67
+ .flatMap((raw, index) => {
68
+ const line = raw.trim()
69
+ if (line.length < 4) return []
70
+ const error = isError(line)
71
+ const value = VALUE_TOKEN.test(line)
72
+ if (!error && !value) return []
73
+ const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + (labels && VALUE_LABEL.test(line) ? 1 : 0)
74
+ return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
75
+ })
76
+ const seen = new Set<string>()
77
+ return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
78
+ }
79
+
80
+ export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
81
+ return scoreLines(text, options)
82
+ .slice(0, resolved(options).linesPerCall)
83
+ .sort((a, b) => a.index - b.index)
84
+ .map((s) => s.line)
85
+ }
86
+
87
+ export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
88
+ const results = new Map<string, Block>()
89
+ for (const message of turn) {
90
+ if (message.role !== "user") continue
91
+ for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
92
+ }
93
+ return turn
94
+ .filter((m) => m.role === "assistant")
95
+ .flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
96
+ .flatMap((call) => {
97
+ const result = results.get(call.id)
98
+ return result ? [{ call, result }] : []
99
+ })
100
+ }
101
+
102
+ function render(entries: Entry[]): string | null {
103
+ const shown = entries.filter((e) => e.lines.length > 0).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
104
+ return shown.length > 0 ? toolEvidence(shown) : null
105
+ }
106
+
107
+ function inCallOrder(heads: string[], pairs: Array<{ result: Block }>, options: EvidenceOptions): Entry[] {
108
+ const { turnChars } = resolved(options)
109
+ const entries: Entry[] = []
110
+ let used = 0
111
+ pairs.forEach(({ result }, i) => {
112
+ const lines = salientLines(resultText(result), options)
113
+ if (lines.length === 0) return
114
+ const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
115
+ if (used + size > turnChars) return
116
+ entries.push({ head: heads[i], lines })
117
+ used += size
118
+ })
119
+ return entries
120
+ }
121
+
122
+ function byScore(heads: string[], pairs: Array<{ result: Block }>, options: EvidenceOptions): Entry[] {
123
+ const { turnChars, linesPerCall, callFloor } = resolved(options)
124
+ const perCall = pairs.map(({ result }) => scoreLines(resultText(result), options))
125
+ const chosen = new Map<number, Scored[]>()
126
+ let used = 0
127
+ const take = (call: number, line: Scored) => {
128
+ const bucket = chosen.get(call) ?? []
129
+ const cost = line.line.length + 5 + (bucket.length > 0 ? 0 : heads[call].length + 4)
130
+ if (bucket.length >= linesPerCall || used + cost > turnChars) return
131
+ bucket.push(line)
132
+ chosen.set(call, bucket)
133
+ used += cost
134
+ }
135
+ for (const [call, lines] of perCall.entries()) for (const line of lines.slice(0, callFloor)) take(call, line)
136
+ const rest = perCall.flatMap((lines, call) => lines.slice(callFloor).map((line) => ({ line, call }))).sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
137
+ for (const { line, call } of rest) take(call, line)
138
+ return heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) }))
139
+ }
140
+
141
+ export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
142
+ const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
143
+ const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
144
+ return render(resolved(options).ranked ? byScore(heads, pairs, options) : inCallOrder(heads, pairs, options))
145
+ }
@@ -0,0 +1,65 @@
1
+ import { type Message, textOf } from "../content"
2
+ import { type RecallChunk, recallChunks, terms } from "../recall"
3
+ import { isPrompt, reduceTurn, splitTurns } from "./turns"
4
+
5
+ export interface FoldedTurn {
6
+ user: string
7
+ evidence: string
8
+ reply: string
9
+ }
10
+
11
+ export const FOLD_CAP_CHARS = 400_000
12
+
13
+ const spoken = (message: Message) =>
14
+ textOf(message)
15
+ .replace(/<(system-reminder|system-message|recalled-context|handoff)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, "")
16
+ .trim()
17
+
18
+ const isEvidence = (text: string) => text.startsWith("<tool-evidence>")
19
+
20
+ export function foldedTurns(messages: Message[]): FoldedTurn[] {
21
+ return splitTurns(messages)
22
+ .turns.filter((turn) => isPrompt(turn[0]))
23
+ .map((turn) => {
24
+ const reduced = reduceTurn(turn, { evidence: true })
25
+ const users = reduced.filter((m) => m.role === "user").map(spoken)
26
+ return {
27
+ user: users.filter((t) => t && !isEvidence(t)).join("\n\n"),
28
+ evidence: users.filter(isEvidence).join("\n"),
29
+ reply: reduced
30
+ .filter((m) => m.role === "assistant")
31
+ .map(spoken)
32
+ .join("\n"),
33
+ }
34
+ })
35
+ }
36
+
37
+ function renderTurn(turn: FoldedTurn, index: number, full: boolean): string {
38
+ const parts = [`### Turn ${index + 1}`, `**You:** ${turn.user}`]
39
+ if (full && turn.evidence) parts.push(turn.evidence)
40
+ if (full && turn.reply) parts.push(`**Agent:** ${turn.reply}`)
41
+ return parts.join("\n\n")
42
+ }
43
+
44
+ export function foldedView(messages: Message[], cap = Number.POSITIVE_INFINITY): string {
45
+ const turns = foldedTurns(messages)
46
+ const full = new Set<number>()
47
+ let used = 0
48
+ for (let i = turns.length - 1; i >= 0; i -= 1) {
49
+ const size = renderTurn(turns[i], i, true).length
50
+ if (used + size > cap) break
51
+ full.add(i)
52
+ used += size
53
+ }
54
+ return turns.map((turn, i) => renderTurn(turn, i, full.has(i))).join("\n\n")
55
+ }
56
+
57
+ export function sourceChunks(messages: Message[], from: string, users = false): RecallChunk[] {
58
+ const chunks = recallChunks([...messages, { role: "user", content: "" }], new Map()).map((chunk) => ({ ...chunk, from }))
59
+ if (!users) return chunks
60
+ const said = splitTurns(messages)
61
+ .turns.filter((turn) => isPrompt(turn[0]))
62
+ .map((turn, i) => ({ turn: i + 1, text: spoken(turn[0]) }))
63
+ .filter((t) => t.text)
64
+ return [...said.map((t) => ({ turn: t.turn, from, call: "you", lines: t.text.split("\n"), terms: terms(t.text) })), ...chunks]
65
+ }