@mewbleh/purrx 1.0.8

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,101 @@
1
+ import readline from "node:readline";
2
+ import { READ_ONLY_TOOLS } from "../tools/builtin.js";
3
+
4
+ // Tools that only read state are always safe to run without approval.
5
+ const SAFE_TOOLS = READ_ONLY_TOOLS;
6
+
7
+ // Tools considered "edits" (auto-approved under the auto-edit policy).
8
+ const EDIT_TOOLS = new Set(["write_file", "edit_file", "remember"]);
9
+
10
+ // Approval policies:
11
+ // "suggest" -> ask before every mutating action (default, safest)
12
+ // "auto-edit" -> auto-approve file writes/edits, still ask before commands
13
+ // "full-auto" -> never ask (use with care)
14
+ export const POLICIES = ["suggest", "auto-edit", "full-auto"];
15
+
16
+ /**
17
+ * @typedef {(toolName: string, description: string) => Promise<"approve"|"always"|"reject">} Prompter
18
+ */
19
+
20
+ /**
21
+ * @param {import("../types.js").ApprovalPolicy} [policy]
22
+ */
23
+ export function createApprovalManager(policy = "suggest") {
24
+ // Remembers "always allow" decisions for the current session.
25
+ const alwaysAllowed = new Set();
26
+
27
+ /** @type {Prompter} */
28
+ let prompter = defaultPrompter;
29
+
30
+ /** @param {string} toolName */
31
+ function needsApproval(toolName) {
32
+ if (SAFE_TOOLS.has(toolName)) return false;
33
+ if (policy === "full-auto") return false;
34
+ if (policy === "auto-edit" && EDIT_TOOLS.has(toolName)) return false;
35
+ if (alwaysAllowed.has(toolName)) return false;
36
+ return true;
37
+ }
38
+
39
+ /**
40
+ * Ask the user to approve a tool call.
41
+ * @param {string} toolName
42
+ * @param {string} description
43
+ * @returns {Promise<"approve"|"reject">}
44
+ */
45
+ async function requestApproval(toolName, description) {
46
+ if (!needsApproval(toolName)) return "approve";
47
+ const decision = await prompter(toolName, description);
48
+ if (decision === "always") {
49
+ alwaysAllowed.add(toolName);
50
+ return "approve";
51
+ }
52
+ return decision === "approve" ? "approve" : "reject";
53
+ }
54
+
55
+ /** @param {Prompter} fn */
56
+ function setPrompter(fn) {
57
+ prompter = fn;
58
+ }
59
+
60
+ function getPrompter() {
61
+ return prompter;
62
+ }
63
+
64
+ return {
65
+ needsApproval,
66
+ requestApproval,
67
+ setPrompter,
68
+ getPrompter,
69
+ get policy() {
70
+ return policy;
71
+ },
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Fallback readline-based prompter used when no richer UI is attached.
77
+ * @type {Prompter}
78
+ */
79
+ async function defaultPrompter(toolName, description) {
80
+ const answer = await ask(
81
+ `\nApprove ${description}\n [y]es / [n]o / [a]lways allow ${toolName}: `
82
+ );
83
+ const choice = answer.trim().toLowerCase();
84
+ if (choice === "a" || choice === "always") return "always";
85
+ if (choice === "y" || choice === "yes" || choice === "") return "approve";
86
+ return "reject";
87
+ }
88
+
89
+ /** @param {string} question */
90
+ function ask(question) {
91
+ return new Promise((resolve) => {
92
+ const rl = readline.createInterface({
93
+ input: process.stdin,
94
+ output: process.stdout,
95
+ });
96
+ rl.question(question, (answer) => {
97
+ rl.close();
98
+ resolve(answer);
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,207 @@
1
+ import { streamResponse } from "../api/client.js";
2
+ import { CONTEXT_LIMIT, COMPACT_THRESHOLD } from "../config.js";
3
+
4
+ // Automatic context-window compaction.
5
+ //
6
+ // As a conversation grows, the history array (Responses API input items) can
7
+ // approach the model's context limit. When it does, we summarize the older
8
+ // portion of the conversation into a single compact "summary" message and keep
9
+ // the most recent turns verbatim. This preserves continuity while freeing
10
+ // space, similar to how Codex/Claude handle long sessions.
11
+
12
+ // Rough token estimate: ~4 chars per token. Good enough for a trigger
13
+ // heuristic; the real count from the API (when available) takes precedence.
14
+ /**
15
+ * @param {import("../types.js").HistoryItem[]} history
16
+ * @returns {number}
17
+ */
18
+ export function estimateTokens(history) {
19
+ let chars = 0;
20
+ for (const item of history) {
21
+ chars += approxItemChars(item);
22
+ }
23
+ return Math.ceil(chars / 4);
24
+ }
25
+
26
+ /**
27
+ * @param {import("../types.js").HistoryItem} item
28
+ * @returns {number}
29
+ */
30
+ function approxItemChars(item) {
31
+ let n = 0;
32
+ if (typeof item.output === "string") n += item.output.length;
33
+ if (typeof item.arguments === "string") n += item.arguments.length;
34
+ if (Array.isArray(item.content)) {
35
+ for (const part of item.content) {
36
+ if (part && typeof part.text === "string") n += part.text.length;
37
+ }
38
+ }
39
+ return n;
40
+ }
41
+
42
+ /**
43
+ * Decide whether the history should be compacted.
44
+ * @param {import("../types.js").HistoryItem[]} history
45
+ * @param {number} [usedTokens] real token count from the last API response
46
+ * @returns {boolean}
47
+ */
48
+ export function shouldCompact(history, usedTokens) {
49
+ const tokens = usedTokens && usedTokens > 0 ? usedTokens : estimateTokens(history);
50
+ return tokens >= CONTEXT_LIMIT * COMPACT_THRESHOLD;
51
+ }
52
+
53
+ // How many of the most recent history items to always keep verbatim.
54
+ const KEEP_RECENT = 6;
55
+
56
+ // A boundary must not split a function_call from its function_call_output, so
57
+ // we find a safe cut index at or before the desired keep point.
58
+ /**
59
+ * @param {import("../types.js").HistoryItem[]} history
60
+ * @param {number} desiredKeep
61
+ * @returns {number} index where the "recent" tail begins
62
+ */
63
+ function safeCutIndex(history, desiredKeep) {
64
+ let cut = Math.max(0, history.length - desiredKeep);
65
+ // If the item just before the cut is a function_call whose output is at/after
66
+ // the cut, move the cut earlier so the pair stays together on one side.
67
+ // Simpler: ensure the first kept item is not a dangling function_call_output.
68
+ while (cut < history.length && history[cut]?.type === "function_call_output") {
69
+ cut += 1;
70
+ }
71
+ return cut;
72
+ }
73
+
74
+ /**
75
+ * Produce a plain-text transcript of history items for summarization.
76
+ * @param {import("../types.js").HistoryItem[]} items
77
+ * @returns {string}
78
+ */
79
+ function transcript(items) {
80
+ const lines = [];
81
+ for (const item of items) {
82
+ if (item.type === "message") {
83
+ const text = (item.content || [])
84
+ .map((p) => p.text || "")
85
+ .join("")
86
+ .trim();
87
+ if (text) lines.push(`${item.role || "assistant"}: ${text}`);
88
+ } else if (item.type === "function_call") {
89
+ lines.push(`tool_call ${item.name}(${item.arguments || ""})`);
90
+ } else if (item.type === "function_call_output") {
91
+ const out = String(item.output || "").slice(0, 500);
92
+ lines.push(`tool_result: ${out}`);
93
+ }
94
+ }
95
+ return lines.join("\n");
96
+ }
97
+
98
+ /**
99
+ * Compact the history in place if it is over threshold. Summarizes the older
100
+ * portion via the model and replaces it with one summary message, keeping the
101
+ * recent tail verbatim.
102
+ *
103
+ * @param {Object} opts
104
+ * @param {import("../types.js").AuthInfo} opts.authInfo
105
+ * @param {import("../types.js").HistoryItem[]} opts.history mutated in place
106
+ * @param {string} opts.model
107
+ * @param {number} [opts.usedTokens]
108
+ * @param {(msg: string) => void} [opts.onInfo]
109
+ * @returns {Promise<boolean>} true if compaction happened
110
+ */
111
+ export async function maybeCompact({ authInfo, history, model, usedTokens, onInfo }) {
112
+ if (!shouldCompact(history, usedTokens)) return false;
113
+ // Need enough material to be worth compacting.
114
+ if (history.length <= KEEP_RECENT + 2) return false;
115
+
116
+ const cut = safeCutIndex(history, KEEP_RECENT);
117
+ if (cut <= 1) return false;
118
+
119
+ const older = history.slice(0, cut);
120
+ const recent = history.slice(cut);
121
+
122
+ if (onInfo) onInfo("context is getting long; compacting older history...");
123
+
124
+ const summaryText = await summarize(authInfo, model, older).catch(() => null);
125
+ if (!summaryText) {
126
+ if (onInfo) onInfo("compaction skipped (summary failed).");
127
+ return false;
128
+ }
129
+
130
+ /** @type {import("../types.js").HistoryItem} */
131
+ const summaryItem = {
132
+ type: "message",
133
+ role: "user",
134
+ content: [
135
+ {
136
+ type: "input_text",
137
+ text:
138
+ "[Conversation summary of earlier turns, auto-generated to save context]\n" +
139
+ summaryText,
140
+ },
141
+ ],
142
+ };
143
+
144
+ // Replace history contents in place.
145
+ history.length = 0;
146
+ history.push(summaryItem, ...recent);
147
+
148
+ if (onInfo) onInfo(`compacted ${older.length} items into a summary.`);
149
+ return true;
150
+ }
151
+
152
+ /**
153
+ * Ask the model to summarize a transcript.
154
+ * @param {import("../types.js").AuthInfo} authInfo
155
+ * @param {string} model
156
+ * @param {import("../types.js").HistoryItem[]} older
157
+ * @returns {Promise<string|null>}
158
+ */
159
+ async function summarize(authInfo, model, older) {
160
+ const text = transcript(older);
161
+ if (!text.trim()) return null;
162
+
163
+ const input = [
164
+ {
165
+ type: "message",
166
+ role: "user",
167
+ content: [
168
+ {
169
+ type: "input_text",
170
+ text:
171
+ "Summarize the following coding-assistant conversation so it can " +
172
+ "replace the original turns without losing important context. " +
173
+ "Preserve: the user's goals and decisions, files created or edited " +
174
+ "and how, key facts learned, unresolved tasks, and any preferences. " +
175
+ "Use terse bullet points. Do not invent details.\n\n" +
176
+ text,
177
+ },
178
+ ],
179
+ },
180
+ ];
181
+
182
+ let out = "";
183
+ const final = await streamResponse(
184
+ {
185
+ authInfo,
186
+ model,
187
+ input,
188
+ instructions:
189
+ "You are a precise summarizer that compresses conversation history for " +
190
+ "a coding agent. Output only the summary as bullet points.",
191
+ },
192
+ {
193
+ onText: (delta) => {
194
+ out += delta;
195
+ },
196
+ }
197
+ );
198
+
199
+ if (out.trim()) return out.trim();
200
+ // Fall back to assembling from the final response output if no text deltas.
201
+ const fromOutput = (final?.output || [])
202
+ .flatMap((i) => i.content || [])
203
+ .map((p) => p.text || "")
204
+ .join("")
205
+ .trim();
206
+ return fromOutput || null;
207
+ }
@@ -0,0 +1,245 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { memoriesDir, skillsDir } from "../config.js";
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // AGENTS.md - project + user instructions
7
+ // ---------------------------------------------------------------------------
8
+ //
9
+ // We collect AGENTS.md files from (in order):
10
+ // 1. the user's global config dir
11
+ // 2. each directory from the filesystem root down to cwd (so nested project
12
+ // instructions override broader ones)
13
+ // The concatenation is injected into the system prompt.
14
+
15
+ /**
16
+ * Walk from `cwd` up to the root, returning AGENTS.md contents ordered from
17
+ * outermost (root) to innermost (cwd).
18
+ * @param {string} cwd
19
+ * @returns {{ path: string, content: string }[]}
20
+ */
21
+ export function findAgentsFiles(cwd) {
22
+ /** @type {{ path: string, content: string }[]} */
23
+ const found = [];
24
+ let dir = path.resolve(cwd);
25
+ const chain = [];
26
+ while (true) {
27
+ chain.push(dir);
28
+ const parent = path.dirname(dir);
29
+ if (parent === dir) break;
30
+ dir = parent;
31
+ }
32
+ // Root-first so closer files come later and take precedence.
33
+ for (const d of chain.reverse()) {
34
+ for (const name of ["AGENTS.md", "agents.md"]) {
35
+ const p = path.join(d, name);
36
+ try {
37
+ const content = fs.readFileSync(p, "utf8").trim();
38
+ if (content) found.push({ path: p, content });
39
+ break;
40
+ } catch {
41
+ // not here
42
+ }
43
+ }
44
+ }
45
+ return found;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // memories.d - persistent cross-session memory
50
+ // ---------------------------------------------------------------------------
51
+ //
52
+ // Every *.md file in <home>/memories.d is loaded and injected. The agent can
53
+ // write new memories via the `remember` tool (see tools/builtin.js).
54
+
55
+ /**
56
+ * @returns {{ name: string, content: string }[]}
57
+ */
58
+ export function loadMemories() {
59
+ const dir = memoriesDir();
60
+ /** @type {{ name: string, content: string }[]} */
61
+ const out = [];
62
+ let entries;
63
+ try {
64
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
65
+ } catch {
66
+ return out;
67
+ }
68
+ for (const f of entries.sort()) {
69
+ try {
70
+ const content = fs.readFileSync(path.join(dir, f), "utf8").trim();
71
+ if (content) out.push({ name: f.replace(/\.md$/, ""), content });
72
+ } catch {
73
+ // skip
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /**
80
+ * Append a memory entry to a memory file (defaults to "notes").
81
+ * @param {string} text
82
+ * @param {string} [topic]
83
+ * @returns {string} the file written to
84
+ */
85
+ export function saveMemory(text, topic = "notes") {
86
+ const dir = memoriesDir();
87
+ fs.mkdirSync(dir, { recursive: true });
88
+ const safe = topic.replace(/[^a-z0-9_-]/gi, "-").toLowerCase() || "notes";
89
+ const file = path.join(dir, `${safe}.md`);
90
+ const stamp = new Date().toISOString().slice(0, 10);
91
+ const entry = `- (${stamp}) ${text.trim()}\n`;
92
+ fs.appendFileSync(file, entry, "utf8");
93
+ return file;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Skills - reusable playbooks the model can opt into
98
+ // ---------------------------------------------------------------------------
99
+ //
100
+ // A skill lives at <skillsDir>/<name>/SKILL.md or <skillsDir>/<name>.md, and
101
+ // may also be defined per-project in ./.purrx/skills. Each SKILL.md starts with
102
+ // an optional frontmatter-like header:
103
+ //
104
+ // # Title
105
+ // description: one line shown in the skill list
106
+ //
107
+ // We surface the name + description to the model always, and load the full body
108
+ // only when the model invokes the `use_skill` tool. This keeps the prompt lean.
109
+
110
+ /**
111
+ * @param {string} cwd
112
+ * @returns {string[]} candidate skill directories (global + project)
113
+ */
114
+ function skillSearchDirs(cwd) {
115
+ return [skillsDir(), path.join(cwd, ".purrx", "skills")];
116
+ }
117
+
118
+ /**
119
+ * @typedef {Object} SkillMeta
120
+ * @property {string} name
121
+ * @property {string} description
122
+ * @property {string} file
123
+ */
124
+
125
+ /**
126
+ * List available skills (metadata only).
127
+ * @param {string} cwd
128
+ * @returns {SkillMeta[]}
129
+ */
130
+ export function listSkills(cwd) {
131
+ /** @type {SkillMeta[]} */
132
+ const skills = [];
133
+ const seen = new Set();
134
+ for (const base of skillSearchDirs(cwd)) {
135
+ let entries;
136
+ try {
137
+ entries = fs.readdirSync(base, { withFileTypes: true });
138
+ } catch {
139
+ continue;
140
+ }
141
+ for (const e of entries) {
142
+ let name = null;
143
+ let file = null;
144
+ if (e.isDirectory()) {
145
+ const candidate = path.join(base, e.name, "SKILL.md");
146
+ if (fs.existsSync(candidate)) {
147
+ name = e.name;
148
+ file = candidate;
149
+ }
150
+ } else if (e.isFile() && e.name.endsWith(".md") && e.name !== "SKILL.md") {
151
+ name = e.name.replace(/\.md$/, "");
152
+ file = path.join(base, e.name);
153
+ }
154
+ if (!name || !file || seen.has(name)) continue;
155
+ seen.add(name);
156
+ skills.push({ name, description: readSkillDescription(file), file });
157
+ }
158
+ }
159
+ return skills;
160
+ }
161
+
162
+ /**
163
+ * Read the full body of a named skill.
164
+ * @param {string} cwd
165
+ * @param {string} name
166
+ * @returns {string|null}
167
+ */
168
+ export function readSkill(cwd, name) {
169
+ const skill = listSkills(cwd).find((s) => s.name === name);
170
+ if (!skill) return null;
171
+ try {
172
+ return fs.readFileSync(skill.file, "utf8");
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Pull a one-line description from a SKILL.md: a `description:` line if present,
180
+ * else the first non-heading line.
181
+ * @param {string} file
182
+ * @returns {string}
183
+ */
184
+ function readSkillDescription(file) {
185
+ let text;
186
+ try {
187
+ text = fs.readFileSync(file, "utf8");
188
+ } catch {
189
+ return "";
190
+ }
191
+ const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
192
+ for (const line of lines) {
193
+ const m = line.match(/^\s*description:\s*(.+)$/i);
194
+ if (m) return m[1].trim();
195
+ }
196
+ for (const line of lines) {
197
+ const t = line.trim();
198
+ if (t && !t.startsWith("#")) return t.slice(0, 100);
199
+ }
200
+ return "";
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Compose everything into a system-prompt addendum.
205
+ // ---------------------------------------------------------------------------
206
+
207
+ /**
208
+ * Build the extra context block (AGENTS.md + memories + skill index) appended
209
+ * to the base system instructions.
210
+ * @param {string} cwd
211
+ * @returns {string}
212
+ */
213
+ export function buildContextBlock(cwd) {
214
+ const parts = [];
215
+
216
+ const agents = findAgentsFiles(cwd);
217
+ if (agents.length) {
218
+ const joined = agents
219
+ .map((a) => `<!-- ${a.path} -->\n${a.content}`)
220
+ .join("\n\n");
221
+ parts.push(`# Project instructions (AGENTS.md)\n${joined}`);
222
+ }
223
+
224
+ const memories = loadMemories();
225
+ if (memories.length) {
226
+ const joined = memories
227
+ .map((m) => `## ${m.name}\n${m.content}`)
228
+ .join("\n\n");
229
+ parts.push(
230
+ `# Memories (persistent notes from past sessions)\n${joined}`
231
+ );
232
+ }
233
+
234
+ const skills = listSkills(cwd);
235
+ if (skills.length) {
236
+ const list = skills
237
+ .map((s) => `- ${s.name}: ${s.description || "(no description)"}`)
238
+ .join("\n");
239
+ parts.push(
240
+ `# Available skills\nCall the use_skill tool with a skill name to load its full playbook before doing the task.\n${list}`
241
+ );
242
+ }
243
+
244
+ return parts.join("\n\n");
245
+ }
@@ -0,0 +1,101 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { sessionsDir } from "../config.js";
5
+
6
+ // A session captures the conversation history (Responses API input items) plus
7
+ // some metadata so it can be resumed later.
8
+
9
+ export function newSessionId() {
10
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
11
+ const rand = crypto.randomBytes(3).toString("hex");
12
+ return `${ts}_${rand}`;
13
+ }
14
+
15
+ function sessionPath(id) {
16
+ return path.join(sessionsDir(), `${id}.json`);
17
+ }
18
+
19
+ export function saveSession(session) {
20
+ const dir = sessionsDir();
21
+ fs.mkdirSync(dir, { recursive: true });
22
+ session.updated_at = new Date().toISOString();
23
+ fs.writeFileSync(sessionPath(session.id), JSON.stringify(session, null, 2));
24
+ return sessionPath(session.id);
25
+ }
26
+
27
+ export function loadSession(id) {
28
+ try {
29
+ const raw = fs.readFileSync(sessionPath(id), "utf8");
30
+ return JSON.parse(raw);
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export function createSession(meta = {}) {
37
+ const id = newSessionId();
38
+ return {
39
+ id,
40
+ created_at: new Date().toISOString(),
41
+ updated_at: new Date().toISOString(),
42
+ cwd: meta.cwd || process.cwd(),
43
+ model: meta.model || null,
44
+ history: [],
45
+ };
46
+ }
47
+
48
+ // Returns the most recently updated session, or null.
49
+ export function latestSession() {
50
+ const sessions = listSessions();
51
+ return sessions.length ? loadSession(sessions[0].id) : null;
52
+ }
53
+
54
+ // Lists sessions sorted newest-first with a short summary.
55
+ export function listSessions() {
56
+ const dir = sessionsDir();
57
+ let files;
58
+ try {
59
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
60
+ } catch {
61
+ return [];
62
+ }
63
+ const out = [];
64
+ for (const f of files) {
65
+ try {
66
+ const data = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"));
67
+ out.push({
68
+ id: data.id,
69
+ updated_at: data.updated_at || data.created_at,
70
+ cwd: data.cwd,
71
+ turns: (data.history || []).filter(
72
+ (i) => i.type === "message" && i.role === "user"
73
+ ).length,
74
+ preview: firstUserText(data.history),
75
+ });
76
+ } catch {
77
+ // skip corrupt files
78
+ }
79
+ }
80
+ out.sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1));
81
+ return out;
82
+ }
83
+
84
+ export function deleteSession(id) {
85
+ try {
86
+ fs.rmSync(sessionPath(id));
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ function firstUserText(history = []) {
94
+ const first = history.find(
95
+ (i) => i.type === "message" && i.role === "user"
96
+ );
97
+ if (!first) return "";
98
+ const part = (first.content || []).find((c) => c.type === "input_text");
99
+ const text = part?.text || "";
100
+ return text.length > 60 ? text.slice(0, 60) + "..." : text;
101
+ }
package/src/index.js ADDED
@@ -0,0 +1,24 @@
1
+ // Public programmatic API for purrx. The CLI lives in bin/purrx.js.
2
+ export { runTurn } from "./core/agent.js";
3
+ export { ToolRegistry } from "./tools/registry.js";
4
+ export { createApprovalManager } from "./core/approval.js";
5
+ export { streamResponse } from "./api/client.js";
6
+ export {
7
+ ensureFreshAuth,
8
+ resolveAuthMode,
9
+ readAuth,
10
+ writeAuth,
11
+ } from "./auth/tokens.js";
12
+ export { loginWithChatGPT, loginWithApiKey } from "./auth/login.js";
13
+ export { listModels, resolveModel } from "./api/models.js";
14
+ export { detectPlatform } from "./platform.js";
15
+ export { startTui } from "./ui/tui.js";
16
+ export {
17
+ buildContextBlock,
18
+ listSkills,
19
+ readSkill,
20
+ loadMemories,
21
+ saveMemory,
22
+ findAgentsFiles,
23
+ } from "./core/context.js";
24
+ export { maybeCompact, shouldCompact, estimateTokens } from "./core/compact.js";