faberwright 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Terminal markdown renderer — full inline and block set.
3
+ * Inline: **bold**, *italic* or _italic_, ~~strike~~, `code`, [links](url) as
4
+ * real OSC 8 hyperlinks. Block: headers, rules, blockquotes, bullet restyle,
5
+ * box-drawn tables, fenced code with SYNTAX HIGHLIGHTING (small built-in
6
+ * tokenizer, zero deps). A stateful LineRenderer powers both whole-text
7
+ * rendering and live line-buffered streaming.
8
+ */
9
+ const BOLD = "\x1b[1m", NOBOLD = "\x1b[22m";
10
+ const ITAL = "\x1b[3m", NOITAL = "\x1b[23m";
11
+ const STRIKE = "\x1b[9m", NOSTRIKE = "\x1b[29m";
12
+ const UNDER = "\x1b[4m", NOUNDER = "\x1b[24m";
13
+ const DIM = "\x1b[2m", NODIM = "\x1b[22m";
14
+ const CYAN = "\x1b[36m", GREEN = "\x1b[32m", YELLOW = "\x1b[33m", MAGENTA = "\x1b[35m", RESET_FG = "\x1b[39m";
15
+ const stripCodes = (s) => s.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\]8;;[^\x07]*\x07/g, "");
16
+ function inline(s) {
17
+ s = s.replace(/\*\*([^*]+)\*\*/g, `${BOLD}$1${NOBOLD}`);
18
+ s = s.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, `$1${ITAL}$2${NOITAL}`);
19
+ s = s.replace(/(^|[\s(])_([^_\s][^_]*)_(?=[\s).,;:!?]|$)/g, `$1${ITAL}$2${NOITAL}`);
20
+ s = s.replace(/~~([^~]+)~~/g, `${STRIKE}$1${NOSTRIKE}`);
21
+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_m, text, url) => `\x1b]8;;${url}\x07${UNDER}${CYAN}${text}${RESET_FG}${NOUNDER}\x1b]8;;\x07`);
22
+ s = s.replace(/`([^`]+)`/g, `${CYAN}$1${RESET_FG}`);
23
+ return s;
24
+ }
25
+ // ------------------------------------------------------ syntax highlighting
26
+ const KEYWORDS = {
27
+ common: ["if", "else", "for", "while", "return", "break", "continue", "true", "false", "null", "new", "try", "catch", "finally", "throw", "switch", "case", "default", "in", "of", "do"],
28
+ js: ["function", "const", "let", "var", "class", "extends", "import", "export", "from", "async", "await", "this", "typeof", "interface", "type", "enum", "implements", "undefined", "yield", "static", "readonly", "public", "private", "protected"],
29
+ py: ["def", "class", "import", "from", "as", "with", "lambda", "None", "True", "False", "and", "or", "not", "is", "elif", "except", "raise", "pass", "yield", "self", "async", "await", "global", "assert", "del"],
30
+ go: ["func", "package", "import", "type", "struct", "interface", "map", "chan", "go", "defer", "select", "range", "var", "const", "nil"],
31
+ rs: ["fn", "let", "mut", "pub", "struct", "enum", "impl", "trait", "use", "mod", "match", "Some", "None", "Ok", "Err", "self", "crate", "async", "await"],
32
+ sh: ["echo", "export", "cd", "then", "fi", "done", "local", "function", "source", "exit"],
33
+ };
34
+ const kwSet = new Set([...KEYWORDS.common, ...KEYWORDS.js, ...KEYWORDS.py, ...KEYWORDS.go, ...KEYWORDS.rs, ...KEYWORDS.sh]);
35
+ /** Line-based highlighter: strings green, comments dim, numbers yellow, keywords magenta. */
36
+ export function highlightLine(line) {
37
+ let out = "";
38
+ let i = 0;
39
+ while (i < line.length) {
40
+ const ch = line[i];
41
+ // comments (rest of line)
42
+ if (ch === "#" || (ch === "/" && line[i + 1] === "/")) {
43
+ out += `${DIM}${line.slice(i)}${NODIM}`;
44
+ return out;
45
+ }
46
+ // strings
47
+ if (ch === '"' || ch === "'" || ch === "`") {
48
+ let j = i + 1;
49
+ while (j < line.length && line[j] !== ch)
50
+ j += line[j] === "\\" ? 2 : 1;
51
+ out += `${GREEN}${line.slice(i, Math.min(j + 1, line.length))}${RESET_FG}`;
52
+ i = j + 1;
53
+ continue;
54
+ }
55
+ // words: keywords / numbers / identifiers
56
+ if (/[A-Za-z0-9_]/.test(ch)) {
57
+ let j = i;
58
+ while (j < line.length && /[A-Za-z0-9_]/.test(line[j]))
59
+ j++;
60
+ const word = line.slice(i, j);
61
+ if (kwSet.has(word))
62
+ out += `${MAGENTA}${word}${RESET_FG}`;
63
+ else if (/^\d[\d_.]*$/.test(word))
64
+ out += `${YELLOW}${word}${RESET_FG}`;
65
+ else
66
+ out += word;
67
+ i = j;
68
+ continue;
69
+ }
70
+ out += ch;
71
+ i++;
72
+ }
73
+ return out;
74
+ }
75
+ // ------------------------------------------------------------------ tables
76
+ function renderTable(rows) {
77
+ const CAP = 40;
78
+ const parsed = [];
79
+ let headerRows = 1;
80
+ for (const row of rows) {
81
+ const cells = row.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
82
+ if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === "")) {
83
+ headerRows = parsed.length;
84
+ continue;
85
+ }
86
+ parsed.push(cells);
87
+ }
88
+ if (!parsed.length)
89
+ return rows;
90
+ const nCols = Math.max(...parsed.map((r) => r.length));
91
+ const widths = Array.from({ length: nCols }, (_, i) => Math.min(CAP, Math.max(1, ...parsed.map((r) => stripCodes(inline(r[i] ?? "")).length))));
92
+ const cell = (raw, i, bold) => {
93
+ let text = inline(raw ?? "");
94
+ let plain = stripCodes(text);
95
+ if (plain.length > widths[i]) {
96
+ text = plain.slice(0, widths[i] - 1) + "…";
97
+ plain = text;
98
+ }
99
+ const pad = " ".repeat(widths[i] - plain.length);
100
+ return bold ? `${BOLD}${text}${NOBOLD}${pad}` : `${text}${pad}`;
101
+ };
102
+ const rule = (l, m, r) => `${DIM}${l}${widths.map((w) => "─".repeat(w + 2)).join(m)}${r}${NODIM}`;
103
+ const out = [rule("┌", "┬", "┐")];
104
+ parsed.forEach((r, idx) => {
105
+ out.push(`${DIM}│${NODIM} ` +
106
+ widths.map((_, i) => cell(r[i] ?? "", i, idx < headerRows)).join(` ${DIM}│${NODIM} `) +
107
+ ` ${DIM}│${NODIM}`);
108
+ if (idx === headerRows - 1 && parsed.length > headerRows)
109
+ out.push(rule("├", "┼", "┤"));
110
+ });
111
+ out.push(rule("└", "┴", "┘"));
112
+ return out;
113
+ }
114
+ // ------------------------------------------------------------ line renderer
115
+ /**
116
+ * Stateful renderer: feed complete lines, get rendered lines. Tracks fence
117
+ * state across lines; buffers table rows until the table ends. Used for both
118
+ * whole-document rendering and live streaming.
119
+ */
120
+ export class LineRenderer {
121
+ inFence = false;
122
+ tableBuf = [];
123
+ renderLine(line) {
124
+ if (/^\s*```/.test(line)) {
125
+ const flushed = this.flushTable();
126
+ this.inFence = !this.inFence;
127
+ return flushed; // fence markers dropped
128
+ }
129
+ if (this.inFence)
130
+ return [...this.flushTable(), ` ${highlightLine(line)}`];
131
+ if (/^\s*\|.*\|\s*$/.test(line)) {
132
+ this.tableBuf.push(line);
133
+ return [];
134
+ }
135
+ const pre = this.flushTable();
136
+ const header = /^(#{1,4})\s+(.*)$/.exec(line);
137
+ if (header)
138
+ return [...pre, `${BOLD}${inline(header[2])}${NOBOLD}`];
139
+ if (/^\s*(---|___|\*\*\*)\s*$/.test(line))
140
+ return [...pre, `${DIM}${"─".repeat(30)}${NODIM}`];
141
+ const quote = /^\s*>\s?(.*)$/.exec(line);
142
+ if (quote)
143
+ return [...pre, `${DIM}▌ ${NODIM}${ITAL}${inline(quote[1])}${NOITAL}`];
144
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line);
145
+ if (bullet)
146
+ return [...pre, `${bullet[1]}${CYAN}•${RESET_FG} ${inline(bullet[2])}`];
147
+ return [...pre, inline(line)];
148
+ }
149
+ flushTable() {
150
+ if (!this.tableBuf.length)
151
+ return [];
152
+ const t = renderTable(this.tableBuf);
153
+ this.tableBuf = [];
154
+ return t;
155
+ }
156
+ end() { return this.flushTable(); }
157
+ }
158
+ export function renderMarkdown(text) {
159
+ const lr = new LineRenderer();
160
+ const out = [];
161
+ for (const line of text.split("\n"))
162
+ out.push(...lr.renderLine(line));
163
+ out.push(...lr.end());
164
+ return out.join("\n");
165
+ }
166
+ /**
167
+ * Streaming renderer: feed arbitrary deltas; emits rendered COMPLETE lines
168
+ * as they finish (line-buffered — inline markers never split across a line).
169
+ * flush() renders any trailing partial line at end of turn.
170
+ */
171
+ export class StreamRenderer {
172
+ partial = "";
173
+ lr = new LineRenderer();
174
+ feed(delta) {
175
+ this.partial += delta;
176
+ let out = "";
177
+ let nl;
178
+ while ((nl = this.partial.indexOf("\n")) !== -1) {
179
+ const line = this.partial.slice(0, nl);
180
+ this.partial = this.partial.slice(nl + 1);
181
+ for (const r of this.lr.renderLine(line))
182
+ out += r + "\n";
183
+ }
184
+ return out;
185
+ }
186
+ flush() {
187
+ let out = "";
188
+ if (this.partial) {
189
+ for (const r of this.lr.renderLine(this.partial))
190
+ out += r + "\n";
191
+ this.partial = "";
192
+ }
193
+ for (const r of this.lr.end())
194
+ out += r + "\n";
195
+ return out;
196
+ }
197
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Long-term memory (persistent, per-project) at .faber/memory.db.
3
+ * - memories: fact|decision|preference|gotcha, FTS5 recall (LIKE fallback),
4
+ * archive/unarchive/prune lifecycle so growth never poisons recall.
5
+ * - file_notes: one evolving summary per file.
6
+ * Built on node:sqlite -> zero native dependencies.
7
+ */
8
+ import { DatabaseSync } from "node:sqlite";
9
+ const VALID_KINDS = new Set(["fact", "decision", "preference", "gotcha"]);
10
+ export class LongTermMemory {
11
+ db;
12
+ fts = true;
13
+ constructor(dbPath) {
14
+ this.db = new DatabaseSync(dbPath);
15
+ this.db.exec(`CREATE TABLE IF NOT EXISTS memories (
16
+ id INTEGER PRIMARY KEY, kind TEXT NOT NULL, content TEXT NOT NULL,
17
+ tags TEXT DEFAULT '', created REAL NOT NULL, archived INTEGER NOT NULL DEFAULT 0)`);
18
+ try {
19
+ this.db.exec("ALTER TABLE memories ADD COLUMN archived INTEGER NOT NULL DEFAULT 0");
20
+ }
21
+ catch { /* column exists */ }
22
+ this.db.exec(`CREATE TABLE IF NOT EXISTS file_notes (
23
+ path TEXT PRIMARY KEY, summary TEXT NOT NULL, updated REAL NOT NULL)`);
24
+ try {
25
+ this.db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
26
+ USING fts5(content, tags, content='memories', content_rowid='id')`);
27
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
28
+ INSERT INTO memories_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags); END`);
29
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
30
+ INSERT INTO memories_fts(memories_fts, rowid, content, tags)
31
+ VALUES ('delete', old.id, old.content, old.tags); END`);
32
+ }
33
+ catch {
34
+ this.fts = false;
35
+ }
36
+ }
37
+ remember(kind, content, tags = "") {
38
+ const k = VALID_KINDS.has(kind) ? kind : "fact";
39
+ const res = this.db.prepare("INSERT INTO memories (kind, content, tags, created) VALUES (?, ?, ?, ?)").run(k, content.trim(), tags.trim(), Date.now() / 1000);
40
+ return Number(res.lastInsertRowid);
41
+ }
42
+ forget(id) {
43
+ return this.db.prepare("DELETE FROM memories WHERE id = ?").run(id).changes > 0;
44
+ }
45
+ archive(id, archived = true) {
46
+ return this.db.prepare("UPDATE memories SET archived = ? WHERE id = ?")
47
+ .run(archived ? 1 : 0, id).changes > 0;
48
+ }
49
+ archiveOlderThan(days) {
50
+ const cutoff = Date.now() / 1000 - days * 86_400;
51
+ return Number(this.db.prepare("UPDATE memories SET archived = 1 WHERE archived = 0 AND created < ?").run(cutoff).changes);
52
+ }
53
+ recall(query, limit = 8) {
54
+ const terms = (query.match(/[A-Za-z0-9_]{3,}/g) ?? []).slice(0, 12);
55
+ let rows = [];
56
+ if (terms.length && this.fts) {
57
+ try {
58
+ rows = this.db.prepare(`SELECT m.* FROM memories_fts f JOIN memories m ON m.id = f.rowid
59
+ WHERE memories_fts MATCH ? AND m.archived = 0 ORDER BY rank LIMIT ?`).all(terms.join(" OR "), limit);
60
+ }
61
+ catch {
62
+ rows = [];
63
+ }
64
+ }
65
+ if (!rows.length && terms.length) {
66
+ const like = terms.map(() => "content LIKE ?").join(" OR ");
67
+ rows = this.db.prepare(`SELECT * FROM memories WHERE archived = 0 AND (${like}) ORDER BY created DESC LIMIT ?`).all(...terms.map((t) => `%${t}%`), limit);
68
+ }
69
+ if (!rows.length) {
70
+ rows = this.db.prepare("SELECT * FROM memories WHERE archived = 0 ORDER BY created DESC LIMIT ?").all(limit);
71
+ }
72
+ return rows;
73
+ }
74
+ allMemories(includeArchived = false) {
75
+ const where = includeArchived ? "" : "WHERE archived = 0";
76
+ return this.db.prepare(`SELECT * FROM memories ${where} ORDER BY id`).all();
77
+ }
78
+ archivedMemories() {
79
+ return this.db.prepare("SELECT * FROM memories WHERE archived = 1 ORDER BY id").all();
80
+ }
81
+ noteFile(path, summary) {
82
+ this.db.prepare(`INSERT INTO file_notes (path, summary, updated) VALUES (?, ?, ?)
83
+ ON CONFLICT(path) DO UPDATE SET summary=excluded.summary, updated=excluded.updated`).run(path, summary.trim(), Date.now() / 1000);
84
+ }
85
+ fileNotes(limit = 20) {
86
+ return this.db.prepare("SELECT * FROM file_notes ORDER BY updated DESC LIMIT ?").all(limit);
87
+ }
88
+ renderForPrompt(task) {
89
+ const parts = [];
90
+ const memories = this.recall(task);
91
+ if (memories.length) {
92
+ parts.push("Relevant long-term memory from previous sessions:");
93
+ for (const m of memories)
94
+ parts.push(`- [${m.kind}#${m.id}] ${m.content}`);
95
+ }
96
+ const notes = this.fileNotes(10);
97
+ if (notes.length) {
98
+ parts.push("", "Known files:");
99
+ for (const n of notes)
100
+ parts.push(`- ${n.path}: ${n.summary}`);
101
+ }
102
+ return parts.join("\n");
103
+ }
104
+ close() { this.db.close(); }
105
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Session persistence — closes the biggest gap from v0.1.
3
+ *
4
+ * Every message is appended to .faber/sessions/<id>.jsonl AS IT HAPPENS
5
+ * (append-only JSONL = crash-safe: a killed process loses at most the final
6
+ * partial line, which is skipped on load). `faber --resume` or /resume
7
+ * reloads the latest session's messages into short-term memory.
8
+ */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ export class SessionStore {
12
+ dir;
13
+ file;
14
+ id;
15
+ constructor(dir, resumeId) {
16
+ this.dir = dir;
17
+ this.id = resumeId ?? new Date().toISOString().replace(/[:.]/g, "-");
18
+ this.file = path.join(dir, `${this.id}.jsonl`);
19
+ }
20
+ append(message) {
21
+ try {
22
+ fs.appendFileSync(this.file, JSON.stringify(message) + "\n");
23
+ }
24
+ catch { /* persistence is best-effort; never break the task over it */ }
25
+ }
26
+ load() {
27
+ if (!fs.existsSync(this.file))
28
+ return [];
29
+ const out = [];
30
+ for (const line of fs.readFileSync(this.file, "utf8").split("\n")) {
31
+ if (!line.trim())
32
+ continue;
33
+ try {
34
+ out.push(JSON.parse(line));
35
+ }
36
+ catch { /* skip torn final line */ }
37
+ }
38
+ return out;
39
+ }
40
+ static latestId(dir) {
41
+ if (!fs.existsSync(dir))
42
+ return undefined;
43
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
44
+ const last = files.at(-1);
45
+ return last?.replace(/\.jsonl$/, "");
46
+ }
47
+ static list(dir) {
48
+ if (!fs.existsSync(dir))
49
+ return [];
50
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort().map((f) => {
51
+ const p = path.join(dir, f);
52
+ const text = fs.readFileSync(p, "utf8");
53
+ return {
54
+ id: f.replace(/\.jsonl$/, ""),
55
+ messages: text.split("\n").filter((l) => l.trim()).length,
56
+ bytes: fs.statSync(p).size,
57
+ };
58
+ });
59
+ }
60
+ /** Cheap digest of the most recent session (no LLM call): first ask + last answer. */
61
+ static lastSessionInfo(dir, excludeId) {
62
+ const files = fs.existsSync(dir)
63
+ ? fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort()
64
+ : [];
65
+ const file = files.reverse().find((f) => f.replace(/\.jsonl$/, "") !== excludeId);
66
+ if (!file)
67
+ return undefined;
68
+ const p = path.join(dir, file);
69
+ const messages = new SessionStore(dir, file.replace(/\.jsonl$/, "")).load();
70
+ if (!messages.length)
71
+ return undefined;
72
+ const textOf = (m) => m.content.filter((b) => b.type === "text").map((b) => b.text).join(" ").trim();
73
+ const firstUser = messages.find((m) => m.role === "user" && textOf(m));
74
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant" && textOf(m));
75
+ const digest = [
76
+ firstUser ? `asked: "${textOf(firstUser).slice(0, 140)}"` : "",
77
+ lastAssistant ? `ended: "${textOf(lastAssistant).slice(0, 140)}"` : "",
78
+ ].filter(Boolean).join(" — ");
79
+ return {
80
+ id: file.replace(/\.jsonl$/, ""),
81
+ messages: messages.length,
82
+ ageMs: Date.now() - fs.statSync(p).mtimeMs,
83
+ digest,
84
+ };
85
+ }
86
+ /**
87
+ * Keyword search across past session transcripts. Scores each message by
88
+ * term overlap (rarer/longer terms weigh more), returns the best snippets
89
+ * with surrounding context. Zero dependencies; embeddings can replace the
90
+ * scorer later behind the same signature.
91
+ */
92
+ static search(dir, query, limit = 5, excludeId) {
93
+ const STOP = new Set([
94
+ "the", "and", "for", "you", "did", "what", "when", "where", "who", "how",
95
+ "about", "with", "that", "this", "was", "were", "our", "your", "have",
96
+ "has", "had", "can", "could", "should", "would", "tell", "last", "time",
97
+ "conversation", "session", "discuss", "discussed", "talk", "talked",
98
+ "remember", "ask", "asked", "say", "said", "previous", "earlier",
99
+ ]);
100
+ const terms = (query.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []).filter((t) => !STOP.has(t));
101
+ if (!terms.length || !fs.existsSync(dir))
102
+ return [];
103
+ const hits = [];
104
+ for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl"))) {
105
+ const id = f.replace(/\.jsonl$/, "");
106
+ if (id === excludeId)
107
+ continue;
108
+ const when = new Date(fs.statSync(path.join(dir, f)).mtimeMs).toISOString().slice(0, 16).replace("T", " ");
109
+ for (const m of new SessionStore(dir, id).load()) {
110
+ const text = m.content
111
+ .map((b) => b.type === "text" ? b.text : b.type === "tool_result" ? String(b.content) : "")
112
+ .join(" ");
113
+ const lower = text.toLowerCase();
114
+ let score = 0;
115
+ for (const t of terms) {
116
+ if (lower.includes(t))
117
+ score += Math.min(t.length, 10); // longer terms weigh more
118
+ }
119
+ if (score > 0) {
120
+ // snippet centered on the first matching term
121
+ const idx = Math.max(0, lower.indexOf(terms.find((t) => lower.includes(t))) - 80);
122
+ hits.push({ session: id, when, role: m.role, snippet: text.slice(idx, idx + 300).trim(), score });
123
+ }
124
+ }
125
+ }
126
+ return hits.sort((a, b) => b.score - a.score).slice(0, limit);
127
+ }
128
+ }
@@ -0,0 +1,56 @@
1
+ export const SUMMARY_MARKER = "[COMPRESSED HISTORY]";
2
+ export function estimateTokens(obj) {
3
+ try {
4
+ return Math.max(1, JSON.stringify(obj).length >> 2);
5
+ }
6
+ catch {
7
+ return Math.max(1, String(obj).length >> 2);
8
+ }
9
+ }
10
+ export class ShortTermMemory {
11
+ tokenBudget;
12
+ keepRecent;
13
+ messages = [];
14
+ constructor(tokenBudget = 60_000, keepRecent = 12) {
15
+ this.tokenBudget = tokenBudget;
16
+ this.keepRecent = keepRecent;
17
+ }
18
+ add(m) { this.messages.push(m); }
19
+ clear() { this.messages = []; }
20
+ tokens() { return estimateTokens(this.messages); }
21
+ async maybeCompact(llm, force = false) {
22
+ if (!force && this.tokens() <= this.tokenBudget)
23
+ return false;
24
+ if (this.messages.length <= this.keepRecent)
25
+ return false;
26
+ let cut = this.messages.length - this.keepRecent;
27
+ while (cut > 0 && this.messages[cut].content[0]?.type === "tool_result")
28
+ cut--;
29
+ if (cut <= 0)
30
+ return false;
31
+ const old = this.messages.slice(0, cut);
32
+ const recent = this.messages.slice(cut);
33
+ const summary = await llm.summarize(render(old), "Summarize this coding-agent conversation history. Preserve: the user's goals, " +
34
+ "decisions made, files created/modified and why, key facts learned about the " +
35
+ "codebase, and any unresolved problems. Be dense; max 400 words.");
36
+ this.messages = [
37
+ { role: "user", content: [{ type: "text", text: `${SUMMARY_MARKER} Earlier context, compressed:\n${summary}` }] },
38
+ ...recent,
39
+ ];
40
+ return true;
41
+ }
42
+ }
43
+ function render(messages, maxBlock = 1500) {
44
+ const lines = [];
45
+ for (const m of messages) {
46
+ for (const b of m.content) {
47
+ if (b.type === "text")
48
+ lines.push(`${m.role}: ${b.text.slice(0, maxBlock)}`);
49
+ else if (b.type === "tool_use")
50
+ lines.push(`${m.role} -> tool ${b.name}(${JSON.stringify(b.input).slice(0, 400)})`);
51
+ else
52
+ lines.push(`tool result: ${b.content.slice(0, maxBlock)}`);
53
+ }
54
+ }
55
+ return lines.join("\n");
56
+ }
package/dist/prompt.js ADDED
@@ -0,0 +1,80 @@
1
+ import pc from "picocolors";
2
+ let guardFn;
3
+ /** Wired by the CLI: pauses the composed-input pipe while the selector owns stdin. */
4
+ export function setSelectGuard(fn) { guardFn = fn; }
5
+ export async function select(rl, question, options, defaultIndex = 0) {
6
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
7
+ const menu = options.map((o, i) => ` ${i + 1}) ${o}`).join("\n");
8
+ const ans = (await rl.question(`${question}\n${menu}\nChoose [1-${options.length}] (default ${defaultIndex + 1}): `)).trim();
9
+ const n = Number.parseInt(ans, 10);
10
+ return Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defaultIndex;
11
+ }
12
+ console.log(pc.bold(question) + pc.dim(" ↑/↓ then Enter, or 1-9"));
13
+ return new Promise((resolve) => {
14
+ let idx = defaultIndex;
15
+ let firstRender = true;
16
+ const render = () => {
17
+ if (!firstRender)
18
+ process.stdout.write(`\x1b[${options.length}A`);
19
+ firstRender = false;
20
+ for (let i = 0; i < options.length; i++) {
21
+ process.stdout.write("\x1b[2K");
22
+ process.stdout.write((i === idx ? pc.cyan(`❯ ${options[i]}`) : pc.dim(` ${options[i]}`)) + "\n");
23
+ }
24
+ };
25
+ const stdin = process.stdin;
26
+ const wasRaw = stdin.isRaw ?? false;
27
+ guardFn?.(true);
28
+ rl.pause();
29
+ stdin.setRawMode(true);
30
+ stdin.resume();
31
+ const finish = (result) => {
32
+ stdin.removeListener("data", onData);
33
+ stdin.setRawMode(wasRaw);
34
+ guardFn?.(false);
35
+ rl.resume();
36
+ resolve(result);
37
+ };
38
+ const onData = (buf) => {
39
+ // A chunk may carry several keys (key repeat, paste, piped input) —
40
+ // tokenize into individual sequences instead of comparing whole-chunk.
41
+ const s = buf.toString();
42
+ let done = false;
43
+ for (let i = 0; i < s.length && !done;) {
44
+ let key;
45
+ if (s[i] === "\x1b" && s[i + 1] === "[") {
46
+ key = s.slice(i, i + 3);
47
+ i += 3;
48
+ }
49
+ else {
50
+ key = s[i];
51
+ i += 1;
52
+ }
53
+ if (key === "\x1b[A" || key === "k") {
54
+ idx = (idx - 1 + options.length) % options.length;
55
+ render();
56
+ }
57
+ else if (key === "\x1b[B" || key === "j") {
58
+ idx = (idx + 1) % options.length;
59
+ render();
60
+ }
61
+ else if (key >= "1" && key <= "9" && Number(key) <= options.length) {
62
+ idx = Number(key) - 1;
63
+ render();
64
+ finish(idx);
65
+ done = true;
66
+ }
67
+ else if (key === "\r" || key === "\n") {
68
+ finish(idx);
69
+ done = true;
70
+ }
71
+ else if (key === "\x03" || key === "\x1b") {
72
+ finish(-1);
73
+ done = true;
74
+ }
75
+ }
76
+ };
77
+ render();
78
+ stdin.on("data", onData);
79
+ });
80
+ }
package/dist/status.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Live status line: "✳ Working… 14s" while the agent runs (a
3
+ * heartbeat), finishing with "✳ Worked for 14s". Suspends itself while text
4
+ * is streaming so it never interleaves with model output. TTY only.
5
+ */
6
+ import pc from "picocolors";
7
+ const FRAMES = ["✳", "✢", "✳", "✻"];
8
+ export function formatElapsed(ms) {
9
+ const s = Math.round(ms / 1000);
10
+ if (s < 90)
11
+ return `${s}s`;
12
+ const m = Math.floor(s / 60);
13
+ return `${m}m ${s - m * 60}s`;
14
+ }
15
+ export class StatusLine {
16
+ out;
17
+ enabled;
18
+ timer;
19
+ startedAt = 0;
20
+ frame = 0;
21
+ suspended = false;
22
+ visible = false;
23
+ constructor(out, enabled) {
24
+ this.out = out;
25
+ this.enabled = enabled;
26
+ }
27
+ start() {
28
+ if (!this.enabled)
29
+ return;
30
+ this.startedAt = Date.now();
31
+ this.timer = setInterval(() => this.tick(), 250);
32
+ this.timer.unref?.();
33
+ }
34
+ tick() {
35
+ if (this.suspended)
36
+ return;
37
+ this.frame = (this.frame + 1) % FRAMES.length;
38
+ this.out.write(`\r\x1b[2K` +
39
+ pc.dim(`${FRAMES[this.frame]} Working… ${formatElapsed(Date.now() - this.startedAt)} (ctrl-c to cancel, type to steer)`));
40
+ this.visible = true;
41
+ }
42
+ /** Clear the line before printing real output (tool lines, prompts). */
43
+ clear() {
44
+ if (this.visible) {
45
+ this.out.write("\r\x1b[2K");
46
+ this.visible = false;
47
+ }
48
+ }
49
+ /** Streaming text owns the terminal; stop redrawing until it finishes. */
50
+ suspend() { this.clear(); this.suspended = true; }
51
+ resume() { this.suspended = false; }
52
+ /** Stop and print the final elapsed summary. */
53
+ stop() {
54
+ if (!this.enabled)
55
+ return;
56
+ if (this.timer)
57
+ clearInterval(this.timer);
58
+ this.clear();
59
+ if (this.startedAt) {
60
+ this.out.write(pc.dim(`✳ Worked for ${formatElapsed(Date.now() - this.startedAt)}\n`));
61
+ }
62
+ }
63
+ }