mini-coder 0.7.3 → 0.8.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.
- package/AGENTS.md +114 -0
- package/README.md +53 -66
- package/bin/mini-coder.ts +2 -0
- package/demo.gif +0 -0
- package/package.json +17 -20
- package/src/agent.ts +177 -255
- package/src/cli.ts +101 -0
- package/src/config.ts +150 -0
- package/src/prompt.ts +54 -207
- package/src/session.ts +124 -69
- package/src/tools/bash.ts +89 -0
- package/src/tools/common.ts +32 -0
- package/src/tools/edit.ts +41 -0
- package/src/tools/index.ts +47 -0
- package/src/tools/read.ts +64 -0
- package/src/tui/commands.ts +63 -0
- package/src/tui/editor.ts +291 -0
- package/src/tui/highlight.ts +189 -0
- package/src/tui/stream.ts +142 -0
- package/src/tui/styles.ts +42 -0
- package/src/tui/term.ts +436 -0
- package/src/tui/theme.ts +121 -0
- package/src/tui/tui.ts +595 -0
- package/src/tui/usage.ts +67 -0
- package/tsconfig.json +8 -8
- package/bin/mc.ts +0 -11
- package/bun.lock +0 -346
- package/nono-mini-coder.json +0 -42
- package/src/args.ts +0 -300
- package/src/error-handling.test.ts +0 -163
- package/src/git.ts +0 -23
- package/src/headless.ts +0 -66
- package/src/index.ts +0 -43
- package/src/oauth.ts +0 -157
- package/src/shared.ts +0 -119
- package/src/themes.ts +0 -234
- package/src/tool-bash.ts +0 -77
- package/src/tool-edit.ts +0 -121
- package/src/tool-read.ts +0 -100
- package/src/tui-components.ts +0 -127
- package/src/tui-conversation.ts +0 -218
- package/src/tui-editor.ts +0 -29
- package/src/tui-overlay.ts +0 -618
- package/src/tui.ts +0 -314
- package/src/types.ts +0 -194
- package/src/update.ts +0 -171
package/src/config.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
createProvider,
|
|
6
|
+
envApiKeyAuth,
|
|
7
|
+
Type,
|
|
8
|
+
type Api,
|
|
9
|
+
type Model,
|
|
10
|
+
type MutableModels,
|
|
11
|
+
type Static,
|
|
12
|
+
} from "@earendil-works/pi-ai";
|
|
13
|
+
import { Value } from "typebox/value";
|
|
14
|
+
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
|
|
15
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
16
|
+
import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy";
|
|
17
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
18
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
19
|
+
|
|
20
|
+
const ToolNameSchema = Type.Union([Type.Literal("edit"), Type.Literal("read"), Type.Literal("bash")]);
|
|
21
|
+
export type ToolName = Static<typeof ToolNameSchema>;
|
|
22
|
+
|
|
23
|
+
const CustomApiSchema = Type.Union([
|
|
24
|
+
Type.Literal("openai-completions"),
|
|
25
|
+
Type.Literal("openai-responses"),
|
|
26
|
+
Type.Literal("anthropic-messages"),
|
|
27
|
+
Type.Literal("google-generative-ai"),
|
|
28
|
+
]);
|
|
29
|
+
type CustomApi = Static<typeof CustomApiSchema>;
|
|
30
|
+
|
|
31
|
+
const CustomProviderSchema = Type.Object(
|
|
32
|
+
{
|
|
33
|
+
id: Type.String({ minLength: 1 }),
|
|
34
|
+
name: Type.Optional(Type.String()),
|
|
35
|
+
baseUrl: Type.String({ minLength: 1 }),
|
|
36
|
+
api: CustomApiSchema,
|
|
37
|
+
models: Type.Array(Type.String(), { minItems: 1 }),
|
|
38
|
+
envKeys: Type.Optional(Type.Array(Type.String())),
|
|
39
|
+
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
40
|
+
},
|
|
41
|
+
{ additionalProperties: false },
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const ConfigSchema = Type.Object(
|
|
45
|
+
{
|
|
46
|
+
sessionsDir: Type.String({ default: join(process.cwd(), "sessions") }),
|
|
47
|
+
systemPrompt: Type.String({ default: "" }),
|
|
48
|
+
discoverAgentFiles: Type.Boolean({ default: true }),
|
|
49
|
+
skillsDirs: Type.Array(Type.String(), { default: [] }),
|
|
50
|
+
tools: Type.Array(ToolNameSchema, { default: ["edit", "read", "bash"] }),
|
|
51
|
+
provider: Type.String(),
|
|
52
|
+
model: Type.String(),
|
|
53
|
+
thinkingEffort: Type.Union(
|
|
54
|
+
[
|
|
55
|
+
Type.Literal("minimal"),
|
|
56
|
+
Type.Literal("low"),
|
|
57
|
+
Type.Literal("medium"),
|
|
58
|
+
Type.Literal("high"),
|
|
59
|
+
Type.Literal("xhigh"),
|
|
60
|
+
Type.Literal("max"),
|
|
61
|
+
],
|
|
62
|
+
{ default: "medium" },
|
|
63
|
+
),
|
|
64
|
+
customProviders: Type.Array(CustomProviderSchema, { default: [] }),
|
|
65
|
+
},
|
|
66
|
+
{ additionalProperties: false },
|
|
67
|
+
);
|
|
68
|
+
export type Config = Static<typeof ConfigSchema>;
|
|
69
|
+
|
|
70
|
+
function configPath(): string {
|
|
71
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
72
|
+
return join(base, "mini-coder", "config.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readJson(path: string): unknown {
|
|
76
|
+
let text: string;
|
|
77
|
+
try {
|
|
78
|
+
text = readFileSync(path, "utf8");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
81
|
+
throw new Error(`config ${path}: ${(error as Error).message}`);
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(text);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new Error(`config ${path}: ${(error as Error).message}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadConfig(): Config {
|
|
91
|
+
const path = configPath();
|
|
92
|
+
const filled = Value.Default(ConfigSchema, readJson(path));
|
|
93
|
+
try {
|
|
94
|
+
return Value.Parse(ConfigSchema, filled);
|
|
95
|
+
} catch {
|
|
96
|
+
const first = [...Value.Errors(ConfigSchema, filled)][0];
|
|
97
|
+
throw new Error(`config ${path}: ${first ? `${first.instancePath || "/"} ${first.message}` : "invalid"}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const API_FACTORY: Record<CustomApi, () => ReturnType<typeof openAICompletionsApi>> = {
|
|
102
|
+
"openai-completions": openAICompletionsApi,
|
|
103
|
+
"openai-responses": openAIResponsesApi,
|
|
104
|
+
"anthropic-messages": anthropicMessagesApi,
|
|
105
|
+
"google-generative-ai": googleGenerativeAIApi,
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export function resolveModel(config: Config): { models: MutableModels; model: Model<Api> } {
|
|
109
|
+
const models = builtinModels();
|
|
110
|
+
for (const provider of config.customProviders) {
|
|
111
|
+
const name = provider.name ?? provider.id;
|
|
112
|
+
models.setProvider(
|
|
113
|
+
createProvider({
|
|
114
|
+
id: provider.id,
|
|
115
|
+
name,
|
|
116
|
+
baseUrl: provider.baseUrl,
|
|
117
|
+
headers: provider.headers,
|
|
118
|
+
auth: {
|
|
119
|
+
apiKey: provider.envKeys?.length
|
|
120
|
+
? envApiKeyAuth(name, provider.envKeys)
|
|
121
|
+
: { name, resolve: async () => ({ auth: { apiKey: "unused" } }) },
|
|
122
|
+
},
|
|
123
|
+
api: API_FACTORY[provider.api](),
|
|
124
|
+
models: provider.models.map((id) => ({
|
|
125
|
+
id,
|
|
126
|
+
name: id,
|
|
127
|
+
api: provider.api,
|
|
128
|
+
provider: provider.id,
|
|
129
|
+
baseUrl: provider.baseUrl,
|
|
130
|
+
reasoning: false,
|
|
131
|
+
input: ["text"] satisfies ("text" | "image")[],
|
|
132
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
133
|
+
contextWindow: 200_000,
|
|
134
|
+
maxTokens: 32_768,
|
|
135
|
+
})),
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const model = models.getModel(config.provider, config.model);
|
|
141
|
+
if (!model) {
|
|
142
|
+
const known = models
|
|
143
|
+
.getModels(config.provider)
|
|
144
|
+
.map((m) => m.id)
|
|
145
|
+
.slice(0, 12);
|
|
146
|
+
const hint = known.length ? ` (available: ${known.join(", ")})` : "";
|
|
147
|
+
throw new Error(`config model: unknown model "${config.model}" for provider "${config.provider}"${hint}`);
|
|
148
|
+
}
|
|
149
|
+
return { models, model };
|
|
150
|
+
}
|
package/src/prompt.ts
CHANGED
|
@@ -1,232 +1,79 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without unnecessary superlatives, praise, or emotional validation.
|
|
12
|
-
- User messages and Tool results may include <system-reminder> tags. These contain system-generated reminders and bear no direct relation to the specific tool result in which they appear.
|
|
13
|
-
- You have access to bash, read and edit tools. Prefer using read and edit for file operations, use bash for finding read candidates or to run development commands.
|
|
14
|
-
- Use recent online information, the current environment, and your training data combined for a complete answer.
|
|
15
|
-
- Ensure that you fulfill the user's expectation, requirements and contract **exactly**.
|
|
16
|
-
- Do not overstate what changed or what was verified. Summaries must match the diff.
|
|
17
|
-
- Use temp directory for temp files, scripts, plan files, or anything that doesn't match the requested output.
|
|
18
|
-
- Be concise. Use a professional colleague tone: direct, never condescending, and never rude.
|
|
19
|
-
`;
|
|
20
|
-
|
|
21
|
-
async function getDir() {
|
|
22
|
-
const ignoreFile = Bun.file(".gitignore");
|
|
23
|
-
let ignoreContent = "";
|
|
24
|
-
if (await ignoreFile.exists()) {
|
|
25
|
-
ignoreContent = await ignoreFile.text();
|
|
26
|
-
}
|
|
27
|
-
const ignored = ignoreContent.split("\n");
|
|
28
|
-
const dir = [];
|
|
29
|
-
const glob = promises.glob(["*", "*/*"], { exclude: ignored });
|
|
30
|
-
for await (const file of glob) {
|
|
31
|
-
dir.push(file);
|
|
32
|
-
}
|
|
33
|
-
return dir;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function getEnvPrompt() {
|
|
37
|
-
// TODO: What else do the agents always check before answering every time?
|
|
38
|
-
const gitStatus = await getGitStatus();
|
|
39
|
-
const envKeys = ["PATH", "USER", "LANG", "HOME", "SHELL", "BUN_INSTALL"];
|
|
40
|
-
const env: Record<string, string> = {};
|
|
41
|
-
for (const key of envKeys) {
|
|
42
|
-
const v = Bun.env[key];
|
|
43
|
-
|
|
44
|
-
if (v !== undefined) {
|
|
45
|
-
env[key] = v;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const envStatus = JSON.stringify(
|
|
50
|
-
{
|
|
51
|
-
os: platform(),
|
|
52
|
-
env,
|
|
53
|
-
cwd: process.cwd(),
|
|
54
|
-
dir: await getDir(),
|
|
55
|
-
git: gitStatus,
|
|
56
|
-
},
|
|
57
|
-
null,
|
|
58
|
-
4,
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
const text = `### Environment status and information
|
|
62
|
-
|
|
63
|
-
\`\`\`json
|
|
64
|
-
${envStatus}
|
|
65
|
-
\`\`\`
|
|
66
|
-
`;
|
|
67
|
-
|
|
68
|
-
return text;
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import type { Config } from "./config.ts";
|
|
5
|
+
|
|
6
|
+
interface Skill {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
path: string;
|
|
69
10
|
}
|
|
70
11
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const localPath = join(process.cwd(), "AGENTS.md");
|
|
83
|
-
const localFile = Bun.file(localPath);
|
|
84
|
-
|
|
85
|
-
if (await localFile.exists()) {
|
|
86
|
-
content.push(await localFile.text());
|
|
12
|
+
function frontmatter(text: string): { name?: string; description?: string } {
|
|
13
|
+
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
14
|
+
if (!match) return {};
|
|
15
|
+
const out: { name?: string; description?: string } = {};
|
|
16
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
17
|
+
const separator = line.indexOf(":");
|
|
18
|
+
if (separator < 0) continue;
|
|
19
|
+
const key = line.slice(0, separator).trim();
|
|
20
|
+
const value = line.slice(separator + 1).trim().replace(/^["']|["']$/g, "");
|
|
21
|
+
if (key === "name") out.name = value;
|
|
22
|
+
if (key === "description") out.description = value;
|
|
87
23
|
}
|
|
88
|
-
|
|
89
|
-
return content.join("\n\n").trim();
|
|
24
|
+
return out;
|
|
90
25
|
}
|
|
91
26
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const skillRoots = [
|
|
96
|
-
join(homedir(), ".agents", "skills"),
|
|
97
|
-
join(process.cwd(), ".agents", "skills"),
|
|
98
|
-
];
|
|
99
|
-
|
|
100
|
-
for (const root of skillRoots) {
|
|
27
|
+
function discoverSkills(dirs: string[]): Skill[] {
|
|
28
|
+
const skills: Skill[] = [];
|
|
29
|
+
for (const root of dirs) {
|
|
101
30
|
let entries: string[];
|
|
102
|
-
|
|
103
31
|
try {
|
|
104
|
-
entries =
|
|
32
|
+
entries = readdirSync(root);
|
|
105
33
|
} catch {
|
|
106
34
|
continue;
|
|
107
35
|
}
|
|
108
|
-
|
|
109
36
|
for (const entry of entries) {
|
|
110
|
-
const path =
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (!
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const parsed = parseSkillFrontmatter(await file.text());
|
|
118
|
-
|
|
119
|
-
if (!parsed) {
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
skillsBlock += `## ${parsed.name}
|
|
124
|
-
|
|
125
|
-
> Absolute file path to read: ${path}
|
|
126
|
-
|
|
127
|
-
${parsed.description}
|
|
128
|
-
|
|
129
|
-
`;
|
|
37
|
+
const path = resolve(root, entry, "SKILL.md");
|
|
38
|
+
if (!existsSync(path)) continue;
|
|
39
|
+
const meta = frontmatter(readFileSync(path, "utf8"));
|
|
40
|
+
if (!meta.name) continue;
|
|
41
|
+
skills.push({ name: meta.name, description: meta.description ?? "", path });
|
|
130
42
|
}
|
|
131
43
|
}
|
|
132
|
-
|
|
133
|
-
if (!skillsBlock.length) return "";
|
|
134
|
-
|
|
135
|
-
const skills = `# Skills
|
|
136
|
-
|
|
137
|
-
- The following skills provide specialized instructions for specific tasks.
|
|
138
|
-
- Use the bash tool to read a skill's file when the task matches its description.
|
|
139
|
-
- Use the skill provided absolute file path instead of guessing or constructing one.
|
|
140
|
-
- Skills can be global (in ~/.agents/skills) or local to the directory (./agents/skills)
|
|
141
|
-
|
|
142
|
-
${skillsBlock}`;
|
|
143
|
-
|
|
144
|
-
return skills.trim();
|
|
44
|
+
return skills;
|
|
145
45
|
}
|
|
146
46
|
|
|
147
|
-
|
|
148
|
-
const
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (agentsContent) {
|
|
157
|
-
complete += `\n${agentsContent}`;
|
|
47
|
+
function agentFiles(): { path: string; text: string }[] {
|
|
48
|
+
const roots = [join(homedir(), ".agents"), process.cwd()];
|
|
49
|
+
const found: { path: string; text: string }[] = [];
|
|
50
|
+
for (const root of roots) {
|
|
51
|
+
for (const name of ["AGENTS.md", "CLAUDE.md"]) {
|
|
52
|
+
const path = join(root, name);
|
|
53
|
+
if (existsSync(path)) found.push({ path, text: readFileSync(path, "utf8").trim() });
|
|
54
|
+
}
|
|
158
55
|
}
|
|
159
|
-
|
|
160
|
-
return complete;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export async function injectEnvReminder(): Promise<string> {
|
|
164
|
-
const envStatus = await getEnvPrompt();
|
|
165
|
-
return `<system-reminder>\n${envStatus}\n</system-reminder>`;
|
|
56
|
+
return found;
|
|
166
57
|
}
|
|
167
58
|
|
|
168
|
-
|
|
169
|
-
|
|
59
|
+
export function buildSystemPrompt(config: Config): string {
|
|
60
|
+
const sections: string[] = [];
|
|
61
|
+
if (config.systemPrompt.trim()) sections.push(config.systemPrompt.trim());
|
|
170
62
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
// Checks recent tool usage for simple repeated-call patterns and inserts an
|
|
178
|
-
// anti-doom-loop reminder when the agent appears stuck.
|
|
179
|
-
export function insertToolUsageReminder(
|
|
180
|
-
messages: Message[],
|
|
181
|
-
toolMessage: ToolResultMessage,
|
|
182
|
-
): ToolResultMessage {
|
|
183
|
-
const lastReminderIdx = messages.findLastIndex((m) => {
|
|
184
|
-
return (
|
|
185
|
-
m.role === "toolResult" &&
|
|
186
|
-
m.content.find(
|
|
187
|
-
(b) => b.type === "text" && b.text.includes(doomLoopReminder),
|
|
188
|
-
)
|
|
189
|
-
);
|
|
190
|
-
});
|
|
191
|
-
const lastUserMessageIdx = messages.findLastIndex((m) => {
|
|
192
|
-
return m.role === "user";
|
|
193
|
-
});
|
|
194
|
-
const messagesSinceLast = messages.slice(
|
|
195
|
-
Math.max(lastReminderIdx, lastUserMessageIdx) + 1,
|
|
196
|
-
);
|
|
197
|
-
const minMessagesForReminder = 4;
|
|
198
|
-
|
|
199
|
-
if (messagesSinceLast.length < minMessagesForReminder) return toolMessage;
|
|
200
|
-
|
|
201
|
-
let errorCount = 0;
|
|
202
|
-
let sameToolCount = 0;
|
|
203
|
-
const seenArgs = new Set<string>();
|
|
204
|
-
|
|
205
|
-
for (const msg of messagesSinceLast) {
|
|
206
|
-
if (msg.role === "toolResult" && msg.isError) errorCount++;
|
|
207
|
-
|
|
208
|
-
if (msg.role === "assistant") {
|
|
209
|
-
const calls = msg.content.filter((b) => b.type === "toolCall");
|
|
210
|
-
for (const c of calls) {
|
|
211
|
-
const args = JSON.stringify(c.arguments);
|
|
212
|
-
if (seenArgs.has(args)) sameToolCount++;
|
|
213
|
-
seenArgs.add(args);
|
|
214
|
-
}
|
|
63
|
+
if (config.skillsDirs.length > 0) {
|
|
64
|
+
const skills = discoverSkills(config.skillsDirs);
|
|
65
|
+
if (skills.length > 0) {
|
|
66
|
+
sections.push(
|
|
67
|
+
"## Skills\n\n" + skills.map((s) => `- ${s.name}: ${s.description} (${s.path})`).join("\n"),
|
|
68
|
+
);
|
|
215
69
|
}
|
|
216
70
|
}
|
|
217
71
|
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
return {
|
|
223
|
-
...toolMessage,
|
|
224
|
-
content: [
|
|
225
|
-
...toolMessage.content,
|
|
226
|
-
{ type: "text", text: doomLoopReminder },
|
|
227
|
-
],
|
|
228
|
-
};
|
|
72
|
+
if (config.discoverAgentFiles) {
|
|
73
|
+
for (const file of agentFiles()) {
|
|
74
|
+
sections.push(`## ${file.path}\n\n${file.text}`);
|
|
75
|
+
}
|
|
229
76
|
}
|
|
230
77
|
|
|
231
|
-
return
|
|
78
|
+
return sections.join("\n\n");
|
|
232
79
|
}
|
package/src/session.ts
CHANGED
|
@@ -1,91 +1,146 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
fsyncSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
openSync,
|
|
6
|
+
writeSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
2
9
|
import { join } from "node:path";
|
|
3
|
-
import type { Message } from "@earendil-works/pi-ai";
|
|
4
|
-
import { Value } from "typebox/value";
|
|
5
|
-
import { SESSIONS_DIR } from "./shared";
|
|
6
|
-
import { type Session, SessionSchema } from "./types";
|
|
10
|
+
import type { Message, Tool } from "@earendil-works/pi-ai";
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
interface SessionHeader {
|
|
13
|
+
type: "session";
|
|
14
|
+
version: 1;
|
|
15
|
+
id: string;
|
|
16
|
+
cwd: string;
|
|
17
|
+
createdAt: string;
|
|
18
|
+
title: string;
|
|
10
19
|
}
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
interface RequestRecord {
|
|
22
|
+
type: "request";
|
|
23
|
+
at: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
model: string;
|
|
26
|
+
api: string;
|
|
27
|
+
thinkingEffort: string;
|
|
28
|
+
systemPrompt: string;
|
|
29
|
+
tools: Tool[];
|
|
30
|
+
}
|
|
14
31
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
interface MessageRecord {
|
|
33
|
+
type: "message";
|
|
34
|
+
at: string;
|
|
35
|
+
message: Message;
|
|
36
|
+
}
|
|
18
37
|
|
|
19
|
-
|
|
20
|
-
const sessionJson = await file.text();
|
|
21
|
-
const parsed = JSON.parse(sessionJson) as unknown;
|
|
22
|
-
const valid = Value.Check(SessionSchema, parsed);
|
|
38
|
+
type SessionRecord = SessionHeader | RequestRecord | MessageRecord;
|
|
23
39
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
function slugify(text: string): string {
|
|
41
|
+
const slug = text
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
44
|
+
.replace(/^-+|-+$/g, "")
|
|
45
|
+
.slice(0, 40)
|
|
46
|
+
.replace(/-+$/, "");
|
|
47
|
+
return slug || "session";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
28
51
|
|
|
29
|
-
|
|
52
|
+
function shortId(): string {
|
|
53
|
+
const bytes = randomBytes(6);
|
|
54
|
+
let out = "";
|
|
55
|
+
for (let i = 0; i < 6; i++) out += ALPHABET[bytes[i] % 36];
|
|
56
|
+
return out;
|
|
30
57
|
}
|
|
31
58
|
|
|
32
|
-
function
|
|
33
|
-
|
|
59
|
+
function timestamp(date: Date): string {
|
|
60
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
61
|
+
return (
|
|
62
|
+
`${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
|
|
63
|
+
`-${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}`
|
|
64
|
+
);
|
|
34
65
|
}
|
|
35
66
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
67
|
+
function titleFor(message: Message): string {
|
|
68
|
+
if (message.role !== "user") return "session";
|
|
69
|
+
const text = typeof message.content === "string"
|
|
70
|
+
? message.content
|
|
71
|
+
: message.content.map((block) => (block.type === "text" ? block.text : "")).join(" ");
|
|
72
|
+
return slugify(text);
|
|
73
|
+
}
|
|
40
74
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
sessions.push(parsed);
|
|
52
|
-
}
|
|
53
|
-
} catch {
|
|
54
|
-
// Ignore invalid session files.
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
} catch {
|
|
58
|
-
return [];
|
|
75
|
+
export class Session {
|
|
76
|
+
private readonly sessionsDir: string;
|
|
77
|
+
private readonly cwd: string;
|
|
78
|
+
id: string | null = null;
|
|
79
|
+
private fd: number | null = null;
|
|
80
|
+
private closed = false;
|
|
81
|
+
|
|
82
|
+
constructor(sessionsDir: string, cwd: string) {
|
|
83
|
+
this.sessionsDir = sessionsDir;
|
|
84
|
+
this.cwd = cwd;
|
|
59
85
|
}
|
|
60
86
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
appendMessage(message: Message): void {
|
|
88
|
+
if (this.closed) return;
|
|
89
|
+
this.ensure(titleFor(message));
|
|
90
|
+
this.write({
|
|
91
|
+
type: "message",
|
|
92
|
+
at: new Date().toISOString(),
|
|
93
|
+
message,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
65
96
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
97
|
+
appendRequest(input: Omit<RequestRecord, "type" | "at">): void {
|
|
98
|
+
if (this.closed) return;
|
|
99
|
+
this.ensure("session");
|
|
100
|
+
this.write({ type: "request", at: new Date().toISOString(), ...input });
|
|
101
|
+
}
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const newMessages = messages.slice(existing.messages.length);
|
|
78
|
-
existing.messages = [...existing.messages, ...newMessages];
|
|
79
|
-
await saveSession(existing);
|
|
103
|
+
close(): void {
|
|
104
|
+
this.closed = true;
|
|
105
|
+
if (this.fd !== null) {
|
|
106
|
+
closeSync(this.fd);
|
|
107
|
+
this.fd = null;
|
|
80
108
|
}
|
|
81
|
-
return;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
111
|
+
private ensure(title: string): void {
|
|
112
|
+
if (this.fd !== null) return;
|
|
113
|
+
mkdirSync(this.sessionsDir, { recursive: true });
|
|
114
|
+
const stamp = timestamp(new Date());
|
|
115
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
116
|
+
const name = `${stamp}-${title}-${shortId()}`;
|
|
117
|
+
const dir = join(this.sessionsDir, name);
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(dir);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
this.id = name;
|
|
125
|
+
const logPath = join(dir, "session.jsonl");
|
|
126
|
+
this.fd = openSync(logPath, "a");
|
|
127
|
+
const header: SessionHeader = {
|
|
128
|
+
type: "session",
|
|
129
|
+
version: 1,
|
|
130
|
+
id: name,
|
|
131
|
+
cwd: this.cwd,
|
|
132
|
+
createdAt: new Date().toISOString(),
|
|
133
|
+
title,
|
|
134
|
+
};
|
|
135
|
+
this.write(header);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`session: could not create a unique directory in ${this.sessionsDir}`);
|
|
139
|
+
}
|
|
89
140
|
|
|
90
|
-
|
|
141
|
+
private write(record: SessionRecord): void {
|
|
142
|
+
if (this.fd === null) throw new Error("session: append before create");
|
|
143
|
+
writeSync(this.fd, JSON.stringify(record) + "\n");
|
|
144
|
+
fsyncSync(this.fd);
|
|
145
|
+
}
|
|
91
146
|
}
|