@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.
- package/AGENTS.md +31 -0
- package/LICENSE +201 -0
- package/README.md +314 -0
- package/bin/purrx.js +352 -0
- package/package.json +64 -0
- package/src/api/client.js +121 -0
- package/src/api/models.js +57 -0
- package/src/auth/login.js +199 -0
- package/src/auth/pkce.js +34 -0
- package/src/auth/tokens.js +186 -0
- package/src/config.js +57 -0
- package/src/core/agent.js +197 -0
- package/src/core/approval.js +101 -0
- package/src/core/compact.js +207 -0
- package/src/core/context.js +245 -0
- package/src/core/session.js +101 -0
- package/src/index.js +24 -0
- package/src/platform.js +94 -0
- package/src/tools/builtin.js +476 -0
- package/src/tools/mcp.js +223 -0
- package/src/tools/registry.js +62 -0
- package/src/types.js +68 -0
- package/src/ui/render.js +47 -0
- package/src/ui/theme.js +114 -0
- package/src/ui/tui.js +317 -0
|
@@ -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";
|