@chroxify/eunoe 0.1.0 → 1.0.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 (43) hide show
  1. package/README.md +315 -236
  2. package/package.json +3 -2
  3. package/src/cli/args.ts +1 -1
  4. package/src/cli/commands/index.ts +16 -2
  5. package/src/cli/commands/mode.ts +53 -17
  6. package/src/cli/commands/serve.ts +2 -1
  7. package/src/cli/commands/settings.ts +100 -0
  8. package/src/cli/commands/start.ts +64 -0
  9. package/src/cli/commands/status.ts +50 -16
  10. package/src/cli/commands/stop.ts +33 -11
  11. package/src/cli/commands/uninstall.ts +11 -9
  12. package/src/cli/constants.ts +28 -5
  13. package/src/cli/help.ts +2 -2
  14. package/src/cli/launchd.ts +8 -3
  15. package/src/cli/prompt.ts +91 -0
  16. package/src/cli/service.ts +33 -0
  17. package/src/cli/sessions.ts +25 -0
  18. package/src/cli/stats.ts +73 -0
  19. package/src/cli/types.ts +1 -0
  20. package/src/cli/ui.ts +15 -0
  21. package/src/config/constants.ts +15 -3
  22. package/src/config/index.ts +55 -6
  23. package/src/config/types.ts +34 -5
  24. package/src/context/constants.ts +35 -0
  25. package/src/context/content.ts +23 -1
  26. package/src/context/evidence.ts +92 -0
  27. package/src/context/recall.ts +155 -0
  28. package/src/context/rerank.ts +44 -0
  29. package/src/context/strategies.ts +6 -6
  30. package/src/context/turns.ts +19 -14
  31. package/src/context/types.ts +43 -1
  32. package/src/proxy/canonical.ts +32 -0
  33. package/src/proxy/compaction.ts +29 -12
  34. package/src/proxy/constants.ts +10 -0
  35. package/src/proxy/fingerprint.ts +15 -0
  36. package/src/proxy/rewrite.ts +84 -10
  37. package/src/proxy/server.ts +30 -12
  38. package/src/proxy/sessions.ts +46 -0
  39. package/src/proxy/types.ts +24 -0
  40. package/src/transcript/constants.ts +0 -1
  41. package/src/transcript/locate.ts +1 -1
  42. package/src/transcript/messages.ts +29 -0
  43. package/src/cli/commands/install.ts +0 -44
@@ -0,0 +1,91 @@
1
+ import { c, useColor } from "./ui"
2
+
3
+ export interface Choice<T extends string = string> {
4
+ value: T
5
+ label?: string
6
+ hint?: string
7
+ }
8
+
9
+ export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
10
+
11
+ function keys(onKey: (key: string) => boolean | void): Promise<void> {
12
+ return new Promise((resolve) => {
13
+ const stdin = process.stdin
14
+ stdin.setRawMode(true)
15
+ stdin.resume()
16
+ stdin.setEncoding("utf8")
17
+ const handler = (chunk: string) => {
18
+ if (onKey(chunk) === true) {
19
+ stdin.off("data", handler)
20
+ stdin.setRawMode(false)
21
+ stdin.pause()
22
+ resolve()
23
+ }
24
+ }
25
+ stdin.on("data", handler)
26
+ })
27
+ }
28
+
29
+ function render(title: string, choices: Choice[], index: number, width: number) {
30
+ const lines = [` ${c.bold(title)}`, ""]
31
+ for (const [i, choice] of choices.entries()) {
32
+ const label = (choice.label ?? choice.value).padEnd(width)
33
+ const marker = i === index ? c.green("●") : c.dim("○")
34
+ lines.push(` ${marker} ${i === index ? c.bold(label) : label}${choice.hint ? ` ${c.dim(choice.hint)}` : ""}`)
35
+ }
36
+ lines.push("", ` ${c.dim("↑↓ move · enter choose · esc cancel")}`)
37
+ return lines
38
+ }
39
+
40
+ export async function select<T extends string>(title: string, choices: Choice<T>[], current?: T): Promise<T | null> {
41
+ let index = Math.max(0, choices.findIndex((choice) => choice.value === current))
42
+ const width = Math.max(...choices.map((choice) => (choice.label ?? choice.value).length))
43
+ let drawn = 0
44
+ const draw = () => {
45
+ const lines = render(title, choices, index, width)
46
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
47
+ process.stdout.write(lines.join("\n") + "\n")
48
+ drawn = lines.length
49
+ }
50
+ let chosen: T | null = null
51
+ process.stdout.write(useColor ? "\x1b[?25l" : "")
52
+ draw()
53
+ await keys((key) => {
54
+ if (key === "\x1b[A" || key === "k") index = (index + choices.length - 1) % choices.length
55
+ else if (key === "\x1b[B" || key === "j") index = (index + 1) % choices.length
56
+ else if (key === "\r" || key === "\n") {
57
+ chosen = choices[index].value
58
+ return true
59
+ } else if (key === "\x1b" || key === "q" || key === "\x03") return true
60
+ draw()
61
+ })
62
+ process.stdout.write(`\x1b[${drawn}A\x1b[J`)
63
+ process.stdout.write(useColor ? "\x1b[?25h" : "")
64
+ return chosen
65
+ }
66
+
67
+ export async function input(title: string, placeholder: string, initial = ""): Promise<string | null> {
68
+ let value = initial
69
+ let drawn = 0
70
+ const draw = () => {
71
+ const shown = value ? value : c.dim(placeholder)
72
+ const lines = [` ${c.bold(title)}`, ` ${c.cyan("›")} ${shown}`, "", ` ${c.dim("enter save · esc cancel")}`]
73
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
74
+ process.stdout.write(lines.join("\n") + "\n")
75
+ drawn = lines.length
76
+ }
77
+ let result: string | null = null
78
+ draw()
79
+ await keys((key) => {
80
+ if (key === "\r" || key === "\n") {
81
+ result = value
82
+ return true
83
+ }
84
+ if (key === "\x1b" || key === "\x03") return true
85
+ if (key === "\x7f" || key === "\b") value = value.slice(0, -1)
86
+ else if (key >= " " && !key.startsWith("\x1b")) value += key
87
+ draw()
88
+ })
89
+ process.stdout.write(`\x1b[${drawn}A\x1b[J`)
90
+ return result
91
+ }
@@ -0,0 +1,33 @@
1
+ import path from "node:path"
2
+ import { paths } from "../config/constants"
3
+ import type { Config } from "../config/types"
4
+ import { proxyUrl } from "./claude-settings"
5
+ import { START_POLL_MS, START_TIMEOUT_MS } from "./constants"
6
+ import { probe } from "./inspect"
7
+ import * as launchd from "./launchd"
8
+ import { c, line, ok, warn } from "./ui"
9
+
10
+ async function waitForProxy(port: number) {
11
+ const deadline = Date.now() + START_TIMEOUT_MS
12
+ while (Date.now() < deadline) {
13
+ if (await probe(port)) return true
14
+ await Bun.sleep(START_POLL_MS)
15
+ }
16
+ return false
17
+ }
18
+
19
+ export async function startService(config: Config) {
20
+ if (!launchd.supported) {
21
+ if (await probe(config.port)) return true
22
+ warn("No launchd here, so keep the proxy running yourself:")
23
+ line(` ${c.cyan("eunoe serve")}`)
24
+ return false
25
+ }
26
+ launchd.installAgent([process.execPath, path.join(import.meta.dir, "main.ts"), "serve"], paths.proxyLog)
27
+ if (await waitForProxy(config.port)) {
28
+ ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
29
+ return true
30
+ }
31
+ warn(`The proxy didn't answer on ${proxyUrl(config.port)} yet. Check ${paths.proxyLog}.`)
32
+ return false
33
+ }
@@ -0,0 +1,25 @@
1
+ import { SESSION_ID_PATTERN } from "../config/constants"
2
+ import { seenSessions } from "../proxy/sessions"
3
+ import { flagValues } from "./args"
4
+ import { SHORT_ID_LENGTH } from "./constants"
5
+ import { fail } from "./ui"
6
+
7
+ export const shortId = (sessionId: string) => sessionId.slice(0, SHORT_ID_LENGTH)
8
+
9
+ function resolve(input: string): string {
10
+ if (!SESSION_ID_PATTERN.test(input)) fail(`Not a session id: ${input}`)
11
+ const seen = seenSessions().map(([id]) => id)
12
+ if (seen.includes(input)) return input
13
+ const matches = seen.filter((id) => id.startsWith(input))
14
+ if (matches.length === 1) return matches[0]
15
+ if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
16
+ return input
17
+ }
18
+
19
+ export function sessionArgs(args: string[]): string[] {
20
+ return [...new Set(flagValues(args, "--session").map(resolve))]
21
+ }
22
+
23
+ export function isKnown(sessionId: string) {
24
+ return seenSessions().some(([id]) => id === sessionId)
25
+ }
@@ -0,0 +1,73 @@
1
+ import { STATUS_WINDOW_MS } from "./constants"
2
+ import type { RequestRecord } from "./types"
3
+
4
+ export interface SessionStats {
5
+ context: number
6
+ plain: number | null
7
+ turns: number | null
8
+ requests: number
9
+ folds: number
10
+ lastSeen: string
11
+ }
12
+
13
+ export interface Totals {
14
+ requests: number
15
+ cacheRead: number
16
+ cacheWrite: number
17
+ uncached: number
18
+ folds: number
19
+ lastFold: string | null
20
+ sent: number
21
+ saved: number
22
+ }
23
+
24
+ const THREAD_SUFFIX = /-[0-9a-f]{24}$/
25
+
26
+ export const sessionOf = (record: RequestRecord): string | null => (typeof record.thread === "string" ? record.thread.replace(THREAD_SUFFIX, "") : null)
27
+
28
+ const plainTokens = (record: RequestRecord): number | null => (record.charsIn > 0 && record.charsOut > 0 && record.input > 0 ? Math.round(record.input * record.charsIn / record.charsOut) : null)
29
+
30
+ export function withinWindow(records: RequestRecord[], now = Date.now(), window = STATUS_WINDOW_MS): RequestRecord[] {
31
+ return records.filter((record) => now - Date.parse(record.ts) <= window)
32
+ }
33
+
34
+ export function perSession(records: RequestRecord[]): Map<string, SessionStats> {
35
+ const stats = new Map<string, SessionStats>()
36
+ for (const record of records) {
37
+ const id = sessionOf(record)
38
+ if (!id || !(record.input > 0)) continue
39
+ const previous = stats.get(id)
40
+ stats.set(id, {
41
+ context: record.input,
42
+ plain: plainTokens(record),
43
+ turns: typeof record.turns === "number" ? record.turns : previous?.turns ?? null,
44
+ requests: (previous?.requests ?? 0) + 1,
45
+ folds: (previous?.folds ?? 0) + (record.compacted ? 1 : 0),
46
+ lastSeen: record.ts,
47
+ })
48
+ }
49
+ return stats
50
+ }
51
+
52
+ export function totals(records: RequestRecord[]): Totals {
53
+ const out: Totals = { requests: 0, cacheRead: 0, cacheWrite: 0, uncached: 0, folds: 0, lastFold: null, sent: 0, saved: 0 }
54
+ for (const record of records) {
55
+ if (!(record.input > 0)) continue
56
+ out.requests += 1
57
+ out.cacheRead += record.cacheRead ?? 0
58
+ out.cacheWrite += record.cacheWrite ?? 0
59
+ out.uncached += record.uncached ?? 0
60
+ if (record.compacted) {
61
+ out.folds += 1
62
+ out.lastFold = record.ts
63
+ }
64
+ const plain = plainTokens(record)
65
+ if (plain !== null) {
66
+ out.sent += record.input
67
+ out.saved += plain - record.input
68
+ }
69
+ }
70
+ return out
71
+ }
72
+
73
+ export const savedShare = (context: number, plain: number | null): number | null => (plain && plain > context ? 1 - context / plain : plain ? 0 : null)
package/src/cli/types.ts CHANGED
@@ -7,6 +7,7 @@ export interface Command {
7
7
  usage: string
8
8
  details?: string[]
9
9
  options?: OptionRow[]
10
+ hidden?: boolean
10
11
  run: (args: string[]) => void | Promise<void>
11
12
  }
12
13
 
package/src/cli/ui.ts CHANGED
@@ -53,6 +53,21 @@ export function rows(entries: Array<[string, string]>, indent = 2) {
53
53
  for (const [label, value] of entries) console.log(`${" ".repeat(indent)}${c.dim(label.padEnd(width))} ${value}`)
54
54
  }
55
55
 
56
+ const visible = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "")
57
+
58
+ export function table(entries: string[][], indent = 4, gap = 2) {
59
+ const columns = Math.max(...entries.map((row) => row.length))
60
+ const widths = Array.from({ length: columns }, (_, column) => Math.max(...entries.map((row) => visible(row[column] ?? "").length)))
61
+ for (const row of entries) {
62
+ 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()}`)
64
+ }
65
+ }
66
+
67
+ export function percent(share: number) {
68
+ return `${Math.round(share * 100)}%`
69
+ }
70
+
56
71
  export function tilde(file: string) {
57
72
  const home = homedir()
58
73
  return file.startsWith(home) ? `~${file.slice(home.length)}` : file
@@ -1,6 +1,6 @@
1
1
  import { homedir } from "node:os"
2
2
  import path from "node:path"
3
- import type { Config, Mode, SearchMode } from "./types"
3
+ import type { Config, Mode, Scope, SearchMode, Tunable } from "./types"
4
4
 
5
5
  export const EUNOE_DIR = process.env.EUNOE_DIR ?? path.join(homedir(), ".eunoe")
6
6
 
@@ -10,18 +10,30 @@ export const paths = {
10
10
  threads: path.join(EUNOE_DIR, "threads"),
11
11
  transcripts: path.join(EUNOE_DIR, "transcripts"),
12
12
  models: path.join(EUNOE_DIR, "models.json"),
13
+ sessions: path.join(EUNOE_DIR, "sessions.json"),
13
14
  proxyLog: path.join(EUNOE_DIR, "proxy.log"),
15
+ typesafeKey: path.join(EUNOE_DIR, "typesafe-key"),
14
16
  }
15
17
 
18
+ export const RERANK_OFF = "off"
19
+
16
20
  export const DEFAULTS: Config = {
17
- mode: "compact",
21
+ mode: "default",
22
+ scope: "all",
23
+ sessions: {},
18
24
  port: 8788,
19
25
  compactAt: 0.9,
20
26
  keepTurns: "all",
27
+ keep: 3,
28
+ budget: 100_000,
21
29
  search: "markdown",
30
+ rerank: "jev-preview",
22
31
  upstream: "https://api.anthropic.com",
23
32
  claudeConfigDirs: [],
24
33
  }
25
34
 
26
- export const MODES: Mode[] = ["off", "trim", "compact"]
35
+ export const MODES: Mode[] = ["default", "compact", "off"]
27
36
  export const SEARCH_MODES: SearchMode[] = ["jsonl", "markdown", "xml", "qmd"]
37
+ export const TUNABLES: Tunable[] = ["keep", "budget", "compactAt", "keepTurns", "search", "rerank"]
38
+ export const SCOPES: Scope[] = ["all", "sessions"]
39
+ export const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/
@@ -1,15 +1,47 @@
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, SEARCH_MODES } from "./constants"
5
- import type { Config } from "./types"
4
+ import { DEFAULTS, EUNOE_DIR, MODES, paths, SCOPES, SEARCH_MODES, TUNABLES } from "./constants"
5
+ import type { Config, Mode, SessionSettings, Tunable, Tunables } from "./types"
6
+
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),
14
+ }
15
+
16
+ const TEXT_TUNABLES: Tunable[] = ["search", "rerank"]
17
+
18
+ const isMode = (value: unknown): value is Mode => MODES.includes(value as Mode)
19
+
20
+ 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
23
+ }
24
+
25
+ function normalizeSession(value: unknown): SessionSettings | null {
26
+ if (value === null) return {}
27
+ 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] })
32
+ return settings
33
+ }
6
34
 
7
35
  export function normalizeConfig(raw: Record<string, any>): Config {
8
36
  const config: Config = { ...DEFAULTS, ...raw }
9
- if (!MODES.includes(config.mode)) config.mode = DEFAULTS.mode
10
- if (!SEARCH_MODES.includes(config.search)) config.search = DEFAULTS.search
11
- if (typeof config.compactAt !== "number" || config.compactAt <= 0 || config.compactAt > 1) config.compactAt = DEFAULTS.compactAt
12
- if (config.keepTurns !== "all" && (typeof config.keepTurns !== "number" || config.keepTurns < 0)) config.keepTurns = DEFAULTS.keepTurns
37
+ 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
+ }))
13
45
  if (!Number.isInteger(config.port)) config.port = DEFAULTS.port
14
46
  if (!Array.isArray(config.claudeConfigDirs)) config.claudeConfigDirs = []
15
47
  return config
@@ -47,3 +79,20 @@ export function claudeConfigDirs(config: Config): string[] {
47
79
  for (const dir of config.claudeConfigDirs) dirs.add(dir.replace(/^~(?=$|\/)/, homedir()))
48
80
  return [...dirs]
49
81
  }
82
+
83
+ export function modeFor(config: Config, sessionId: string | null): Mode {
84
+ if (sessionId && sessionId in config.sessions) return config.sessions[sessionId].mode ?? config.mode
85
+ return config.scope === "all" ? config.mode : "off"
86
+ }
87
+
88
+ export function overrides(settings: SessionSettings | undefined): Partial<Tunables> {
89
+ return Object.fromEntries(TUNABLES.filter((key) => settings?.[key] !== undefined).map((key) => [key, settings![key]]))
90
+ }
91
+
92
+ export function settingsFor(config: Config, sessionId: string | null): Config {
93
+ return { ...config, ...overrides(sessionId ? config.sessions[sessionId] : undefined), mode: modeFor(config, sessionId) }
94
+ }
95
+
96
+ export function compactionOwner(config: Config): Mode {
97
+ return config.scope === "all" ? config.mode : "off"
98
+ }
@@ -1,19 +1,48 @@
1
- export type Mode = "off" | "trim" | "compact"
1
+ export type Mode = "off" | "compact" | "default"
2
2
  export type SearchMode = "jsonl" | "markdown" | "xml" | "qmd"
3
+ export type Scope = "all" | "sessions"
4
+
5
+ export interface RollingConfig {
6
+ keep: number
7
+ budget: number
8
+ }
9
+
10
+ export interface RerankConfig {
11
+ model: string
12
+ candidates?: number
13
+ }
3
14
 
4
15
  export interface EvalConfig {
5
- strategy?: "tail" | "guide"
16
+ strategy?: "tail" | "guide" | "trim"
17
+ rolling?: RollingConfig
6
18
  tailTurns?: number
7
19
  forceCut?: boolean
8
20
  trustHeaders?: boolean
21
+ evidence?: boolean
22
+ recall?: boolean
23
+ rerank?: RerankConfig
9
24
  }
10
25
 
11
- export interface Config {
12
- mode: Mode
13
- port: number
26
+ export interface Tunables {
14
27
  compactAt: number
15
28
  keepTurns: number | "all"
29
+ keep: number
30
+ budget: number
16
31
  search: SearchMode
32
+ rerank: string
33
+ }
34
+
35
+ export type Tunable = keyof Tunables
36
+
37
+ export interface SessionSettings extends Partial<Tunables> {
38
+ mode?: Mode
39
+ }
40
+
41
+ export interface Config extends Tunables {
42
+ mode: Mode
43
+ scope: Scope
44
+ sessions: Record<string, SessionSettings>
45
+ port: number
17
46
  upstream: string
18
47
  claudeConfigDirs: string[]
19
48
  window?: number
@@ -5,3 +5,38 @@ export const SUMMARY_CALLS_SHOWN = 20
5
5
 
6
6
  export const CLEARED_INPUT_THRESHOLD = 2_000
7
7
  export const CLEARED_INPUT_KEPT = 500
8
+
9
+ export const REMINDER_ONLY = /^\s*(?:<system-reminder>[\s\S]*?<\/system-reminder>\s*)+$/
10
+
11
+ export const REREADABLE_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch", "TodoWrite", "Edit", "Write", "MultiEdit", "NotebookEdit"])
12
+ export const EVIDENCE_LINES_PER_CALL = 12
13
+ export const EVIDENCE_LINE_CHARS = 220
14
+ export const EVIDENCE_TURN_CHARS = 4_000
15
+ export const EVIDENCE_SCAN_LINES = 1_000_000
16
+ export const EVIDENCE_MIN_SCORE = 2
17
+ export const EVIDENCE_RANKED = true
18
+ export const EVIDENCE_LABELS = true
19
+ export const EVIDENCE_CALL_FLOOR = 2
20
+ export const ERROR_LINE = /\b(?:error|errors|fail|fails|failed|failure|fatal|exception|panic|denied|refused|timed? ?out|traceback|cannot|unable to)\b/i
21
+ export const ERROR_CODE = /\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/
22
+ export const VALUE_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{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
23
+ export 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
24
+ export const KEY_VALUE =/^[\w .\-/()]{2,48}[:=]\s*\S/
25
+ export 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
26
+
27
+ export const RECALL_WINDOW_LINES = 6
28
+ export const RECALL_SHOWN_LINES = 8
29
+ export const RECALL_PER_QUERY = 12
30
+ export const RECALL_MAX_CHARS = 18_000
31
+ export const RECALL_MAX_CHARS_MULTI = 30_000
32
+ export const RECALL_MIN_TERMS = 2
33
+ export const RECALL_SALIENT_EXCERPT = true
34
+ export const RECALL_NARRATIVE = true
35
+ export const RECALL_LINE_CHARS = 240
36
+ export const RECALL_RERANK_CANDIDATES = 30
37
+ export const RERANK_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
38
+ export const RERANK_TIMEOUT_MS = 4_000
39
+ export const RERANK_LINE_CHARS = 240
40
+ export const BM25_K1 = 1.2
41
+ export const BM25_B = 0.75
42
+ export const STOPWORDS = new Set("the and for with that this what which when where who how was were did does done from into onto have has had you your our out not but are can could would should will just then than them they there their its it's about after before again also any each other some such only own same very more most over under while because been being into".split(" "))
@@ -1,3 +1,4 @@
1
+ import { SUMMARY_ARGUMENT_CHARS, SUMMARY_COMMAND_CHARS } from "./constants"
1
2
  import type { Block, Message } from "./types"
2
3
 
3
4
  export function contentBlocks(content: unknown): Block[] {
@@ -18,7 +19,28 @@ export function clean(message: Message): Message {
18
19
  return { role: message.role, content: blocks(message).map(withoutCacheControl) }
19
20
  }
20
21
 
22
+ export function truncate(text: string, max: number): string {
23
+ const cut = text.slice(0, max)
24
+ return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
25
+ }
26
+
27
+ export function wellFormed(_key: string, value: unknown): unknown {
28
+ return typeof value === "string" ? value.toWellFormed() : value
29
+ }
30
+
21
31
  export function clip(value: unknown, max: number): string {
22
32
  const text = String(value).replace(/\s+/g, " ").trim()
23
- return text.length > max ? `${text.slice(0, max)}…` : text
33
+ return text.length > max ? `${truncate(text, max)}…` : text
34
+ }
35
+
36
+ export function describeToolCall(block: Block): string {
37
+ const input = block.input ?? {}
38
+ if (typeof input.command === "string") return `${block.name} \`${clip(input.command, SUMMARY_COMMAND_CHARS)}\``
39
+ 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")
24
46
  }
@@ -0,0 +1,92 @@
1
+ import { ERROR_CODE, ERROR_LINE, EVIDENCE_CALL_FLOOR, EVIDENCE_LABELS, EVIDENCE_LINE_CHARS, EVIDENCE_LINES_PER_CALL, EVIDENCE_MIN_SCORE, EVIDENCE_RANKED, EVIDENCE_SCAN_LINES, EVIDENCE_TURN_CHARS, ID_TOKEN, KEY_VALUE, REREADABLE_TOOLS, VALUE_LABEL, VALUE_TOKEN } from "./constants"
2
+ import { blocks, clip, describeToolCall, resultText } from "./content"
3
+ import type { Block, EvidenceOptions, Message } from "./types"
4
+
5
+ interface Scored {
6
+ index: number
7
+ line: string
8
+ score: number
9
+ }
10
+
11
+ export function isSalient(line: string): boolean {
12
+ return ERROR_LINE.test(line) || ERROR_CODE.test(line) || VALUE_TOKEN.test(line)
13
+ }
14
+
15
+ function scoreLines(text: string, options: EvidenceOptions): Scored[] {
16
+ const scan = options.scanLines ?? EVIDENCE_SCAN_LINES
17
+ const lineChars = options.lineChars ?? EVIDENCE_LINE_CHARS
18
+ const minScore = options.minScore ?? EVIDENCE_MIN_SCORE
19
+ const scored = text.split("\n").slice(0, scan).flatMap((raw, index) => {
20
+ const line = raw.trim()
21
+ if (line.length < 4) return []
22
+ const error = ERROR_LINE.test(line) || ERROR_CODE.test(line)
23
+ const value = VALUE_TOKEN.test(line)
24
+ if (!error && !value) return []
25
+ const score = (error ? 3 : 0) + (value ? (ID_TOKEN.test(line) ? 3 : 2) : 0) + (KEY_VALUE.test(line) ? 1 : 0) + (line.length < 120 ? 1 : 0) + ((options.labels ?? EVIDENCE_LABELS) && VALUE_LABEL.test(line) ? 1 : 0)
26
+ return score >= minScore ? [{ index, line: clip(line, lineChars), score }] : []
27
+ })
28
+ const seen = new Set<string>()
29
+ return scored.sort((a, b) => b.score - a.score || a.index - b.index).filter((s) => !seen.has(s.line) && seen.add(s.line))
30
+ }
31
+
32
+ export function salientLines(text: string, options: EvidenceOptions = {}): string[] {
33
+ return scoreLines(text, options)
34
+ .slice(0, options.linesPerCall ?? EVIDENCE_LINES_PER_CALL)
35
+ .sort((a, b) => a.index - b.index)
36
+ .map((s) => s.line)
37
+ }
38
+
39
+ export function toolPairs(turn: Message[]): Array<{ call: Block; result: Block }> {
40
+ const results = new Map<string, Block>()
41
+ for (const message of turn) {
42
+ if (message.role !== "user") continue
43
+ for (const block of blocks(message)) if (block.type === "tool_result") results.set(block.tool_use_id, block)
44
+ }
45
+ return turn
46
+ .filter((m) => m.role === "assistant")
47
+ .flatMap((m) => blocks(m).filter((b) => b.type === "tool_use"))
48
+ .flatMap((call) => (results.has(call.id) ? [{ call, result: results.get(call.id)! }] : []))
49
+ }
50
+
51
+ function render(entries: Array<{ head: string; lines: string[] }>): string | null {
52
+ const body = entries.filter((e) => e.lines.length).map((e) => `- ${e.head}:\n${e.lines.map((l) => ` ${l}`).join("\n")}`)
53
+ return body.length ? `<tool-evidence>\nKey lines of command output from this turn, kept verbatim (the full output is in the transcript):\n${body.join("\n")}\n</tool-evidence>` : null
54
+ }
55
+
56
+ export function turnEvidence(turn: Message[], options: EvidenceOptions = {}): string | null {
57
+ const turnChars = options.turnChars ?? EVIDENCE_TURN_CHARS
58
+ const perCall = options.linesPerCall ?? EVIDENCE_LINES_PER_CALL
59
+ const pairs = toolPairs(turn).filter(({ call }) => options.allTools || !REREADABLE_TOOLS.has(call.name))
60
+ const heads = pairs.map(({ call, result }) => `${describeToolCall(call)}${result.is_error ? " (failed)" : ""}`)
61
+ if (!(options.ranked ?? EVIDENCE_RANKED)) {
62
+ const entries: Array<{ head: string; lines: string[] }> = []
63
+ let used = 0
64
+ pairs.forEach(({ result }, i) => {
65
+ const lines = salientLines(resultText(result), options)
66
+ if (!lines.length) return
67
+ const size = heads[i].length + 4 + lines.reduce((s, l) => s + l.length + 5, 0)
68
+ if (used + size > turnChars) return
69
+ entries.push({ head: heads[i], lines })
70
+ used += size
71
+ })
72
+ return render(entries)
73
+ }
74
+ const floor = options.callFloor ?? EVIDENCE_CALL_FLOOR
75
+ const perCallLines = pairs.map(({ result }) => scoreLines(resultText(result), options))
76
+ const chosen = new Map<number, Scored[]>()
77
+ let used = 0
78
+ const take = (call: number, line: Scored) => {
79
+ const bucket = chosen.get(call) ?? []
80
+ const cost = line.line.length + 5 + (bucket.length ? 0 : heads[call].length + 4)
81
+ if (bucket.length >= perCall || used + cost > turnChars) return
82
+ bucket.push(line)
83
+ chosen.set(call, bucket)
84
+ used += cost
85
+ }
86
+ perCallLines.forEach((lines, call) => lines.slice(0, floor).forEach((line) => take(call, line)))
87
+ perCallLines
88
+ .flatMap((lines, call) => lines.slice(floor).map((line) => ({ line, call })))
89
+ .sort((a, b) => b.line.score - a.line.score || a.call - b.call || a.line.index - b.line.index)
90
+ .forEach(({ line, call }) => take(call, line))
91
+ return render(heads.map((head, i) => ({ head, lines: (chosen.get(i) ?? []).sort((a, b) => a.index - b.index).map((s) => s.line) })))
92
+ }