@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
@@ -1,16 +1,20 @@
1
1
  import path from "node:path"
2
- import { claudeConfigDirs, loadConfig } from "../config"
2
+ import { claudeConfigDirs, loadConfig, settingsFor } from "../config"
3
3
  import type { Config } from "../config/types"
4
+ import { wellFormed } from "../context/content"
4
5
  import type { Body } from "../context/types"
5
6
  import { findSessionFile } from "../transcript/locate"
6
7
  import { prepareTranscript } from "../transcript/search"
7
8
  import { VERSION } from "../version"
8
- import { HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER } from "./constants"
9
+ import { CANONICAL_HEADER, HEALTH_PATH, IDLE_TIMEOUT_SECONDS, LOGGED_ERROR_CHARS, OMITTED_BEFORE_HEADER, SESSION_HEADER, TRANSCRIPT_HEADER, WINDOW_HEADER } from "./constants"
10
+ import { applyCanonical } from "./canonical"
9
11
  import { sizeOf } from "./estimate"
12
+ import { contextFingerprint } from "./fingerprint"
10
13
  import { logRequest } from "./log"
11
- import { rewrite } from "./rewrite"
14
+ import { rewriteAsync } from "./rewrite"
15
+ import { recordSession } from "./sessions"
12
16
  import { loadState, saveState } from "./state"
13
- import type { Health, Note } from "./types"
17
+ import type { ContextFingerprint, Health, Note } from "./types"
14
18
  import { inputTokens, watchUsage } from "./usage"
15
19
  import { resolveWindow } from "./window"
16
20
 
@@ -31,17 +35,21 @@ function forwardableHeaders(headers: Headers) {
31
35
  return out
32
36
  }
33
37
 
34
- async function prepare(request: Request, body: Body, config: Config, isCountTokens: boolean) {
38
+ async function prepare(request: Request, body: Body, base: Config, isCountTokens: boolean) {
35
39
  const sessionId = sessionIdOf(request.headers, body)
40
+ const config = settingsFor(base, sessionId)
36
41
  const trusted = config.eval?.trustHeaders === true
37
42
  const transcriptOverride = trusted ? request.headers.get(TRANSCRIPT_HEADER) : null
38
43
  const sessionFile = transcriptOverride ?? (sessionId ? findSessionFile(sessionId, claudeConfigDirs(config)) : null)
44
+ if (sessionId && !trusted) recordSession(sessionId, config.mode, sessionFile)
45
+ if (config.mode === "off") return rewriteAsync({ body, sessionId, config, transcript: null, window: null, mayCompact: false })
39
46
  const transcript = sessionFile && sessionId
40
47
  ? prepareTranscript(config.search, sessionFile, transcriptOverride ? path.basename(transcriptOverride, ".jsonl") : sessionId)
41
48
  : null
42
- const needsWindow = config.mode === "compact" || config.eval?.strategy === "tail"
43
- const window = config.window ?? (needsWindow ? await resolveWindow(body.model, request.headers, config.upstream) : null)
44
- return rewrite({
49
+ const needsWindow = !config.eval?.strategy || config.eval.strategy === "tail"
50
+ const windowOverride = trusted ? Number(request.headers.get(WINDOW_HEADER)) || null : null
51
+ const window = windowOverride ?? config.window ?? (needsWindow ? await resolveWindow(body.model, request.headers, config.upstream) : null)
52
+ return rewriteAsync({
45
53
  body,
46
54
  sessionId,
47
55
  config,
@@ -67,15 +75,22 @@ export async function handle(request: Request): Promise<Response> {
67
75
  let note: Note = { action: "pass" }
68
76
  let threadKey: string | null = null
69
77
  let sentSize = 0
78
+ let givenSize = 0
79
+ let sent: (ContextFingerprint & { session: string | null }) | null = null
70
80
 
71
81
  if (bodyText && isMessages) {
72
82
  try {
73
- const body = JSON.parse(bodyText) as Body
83
+ const given = JSON.parse(bodyText) as Body
84
+ const canonicalFile = config.eval?.trustHeaders ? request.headers.get(CANONICAL_HEADER) : null
85
+ const canonical = canonicalFile ? applyCanonical(given, canonicalFile) : null
86
+ const body = canonical?.body ?? given
74
87
  const result = await prepare(request, body, config, isCountTokens)
75
- note = result.note
88
+ note = canonical ? { ...result.note, canonical: canonical.status } : result.note
76
89
  threadKey = result.threadKey
77
- if (result.body !== body) bodyText = JSON.stringify(result.body)
90
+ if (result.body !== given) bodyText = JSON.stringify(result.body, wellFormed)
78
91
  sentSize = sizeOf(result.body)
92
+ givenSize = result.body === given ? sentSize : sizeOf(given)
93
+ if (config.eval) sent = { session: sessionIdOf(request.headers, given), ...contextFingerprint(result.body), given: contextFingerprint(given) }
79
94
  } catch (error) {
80
95
  note = { action: "error-passthrough", error: String(error) }
81
96
  }
@@ -91,7 +106,7 @@ export async function handle(request: Request): Promise<Response> {
91
106
 
92
107
  const response = new Response(upstream.body, { status: upstream.status, statusText: upstream.statusText, headers: forwardableHeaders(upstream.headers) })
93
108
  if (note.action === "pass" || !isMessages || isCountTokens) {
94
- if (note.action !== "pass") logRequest({ path: url.pathname, status: upstream.status, ...note })
109
+ if (note.action !== "pass" || sent) logRequest({ path: url.pathname, status: upstream.status, ...note, ...(sent && { context: sent }) })
95
110
  return response
96
111
  }
97
112
 
@@ -106,6 +121,9 @@ export async function handle(request: Request): Promise<Response> {
106
121
  cacheRead: usage.cache_read_input_tokens ?? 0,
107
122
  cacheWrite: usage.cache_creation_input_tokens ?? 0,
108
123
  uncached: usage.input_tokens ?? 0,
124
+ charsIn: givenSize,
125
+ charsOut: sentSize,
126
+ ...(sent && { context: sent }),
109
127
  })
110
128
  if (threadKey && input > 0 && sentSize > 0) {
111
129
  const state = loadState(threadKey)
@@ -0,0 +1,46 @@
1
+ import { closeSync, mkdirSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs"
2
+ import { EUNOE_DIR, paths } from "../config/constants"
3
+ import type { Mode } from "../config/types"
4
+ import { SESSION_HEAD_BYTES, SESSION_SEEN_INTERVAL_MS, SESSIONS_KEPT } from "./constants"
5
+ import type { SeenSession } from "./types"
6
+
7
+ let seen: Record<string, SeenSession> | null = null
8
+
9
+ function load(): Record<string, SeenSession> {
10
+ try {
11
+ return JSON.parse(readFileSync(paths.sessions, "utf8"))
12
+ } catch {
13
+ return {}
14
+ }
15
+ }
16
+
17
+ function cwdOf(sessionFile: string): string | undefined {
18
+ try {
19
+ const fd = openSync(sessionFile, "r")
20
+ const buffer = Buffer.alloc(SESSION_HEAD_BYTES)
21
+ const read = readSync(fd, buffer, 0, SESSION_HEAD_BYTES, 0)
22
+ closeSync(fd)
23
+ return buffer.subarray(0, read).toString("utf8").match(/"cwd":"((?:[^"\\]|\\.)*)"/)?.[1]
24
+ } catch {
25
+ return undefined
26
+ }
27
+ }
28
+
29
+ export function recordSession(sessionId: string, mode: Mode, sessionFile: string | null) {
30
+ seen ??= load()
31
+ const previous = seen[sessionId]
32
+ const now = Date.now()
33
+ const needsCwd = !previous?.cwd && sessionFile !== null
34
+ if (previous && previous.mode === mode && !needsCwd && now - Date.parse(previous.lastSeen) < SESSION_SEEN_INTERVAL_MS) return
35
+ seen[sessionId] = { lastSeen: new Date(now).toISOString(), mode, cwd: previous?.cwd ?? (sessionFile ? cwdOf(sessionFile) : undefined) }
36
+ const kept = Object.entries(seen).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen)).slice(0, SESSIONS_KEPT)
37
+ seen = Object.fromEntries(kept)
38
+ try {
39
+ mkdirSync(EUNOE_DIR, { recursive: true })
40
+ writeFileSync(paths.sessions, JSON.stringify(seen, null, 2))
41
+ } catch {}
42
+ }
43
+
44
+ export function seenSessions(): Array<[string, SeenSession]> {
45
+ return Object.entries(load()).sort(([, a], [, b]) => b.lastSeen.localeCompare(a.lastSeen))
46
+ }
@@ -7,10 +7,28 @@ export interface ThreadState {
7
7
  keep?: number
8
8
  cleared?: number
9
9
  ratio?: number
10
+ recalls?: Record<string, string>
10
11
  }
11
12
 
12
13
  export type Note = Record<string, unknown>
13
14
 
15
+ export interface ContextFingerprint {
16
+ system: string
17
+ tools: string
18
+ skills: string[]
19
+ given?: ContextFingerprint
20
+ }
21
+
22
+ export interface Canonical {
23
+ system: Body["system"]
24
+ tools: Body["tools"]
25
+ }
26
+
27
+ export interface CanonicalResult {
28
+ body: Body
29
+ status: "applied" | "captured" | "skipped"
30
+ }
31
+
14
32
  export interface RewriteInput {
15
33
  body: Body
16
34
  sessionId: string | null
@@ -50,6 +68,12 @@ export interface Estimator {
50
68
 
51
69
  export type Usage = Record<string, number>
52
70
 
71
+ export interface SeenSession {
72
+ lastSeen: string
73
+ mode: Config["mode"]
74
+ cwd?: string
75
+ }
76
+
53
77
  export interface Health {
54
78
  ok: boolean
55
79
  version: string
@@ -13,4 +13,3 @@ export const TURN_COUNT_FILE = ".turns"
13
13
  export const INDEX_FILE = "index.md"
14
14
  export const QMD_DIR = ".qmd"
15
15
 
16
- export const SESSION_ID_PATTERN = /^[a-zA-Z0-9-]+$/
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from "node:fs"
2
2
  import path from "node:path"
3
- import { SESSION_ID_PATTERN } from "./constants"
3
+ import { SESSION_ID_PATTERN } from "../config/constants"
4
4
 
5
5
  const found = new Map<string, string>()
6
6
 
@@ -0,0 +1,29 @@
1
+ import { readFileSync, statSync } from "node:fs"
2
+ import type { Message } from "../context/types"
3
+ import type { SessionEntry } from "./types"
4
+
5
+ let cached: { file: string; mtimeMs: number; messages: Message[] } | null = null
6
+
7
+ export function sessionMessages(file: string): Message[] {
8
+ let mtimeMs: number
9
+ try {
10
+ mtimeMs = statSync(file).mtimeMs
11
+ } catch {
12
+ return []
13
+ }
14
+ if (cached?.file === file && cached.mtimeMs === mtimeMs) return cached.messages
15
+ const messages: Message[] = []
16
+ for (const line of readFileSync(file, "utf8").split("\n")) {
17
+ if (!line) continue
18
+ let entry: SessionEntry
19
+ try {
20
+ entry = JSON.parse(line)
21
+ } catch {
22
+ continue
23
+ }
24
+ if (entry.isSidechain || entry.isCompactSummary || (entry.type !== "user" && entry.type !== "assistant") || !entry.message) continue
25
+ messages.push({ role: entry.message.role, content: entry.message.content })
26
+ }
27
+ cached = { file, mtimeMs, messages }
28
+ return messages
29
+ }
@@ -1,44 +0,0 @@
1
- import { existsSync } from "node:fs"
2
- import path from "node:path"
3
- import { claudeConfigDirs, loadConfig, saveConfig } from "../../config"
4
- import { paths } from "../../config/constants"
5
- import { flagValues } from "../args"
6
- import { editSettings, proxyUrl, withEunoe } from "../claude-settings"
7
- import { MODE_DESCRIPTIONS } from "../constants"
8
- import { routedDirs } from "../inspect"
9
- import * as launchd from "../launchd"
10
- import type { Command } from "../types"
11
- import { c, fail, line, listFiles, note, ok, rows, tilde, warn } from "../ui"
12
-
13
- export const install: Command = {
14
- summary: "Route Claude Code through eunoe and keep it running",
15
- usage: "eunoe install [--config-dir <dir>]",
16
- details: [
17
- "Starts the proxy in the background and points Claude Code at it by setting",
18
- "ANTHROPIC_BASE_URL in settings.json. New sessions pick it up; restart running ones.",
19
- ],
20
- options: [["--config-dir <dir>", "Also manage this Claude config directory (repeatable, remembered)"]],
21
- run(args) {
22
- const added = flagValues(args, "--config-dir").map((dir) => path.resolve(dir))
23
- for (const dir of added) if (!existsSync(dir)) fail(`No such directory: ${tilde(dir)}`)
24
- if (added.length) saveConfig({ claudeConfigDirs: [...new Set([...loadConfig().claudeConfigDirs, ...added])] })
25
- const config = loadConfig()
26
-
27
- line()
28
- if (launchd.supported) {
29
- launchd.installAgent([process.execPath, path.join(import.meta.dir, "..", "main.ts"), "serve"], paths.proxyLog)
30
- ok(`Proxy running in the background on ${c.cyan(proxyUrl(config.port))}`)
31
- } else {
32
- warn("No launchd here, so keep the proxy running yourself:")
33
- line(` ${c.cyan("eunoe serve")}`)
34
- }
35
- const touched = editSettings(claudeConfigDirs(config), (settings) => withEunoe(settings, config.port, config.mode))
36
- ok("Claude Code routed through eunoe")
37
- listFiles(touched.length ? touched : routedDirs(config).map((dir) => path.join(dir, "settings.json")))
38
- line()
39
- rows([["Mode", `${c.bold(config.mode)} ${c.dim(MODE_DESCRIPTIONS[config.mode])}`]])
40
- line()
41
- note(`New sessions go through eunoe. Restart any that are already running.`)
42
- line()
43
- },
44
- }