@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
@@ -0,0 +1,141 @@
1
+ import type { Config } from "../../config"
2
+ import type { Body, Message } from "../../context/content"
3
+ import { isPrompt, type ReduceOptions, reduceTurn, splitTurns, turnFingerprint } from "../../context/fold/turns"
4
+ import { compact } from "../../proxy/compaction"
5
+ import { DEFAULT_RATIO, sizeOf } from "../../proxy/estimate"
6
+ import type { ThreadState } from "../../proxy/state"
7
+
8
+ export interface ReplayOptions {
9
+ config: Config
10
+ window: number
11
+ fixedTokens: number
12
+ recallChars?: number
13
+ }
14
+
15
+ export interface Replay {
16
+ requests: number
17
+ turns: number
18
+ folds: number
19
+ dropped: number
20
+ nativeCompactions: number
21
+ discarded: number
22
+ plainTokens: number
23
+ eunoeTokens: number
24
+ plainCost: number
25
+ eunoeCost: number
26
+ peakPlain: number
27
+ peakEunoe: number
28
+ }
29
+
30
+ export interface ReplayedSession extends Replay {
31
+ id: string
32
+ file: string
33
+ bytes: number
34
+ cwd: string | null
35
+ title: string | null
36
+ ms: number
37
+ }
38
+
39
+ export const REPLAY_NATIVE_COMPACT_AT = 0.92
40
+
41
+ const NATIVE_SUMMARY_CHARS = 30_000
42
+ const CACHE_READ_WEIGHT = 0.1
43
+ const CACHE_WRITE_WEIGHT = 1.25
44
+
45
+ const emptyChars = sizeOf({ system: "", tools: [], messages: [] })
46
+ const charsOf = (message: Message) => sizeOf({ system: "", tools: [], messages: [message] }) - emptyChars
47
+ const listChars = (sum: number, count: number) => sum + Math.max(0, count - 1)
48
+ const tokens = (chars: number) => Math.round(chars * DEFAULT_RATIO)
49
+
50
+ function commonPrefix(a: Message[], b: Message[]): number {
51
+ let chars = 0
52
+ for (let i = 0; i < Math.min(a.length, b.length); i += 1) {
53
+ if (a[i] !== b[i] && JSON.stringify(a[i]) !== JSON.stringify(b[i])) break
54
+ chars += charsOf(a[i]) + 1
55
+ }
56
+ return chars
57
+ }
58
+
59
+ export function replaySession(messages: Message[], { config, window, fixedTokens, recallChars: recallPerPrompt = 3_500 }: ReplayOptions): Replay {
60
+ const out: Replay = { requests: 0, turns: 0, folds: 0, dropped: 0, nativeCompactions: 0, discarded: 0, plainTokens: 0, eunoeTokens: 0, plainCost: 0, eunoeCost: 0, peakPlain: 0, peakEunoe: 0 }
61
+ const fixedChars = Math.round(fixedTokens / DEFAULT_RATIO)
62
+ const system = "x".repeat(fixedChars)
63
+ const sizes = messages.map(charsOf)
64
+ const { preamble, turns } = splitTurns(messages)
65
+ const turnAt = [...preamble.map(() => -1), ...turns.flatMap((turn, k) => turn.map(() => k))]
66
+ const limit = (config.compactAt * window) / DEFAULT_RATIO
67
+ const cost = (chars: number, cacheable: number) => {
68
+ const cached = Math.max(0, cacheable - fixedChars)
69
+ return CACHE_READ_WEIGHT * tokens(cached) + CACHE_WRITE_WEIGHT * tokens(chars - fixedChars - cached)
70
+ }
71
+ out.turns = turns.length
72
+
73
+ const reduced = new Map<string, Message[]>()
74
+ const reduce = (turn: Message[], options: ReduceOptions) => {
75
+ const key = `${turnFingerprint(turn)}:${turn.length}`
76
+ const hit = reduced.get(key) ?? reduceTurn(turn, options)
77
+ reduced.set(key, hit)
78
+ return hit
79
+ }
80
+
81
+ const state: ThreadState = {}
82
+ const eunoe = { sent: [] as Message[], sum: 0, count: 0, upTo: 0, recall: 0, compactedTurn: -2, previous: 0 }
83
+ const plain = { sum: 0, count: 0, since: 0, previous: 0 }
84
+
85
+ const sendEunoe = (i: number) => {
86
+ const turn = turnAt[i - 1]
87
+ const newTurn = turn !== eunoe.compactedTurn
88
+ if (newTurn && state.cut && isPrompt(messages[i - 1])) eunoe.recall += recallPerPrompt
89
+ for (; eunoe.upTo < i; eunoe.upTo += 1) {
90
+ eunoe.sum += sizes[eunoe.upTo]
91
+ eunoe.count += 1
92
+ }
93
+ let cacheable = eunoe.previous
94
+ if (newTurn || fixedChars + listChars(eunoe.sum, eunoe.count) + eunoe.recall > limit) {
95
+ const recall = eunoe.recall
96
+ const decorate = (ms: Message[]) => (recall ? [...ms, { role: "user", content: "r".repeat(recall) }] : ms)
97
+ const result = compact({ body: { system, tools: [], messages: messages.slice(0, i) } as Body, config, state, window, guide: null, mayCompact: true, reduce, decorate })
98
+ if (result.note.compacted) out.folds += 1
99
+ out.dropped = Math.max(out.dropped, Number(result.note.omitted ?? 0))
100
+ if (result.note.compacted || result.note.actions) cacheable = Math.min(eunoe.previous, fixedChars + commonPrefix(eunoe.sent, result.messages))
101
+ eunoe.sent = result.messages
102
+ eunoe.sum = eunoe.sent.reduce((sum, m) => sum + charsOf(m), 0)
103
+ eunoe.count = eunoe.sent.length
104
+ eunoe.compactedTurn = turn
105
+ }
106
+ const chars = fixedChars + listChars(eunoe.sum, eunoe.count) + eunoe.recall
107
+ out.eunoeTokens += tokens(chars - fixedChars)
108
+ out.peakEunoe = Math.max(out.peakEunoe, tokens(chars))
109
+ out.eunoeCost += cost(chars, cacheable)
110
+ eunoe.previous = chars
111
+ }
112
+
113
+ const sendPlain = (i: number) => {
114
+ for (; plain.since < i; plain.since += 1) {
115
+ plain.sum += sizes[plain.since]
116
+ plain.count += 1
117
+ }
118
+ let chars = fixedChars + listChars(plain.sum, plain.count)
119
+ let cacheable = plain.previous
120
+ out.peakPlain = Math.max(out.peakPlain, tokens(chars))
121
+ if (tokens(chars) > REPLAY_NATIVE_COMPACT_AT * window) {
122
+ out.nativeCompactions += 1
123
+ out.discarded += tokens(chars - fixedChars)
124
+ plain.sum = NATIVE_SUMMARY_CHARS
125
+ plain.count = 1
126
+ chars = fixedChars + listChars(plain.sum, plain.count)
127
+ cacheable = fixedChars
128
+ }
129
+ out.plainTokens += tokens(chars - fixedChars)
130
+ out.plainCost += cost(chars, cacheable)
131
+ plain.previous = chars
132
+ }
133
+
134
+ for (let i = 1; i < messages.length; i += 1) {
135
+ if (messages[i].role !== "assistant") continue
136
+ out.requests += 1
137
+ sendEunoe(i)
138
+ sendPlain(i)
139
+ }
140
+ return out
141
+ }
@@ -0,0 +1,15 @@
1
+ import { statSync } from "node:fs"
2
+ import { basename } from "node:path"
3
+ import { cwdOf, titleOf } from "../../proxy/sessions"
4
+ import { sessionMessages } from "../../transcript/messages"
5
+ import { type ReplayedSession, type ReplayOptions, replaySession } from "."
6
+
7
+ declare var self: Worker
8
+
9
+ self.onmessage = (event: MessageEvent<{ file: string; options: ReplayOptions }>) => {
10
+ const { file, options } = event.data
11
+ const started = performance.now()
12
+ const replay = replaySession(sessionMessages(file), options)
13
+ const done: ReplayedSession = { ...replay, id: basename(file, ".jsonl"), file, bytes: statSync(file).size, cwd: cwdOf(file) ?? null, title: titleOf(file) ?? null, ms: Math.round(performance.now() - started) }
14
+ postMessage(done)
15
+ }
@@ -0,0 +1,88 @@
1
+ import { clip } from "../../context/content"
2
+ import { interact, keys, printable } from "../terminal/prompt"
3
+ import { c, wrap } from "../terminal/ui"
4
+ import { askOf, metaOf, shortId } from "."
5
+ import { rankCandidates, type SessionCandidate } from "./source"
6
+
7
+ export const UNTITLED = "untitled"
8
+
9
+ const ROWS = 7
10
+
11
+ const metas = new Map<string, string>()
12
+
13
+ const columns = () => Math.max(60, process.stdout.columns ?? 100)
14
+
15
+ function cachedMeta(cand: SessionCandidate) {
16
+ const meta = metas.get(cand.id) ?? metaOf(cand)
17
+ metas.set(cand.id, meta)
18
+ return meta
19
+ }
20
+
21
+ export function filterCandidates(query: string, candidates: SessionCandidate[]): SessionCandidate[] {
22
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean)
23
+ if (words.length === 0) return candidates
24
+ const ranked = rankCandidates(query, candidates)
25
+ const haystack = (cand: SessionCandidate) => `${cand.label} ${cand.asks.join(" ")} ${cand.id}`.toLowerCase()
26
+ const loose = candidates.filter((cand) => !ranked.includes(cand) && words.every((word) => haystack(cand).includes(word)))
27
+ return [...ranked, ...loose]
28
+ }
29
+
30
+ function row(cand: SessionCandidate, chosen: boolean, width: number): string[] {
31
+ const marker = chosen ? c.green("●") : c.dim("○")
32
+ const info = cachedMeta(cand)
33
+ const title = cand.label ? clip(cand.label, width - info.length - 16) : UNTITLED
34
+ const head = ` ${marker} ${c.cyan(shortId(cand.id))} ${chosen ? c.bold(title) : cand.label ? title : c.dim(title)} ${c.dim(info)}`
35
+ if (!chosen) return [head]
36
+ const lines = [head]
37
+ for (const [label, text] of [
38
+ ["first", cand.asks[0]],
39
+ ["last ", cand.asks.at(-1)],
40
+ ] as const) {
41
+ const [first = "", ...rest] = wrap(askOf(text), width - 14)
42
+ lines.push(` ${c.dim(label)} ${first}`, ...rest.map((more) => ` ${more}`))
43
+ }
44
+ return lines
45
+ }
46
+
47
+ function render(title: string, query: string, shown: SessionCandidate[], total: number, index: number, help: string): string[] {
48
+ const width = columns()
49
+ const lines = [` ${c.bold(title)} ${c.dim(`${shown.length} of ${total}`)}`, ` ${c.cyan("›")} ${query || c.dim("type to filter")}`, ""]
50
+ if (shown.length === 0) lines.push(` ${c.dim("nothing matches")}`)
51
+ const start = Math.max(0, Math.min(index - Math.floor(ROWS / 2), shown.length - ROWS))
52
+ if (start > 0) lines.push(` ${c.dim(`↑ ${start} more`)}`)
53
+ for (const [i, cand] of shown.slice(start, start + ROWS).entries()) lines.push(...row(cand, start + i === index, width))
54
+ if (start + ROWS < shown.length) lines.push(` ${c.dim(`↓ ${shown.length - start - ROWS} more`)}`)
55
+ lines.push("", ` ${c.dim(help)}`)
56
+ return lines
57
+ }
58
+
59
+ export async function browse(title: string, candidates: SessionCandidate[], initial = "", help = "↑↓ move · type to filter · enter choose · esc quit"): Promise<SessionCandidate | null> {
60
+ let query = initial
61
+ let shown = filterCandidates(query, candidates)
62
+ let index = 0
63
+ let chosen: SessionCandidate | null = null
64
+ const step = (by: number) => {
65
+ index = shown.length > 0 ? (index + shown.length + by) % shown.length : 0
66
+ }
67
+ await interact(
68
+ () => render(title, query, shown, candidates.length, index, help),
69
+ (key) => {
70
+ if (key === keys.up || key === keys.down) {
71
+ step(key === keys.up ? -1 : 1)
72
+ return
73
+ }
74
+ if (keys.enter.includes(key)) {
75
+ chosen = shown[index] ?? null
76
+ return true
77
+ }
78
+ if (keys.escape.includes(key)) return true
79
+ if (keys.backspace.includes(key)) query = query.slice(0, -1)
80
+ else if (key === keys.clear) query = ""
81
+ else if (printable(key)) query += key
82
+ else return
83
+ shown = filterCandidates(query, candidates)
84
+ index = 0
85
+ },
86
+ )
87
+ return chosen
88
+ }
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { claudeConfigDirs, readConfig } from "../../config"
3
+ import { FOLD_CAP_CHARS, foldedView, sourceChunks } from "../../context/fold"
4
+ import { recallFor } from "../../context/recall"
5
+ import { DEFAULT_RATIO } from "../../proxy/estimate"
6
+ import { continueToken } from "../../proxy/handoff"
7
+ import { seenSessions } from "../../proxy/sessions"
8
+ import { isSessionId } from "../../transcript/locate"
9
+ import { subagentChunks } from "../../transcript/subagents"
10
+ import { flagValues } from "../terminal/args"
11
+ import { ago, fail, tokens } from "../terminal/ui"
12
+ import { type SessionCandidate, sessionCandidates } from "./source"
13
+
14
+ const ASK_CHARS = 160
15
+
16
+ export const shortId = (sessionId: string) => sessionId.slice(0, 8)
17
+
18
+ function byPrefix<T>(input: string, items: T[], idOf: (item: T) => string): T | null {
19
+ if (!isSessionId(input)) fail(`Not a session id: ${input}`)
20
+ const exact = items.find((item) => idOf(item) === input)
21
+ if (exact) return exact
22
+ const matches = items.filter((item) => idOf(item).startsWith(input))
23
+ if (matches.length > 1) fail(`${input} matches ${matches.length} sessions`, "Use more of the id.")
24
+ return matches[0] ?? null
25
+ }
26
+
27
+ export function sessionArgs(args: string[]): string[] {
28
+ const seen = seenSessions().map(([id]) => id)
29
+ return [...new Set(flagValues(args, "--session").map((input) => byPrefix(input, seen, (id) => id) ?? input))]
30
+ }
31
+
32
+ export function isKnown(sessionId: string) {
33
+ return seenSessions().some(([id]) => id === sessionId)
34
+ }
35
+
36
+ export const candidatesHere = () => sessionCandidates(process.env.CLAUDE_CODE_SESSION_ID ?? "", process.cwd(), claudeConfigDirs(readConfig()))
37
+
38
+ export function candidateOf(input: string | undefined, candidates: SessionCandidate[]): SessionCandidate {
39
+ if (!input) fail("Say which session: eunoe sessions lists them.")
40
+ return byPrefix(input, candidates, (cand) => cand.id) ?? fail(`No earlier session ${input} in this directory.`, "eunoe sessions lists them.")
41
+ }
42
+
43
+ const tokensOf = (cand: SessionCandidate) => tokens(Math.round(foldedView(cand.messages, FOLD_CAP_CHARS).length * DEFAULT_RATIO))
44
+
45
+ export const titleOf = (cand: SessionCandidate) => cand.label || shortId(cand.id)
46
+
47
+ export const metaOf = (cand: SessionCandidate) => `${cand.asks.length} turns · ~${tokensOf(cand)} tokens · ${ago(new Date(cand.mtime).toISOString())}`
48
+
49
+ export function askOf(text: string | undefined): string {
50
+ const flat = (text ?? "").replace(/\s+/g, " ").trim()
51
+ if (flat.length <= ASK_CHARS) return flat
52
+ const cut = flat.slice(0, ASK_CHARS)
53
+ return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), ASK_CHARS / 2))}…`
54
+ }
55
+
56
+ export const continuePrompt = (id: string) => continueToken(shortId(id))
57
+
58
+ export function copy(text: string): boolean {
59
+ const clipboards: Array<[string, string[]]> = [
60
+ ["pbcopy", []],
61
+ ["wl-copy", []],
62
+ ["xclip", ["-selection", "clipboard"]],
63
+ ]
64
+ return clipboards.some(([cmd, args]) => {
65
+ const result = spawnSync(cmd, args, { input: text })
66
+ return !result.error && result.status === 0
67
+ })
68
+ }
69
+
70
+ export function peekText(cand: SessionCandidate, words: string): string | null {
71
+ const chunks = [...sourceChunks(cand.messages, titleOf(cand), true), ...subagentChunks(cand.file)]
72
+ const found = recallFor(words, chunks, { maxChars: 12_000, maxCharsMulti: 12_000, perQuery: 6, minTerms: 1 })
73
+ if (!found) return null
74
+ const excerpts = found.replace(/^<recalled-context>\n[^\n]*\n\n|\n<\/recalled-context>$/g, "")
75
+ return `Excerpts of session ${shortId(cand.id)} "${titleOf(cand)}" that match "${words}", quoted exactly, oldest first:\n\n${excerpts}`
76
+ }
@@ -0,0 +1,74 @@
1
+ import { statSync } from "node:fs"
2
+ import path from "node:path"
3
+ import { type Message, textOf } from "../../context/content"
4
+ import { isPrompt, splitTurns } from "../../context/fold/turns"
5
+ import { type RecallChunk, rank, terms } from "../../context/recall"
6
+ import { cwdOf, seenSessions, titleOf } from "../../proxy/sessions"
7
+ import { findSessionFile, listEntries, projectFolderName } from "../../transcript/locate"
8
+ import { sessionMessages } from "../../transcript/messages"
9
+
10
+ export interface SessionCandidate {
11
+ id: string
12
+ file: string
13
+ label: string
14
+ mtime: number
15
+ asks: string[]
16
+ outline: string
17
+ messages: Message[]
18
+ }
19
+
20
+ export const SESSION_SOURCE_MIN_TURNS = 2
21
+ export const SESSION_SOURCE_CANDIDATES = 12
22
+ export const SESSION_MIN_TERMS = 2
23
+
24
+ const promptText = (message: Message) =>
25
+ textOf(message)
26
+ .replace(/<[a-z-]+(?:\s[^>]*)?>[\s\S]*?<\/[a-z-]+>/g, "")
27
+ .trim()
28
+
29
+ export function outlineOf(messages: Message[]): string[] {
30
+ return splitTurns(messages)
31
+ .turns.filter((turn) => isPrompt(turn[0]))
32
+ .map((turn) => promptText(turn[0]))
33
+ .filter(Boolean)
34
+ .map((text) => text.slice(0, 240))
35
+ }
36
+
37
+ function sessionFiles(sessionId: string, cwd: string, configDirs: string[]): Array<{ id: string; file: string; mtime: number }> {
38
+ const files = new Map<string, { file: string; mtime: number }>()
39
+ const add = (id: string, file: string) => {
40
+ if (id === sessionId || files.has(id)) return
41
+ try {
42
+ files.set(id, { file, mtime: statSync(file).mtimeMs })
43
+ } catch {}
44
+ }
45
+ for (const [id, seen] of seenSessions()) {
46
+ if (seen.cwd !== cwd) continue
47
+ const file = findSessionFile(id, configDirs)
48
+ if (file) add(id, file)
49
+ }
50
+ for (const dir of configDirs) {
51
+ const folder = path.join(dir, "projects", projectFolderName(cwd))
52
+ for (const name of listEntries(folder)) if (name.endsWith(".jsonl")) add(name.slice(0, -".jsonl".length), path.join(folder, name))
53
+ }
54
+ return [...files].map(([id, entry]) => ({ id, ...entry })).sort((a, b) => b.mtime - a.mtime)
55
+ }
56
+
57
+ export function sessionCandidates(sessionId: string, cwd: string, configDirs: string[]): SessionCandidate[] {
58
+ const out: SessionCandidate[] = []
59
+ for (const { id, file, mtime } of sessionFiles(sessionId, cwd, configDirs)) {
60
+ const recorded = cwdOf(file)
61
+ if (recorded && recorded !== cwd) continue
62
+ const messages = sessionMessages(file)
63
+ const asks = outlineOf(messages)
64
+ if (asks.length < SESSION_SOURCE_MIN_TURNS) continue
65
+ out.push({ id, file, label: titleOf(file) ?? "", mtime, asks, outline: asks.map((ask, i) => `${i + 1}. ${ask}`).join("\n"), messages })
66
+ if (out.length >= SESSION_SOURCE_CANDIDATES) break
67
+ }
68
+ return out
69
+ }
70
+
71
+ export function rankCandidates(prompt: string, candidates: SessionCandidate[]): SessionCandidate[] {
72
+ const chunks: RecallChunk[] = candidates.map((cand, i) => ({ turn: i, call: cand.id, lines: [cand.outline], terms: terms(cand.outline) }))
73
+ return rank(terms(prompt), chunks, SESSION_MIN_TERMS).map((r) => candidates[r.index])
74
+ }
@@ -1,20 +1,36 @@
1
- import { VERSION } from "../version"
2
- import { GLOBAL_OPTIONS, TAGLINE } from "./constants"
3
- import { withLogo } from "./logo"
4
- import type { Command } from "./types"
1
+ import { VERSION } from "../../version"
2
+ import type { Command, OptionRow } from "../command"
3
+ import { withLogo } from "../logo"
5
4
  import { c, line } from "./ui"
6
5
 
6
+ const GROUPS: Array<[title: string, names: string[]]> = [
7
+ ["Proxy", ["start", "stop", "status", "replay", "uninstall"]],
8
+ ["Context", ["mode", "settings"]],
9
+ ["Sessions", ["sessions"]],
10
+ ]
11
+
12
+ const GLOBAL_OPTIONS: OptionRow[] = [
13
+ ["-h, --help", "Show help for a command"],
14
+ ["-v, --version", "Show the version"],
15
+ ]
16
+
7
17
  export function printHelp(commands: Record<string, Command>) {
8
18
  const width = Math.max(...Object.keys(commands).map((name) => name.length), ...GLOBAL_OPTIONS.map(([flag]) => flag.length))
9
19
  line()
10
- for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic(TAGLINE)])) line(row)
20
+ for (const row of withLogo([`${c.bold("eunoe")} ${c.dim(VERSION)}`, c.italic("Context management for Claude Code that never forgets what matters.")])) line(row)
11
21
  line()
12
22
  line(` ${c.dim("Usage")}`)
13
23
  line(` eunoe ${c.cyan("<command>")} ${c.dim("[options]")}`)
14
24
  line()
15
- line(` ${c.dim("Commands")}`)
16
- for (const [name, command] of Object.entries(commands).filter(([, command]) => !command.hidden)) line(` ${c.cyan(name.padEnd(width))} ${command.summary}`)
17
- line()
25
+ const visible = Object.keys(commands).filter((name) => !commands[name].hidden)
26
+ const grouped = new Set(GROUPS.flatMap(([, names]) => names))
27
+ for (const [title, names] of [...GROUPS, ["Other", visible.filter((name) => !grouped.has(name))] as const]) {
28
+ const shown = names.filter((name) => visible.includes(name))
29
+ if (shown.length === 0) continue
30
+ line(` ${c.dim(title)}`)
31
+ for (const name of shown) line(` ${c.cyan(name.padEnd(width))} ${commands[name].summary}`)
32
+ line()
33
+ }
18
34
  line(` ${c.dim("Options")}`)
19
35
  for (const [flag, description] of GLOBAL_OPTIONS) line(` ${flag.padEnd(width)} ${description}`)
20
36
  line()
@@ -0,0 +1,114 @@
1
+ import { c, useColor } from "./ui"
2
+
3
+ const ESC = "\x1b"
4
+ const KEY_SEQUENCE = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z~]|${ESC}.|[\\s\\S]`, "g")
5
+
6
+ interface Choice<T extends string = string> {
7
+ value: T
8
+ label?: string
9
+ hint?: string
10
+ }
11
+
12
+ export const keys = {
13
+ up: "\x1b[A",
14
+ down: "\x1b[B",
15
+ enter: ["\r", "\n"],
16
+ escape: ["\x1b", "\x03"],
17
+ backspace: ["\x7f", "\b"],
18
+ clear: "\x15",
19
+ }
20
+
21
+ export const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
22
+
23
+ export const printable = (key: string) => key >= " " && !key.startsWith("\x1b")
24
+
25
+ function readKeys(onKey: (key: string) => boolean | undefined): Promise<void> {
26
+ return new Promise((resolve) => {
27
+ const stdin = process.stdin
28
+ stdin.setRawMode(true)
29
+ stdin.resume()
30
+ stdin.setEncoding("utf8")
31
+ const handler = (chunk: string) => {
32
+ for (const key of chunk.match(KEY_SEQUENCE) ?? []) {
33
+ if (onKey(key) !== true) continue
34
+ stdin.off("data", handler)
35
+ stdin.setRawMode(false)
36
+ stdin.pause()
37
+ resolve()
38
+ return
39
+ }
40
+ }
41
+ stdin.on("data", handler)
42
+ })
43
+ }
44
+
45
+ export async function interact(render: () => string[], onKey: (key: string) => boolean | undefined, hideCursor = true): Promise<void> {
46
+ let drawn = 0
47
+ const draw = () => {
48
+ const lines = render()
49
+ if (drawn) process.stdout.write(`\x1b[${drawn}A\x1b[J`)
50
+ process.stdout.write(`${lines.join("\n")}\n`)
51
+ drawn = lines.length
52
+ }
53
+ const toggleCursor = hideCursor && useColor
54
+ if (toggleCursor) process.stdout.write("\x1b[?25l")
55
+ draw()
56
+ await readKeys((key) => {
57
+ if (onKey(key) === true) return true
58
+ draw()
59
+ })
60
+ process.stdout.write(`\x1b[${drawn}A\x1b[J`)
61
+ if (toggleCursor) process.stdout.write("\x1b[?25h")
62
+ }
63
+
64
+ function renderChoices(title: string, choices: Choice[], index: number, width: number) {
65
+ const lines = [` ${c.bold(title)}`, ""]
66
+ for (const [i, choice] of choices.entries()) {
67
+ const label = (choice.label ?? choice.value).padEnd(width)
68
+ const marker = i === index ? c.green("●") : c.dim("○")
69
+ lines.push(` ${marker} ${i === index ? c.bold(label) : label}${choice.hint ? ` ${c.dim(choice.hint)}` : ""}`)
70
+ }
71
+ lines.push("", ` ${c.dim("↑↓ move · enter choose · esc cancel")}`)
72
+ return lines
73
+ }
74
+
75
+ export async function select<T extends string>(title: string, choices: Choice<T>[], current?: T): Promise<T | null> {
76
+ let index = Math.max(
77
+ 0,
78
+ choices.findIndex((choice) => choice.value === current),
79
+ )
80
+ const width = Math.max(...choices.map((choice) => (choice.label ?? choice.value).length))
81
+ let chosen: T | null = null
82
+ await interact(
83
+ () => renderChoices(title, choices, index, width),
84
+ (key) => {
85
+ if (key === keys.up || key === "k") index = (index + choices.length - 1) % choices.length
86
+ else if (key === keys.down || key === "j") index = (index + 1) % choices.length
87
+ else if (keys.enter.includes(key)) {
88
+ chosen = choices[index].value
89
+ return true
90
+ } else if (keys.escape.includes(key) || key === "q") return true
91
+ },
92
+ )
93
+ return chosen
94
+ }
95
+
96
+ export async function input(title: string, placeholder: string, initial = ""): Promise<string | null> {
97
+ let value = initial
98
+ let result: string | null = null
99
+ const render = () => [` ${c.bold(title)}`, ` ${c.cyan("›")} ${value || c.dim(placeholder)}`, "", ` ${c.dim("enter save · esc cancel")}`]
100
+ await interact(
101
+ render,
102
+ (key) => {
103
+ if (keys.enter.includes(key)) {
104
+ result = value
105
+ return true
106
+ }
107
+ if (keys.escape.includes(key)) return true
108
+ if (keys.backspace.includes(key)) value = value.slice(0, -1)
109
+ else if (printable(key)) value += key
110
+ },
111
+ false,
112
+ )
113
+ return result
114
+ }