@twinklerg/coden 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/dist/index.js +21269 -0
- package/package.json +48 -0
- package/src/cli/agent-command.ts +497 -0
- package/src/cli/format.ts +42 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/plugin-command.ts +217 -0
- package/src/config/config.ts +96 -0
- package/src/config/trust.ts +35 -0
- package/src/context/manager.ts +186 -0
- package/src/context/truncate.ts +9 -0
- package/src/core/events.ts +32 -0
- package/src/core/runtime.ts +402 -0
- package/src/core/types.ts +97 -0
- package/src/index.ts +14 -0
- package/src/observability/terminal.ts +201 -0
- package/src/observability/trace.ts +30 -0
- package/src/permissions/policy.ts +56 -0
- package/src/permissions/workspace.ts +139 -0
- package/src/plugins/api.ts +68 -0
- package/src/plugins/bun-package-manager.ts +35 -0
- package/src/plugins/installed-loader.ts +144 -0
- package/src/plugins/installer.ts +314 -0
- package/src/plugins/manifest.ts +89 -0
- package/src/plugins/package-manager.ts +10 -0
- package/src/plugins/package-metadata.ts +95 -0
- package/src/plugins/paths.ts +43 -0
- package/src/plugins/specifier.ts +63 -0
- package/src/plugins/transaction.ts +403 -0
- package/src/process/runner.ts +134 -0
- package/src/providers/anthropic.ts +117 -0
- package/src/providers/openai.ts +96 -0
- package/src/providers/scripted.ts +28 -0
- package/src/sessions/store.ts +278 -0
- package/src/tools/builtin/bash.ts +56 -0
- package/src/tools/builtin/edit.ts +42 -0
- package/src/tools/builtin/index.ts +9 -0
- package/src/tools/builtin/read.ts +91 -0
- package/src/tools/builtin/write.ts +34 -0
- package/src/tools/executor.ts +90 -0
- package/src/tools/plugin-loader.ts +122 -0
- package/src/tools/registry.ts +97 -0
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import { userConfigDir, userDataDir } from "../config/config.js";
|
|
9
|
+
import { TrustStore } from "../config/trust.js";
|
|
10
|
+
import { BunPackageManager } from "../plugins/bun-package-manager.js";
|
|
11
|
+
import { InstalledPluginLoader } from "../plugins/installed-loader.js";
|
|
12
|
+
import {
|
|
13
|
+
type InstalledPluginSummary,
|
|
14
|
+
type ListedPlugin,
|
|
15
|
+
PluginInstaller,
|
|
16
|
+
type PluginOperationOptions,
|
|
17
|
+
} from "../plugins/installer.js";
|
|
18
|
+
import { builtinTools } from "../tools/builtin/index.js";
|
|
19
|
+
import {
|
|
20
|
+
type AgentCommandOptions,
|
|
21
|
+
ConfigError,
|
|
22
|
+
collect,
|
|
23
|
+
parseProvider,
|
|
24
|
+
positiveInteger,
|
|
25
|
+
runAgentCommand,
|
|
26
|
+
} from "./agent-command.js";
|
|
27
|
+
import { type PluginCommandService, registerPluginCommand } from "./plugin-command.js";
|
|
28
|
+
|
|
29
|
+
export interface CliDependencies {
|
|
30
|
+
pluginService?: PluginCommandService;
|
|
31
|
+
confirm?: (message: string) => Promise<boolean>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createCliProgram(dependencies: CliDependencies = {}): Command {
|
|
35
|
+
const confirm = dependencies.confirm ?? createDefaultConfirm;
|
|
36
|
+
const pluginService = dependencies.pluginService ?? createDefaultPluginService(confirm);
|
|
37
|
+
const program = new Command()
|
|
38
|
+
.name("coden")
|
|
39
|
+
.description("CodeN — a minimal coding agent")
|
|
40
|
+
.version("0.1.0")
|
|
41
|
+
.argument("[prompt]", "task to execute")
|
|
42
|
+
.option("-p, --print", "non-interactive print mode", false)
|
|
43
|
+
.option("--provider <provider>", "openai or anthropic", parseProvider)
|
|
44
|
+
.option("--model <model-id>", "model identifier")
|
|
45
|
+
.option(
|
|
46
|
+
"--resume [session-id]",
|
|
47
|
+
"resume a previous session, or list sessions when no id is given",
|
|
48
|
+
)
|
|
49
|
+
.option("--auto", "skip permission and project-plugin confirmations", false)
|
|
50
|
+
.option("--verbose", "show detailed runtime status", false)
|
|
51
|
+
.option("--max-steps <number>", "maximum model steps", positiveInteger)
|
|
52
|
+
.option("--plugin <path>", "additional local TypeScript plugin or directory", collect, [])
|
|
53
|
+
.action((prompt: string | undefined, options: AgentCommandOptions) =>
|
|
54
|
+
runAgentCommand(prompt, options),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
registerPluginCommand(program, {
|
|
58
|
+
service: pluginService,
|
|
59
|
+
confirm,
|
|
60
|
+
stdout: process.stdout,
|
|
61
|
+
stderr: process.stderr,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return program;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function createDefaultPluginService(
|
|
68
|
+
confirm: (message: string) => Promise<boolean>,
|
|
69
|
+
): PluginCommandService {
|
|
70
|
+
const workspace = process.cwd();
|
|
71
|
+
const installer = new PluginInstaller(
|
|
72
|
+
workspace,
|
|
73
|
+
userDataDir(),
|
|
74
|
+
new BunPackageManager(),
|
|
75
|
+
new InstalledPluginLoader(),
|
|
76
|
+
builtinTools(),
|
|
77
|
+
);
|
|
78
|
+
const trustStore = new TrustStore(path.join(userConfigDir(), "trusted-workspaces.json"));
|
|
79
|
+
return new TrustingPluginService(installer, workspace, trustStore, confirm);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class TrustingPluginService implements PluginCommandService {
|
|
83
|
+
constructor(
|
|
84
|
+
private readonly service: PluginCommandService,
|
|
85
|
+
private readonly workspace: string,
|
|
86
|
+
private readonly trustStore: TrustStore,
|
|
87
|
+
private readonly confirm: (message: string) => Promise<boolean>,
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
async install(raw: string, options: PluginOperationOptions): Promise<InstalledPluginSummary> {
|
|
91
|
+
if (options.scope === "project") await this.ensureProjectTrust();
|
|
92
|
+
return this.service.install(raw, options);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async remove(packageName: string, options: PluginOperationOptions): Promise<void> {
|
|
96
|
+
return this.service.remove(packageName, options);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async sync(options: PluginOperationOptions): Promise<InstalledPluginSummary[]> {
|
|
100
|
+
if (options.scope === "project") await this.ensureProjectTrust();
|
|
101
|
+
return this.service.sync(options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async list(): Promise<{ project: ListedPlugin[]; global: ListedPlugin[] }> {
|
|
105
|
+
return this.service.list();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async ensureProjectTrust(): Promise<void> {
|
|
109
|
+
const realWorkspace = await realpath(this.workspace);
|
|
110
|
+
if (await this.trustStore.isWorkspaceTrusted(realWorkspace)) return;
|
|
111
|
+
const allowed = await this.confirm(
|
|
112
|
+
`Project npm plugins in ${realWorkspace} run in-process with full user permissions. Trust this workspace?`,
|
|
113
|
+
);
|
|
114
|
+
if (!allowed) throw new Error("workspace is not trusted for project npm plugins");
|
|
115
|
+
await this.trustStore.trustWorkspace(realWorkspace);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function createDefaultConfirm(message: string): Promise<boolean> {
|
|
120
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
121
|
+
try {
|
|
122
|
+
const answer = await rl.question(`${message} [y/N] `);
|
|
123
|
+
return /^y(?:es)?$/i.test(answer);
|
|
124
|
+
} finally {
|
|
125
|
+
rl.close();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isExecutableEntry(): boolean {
|
|
130
|
+
const entry = process.argv[1];
|
|
131
|
+
if (!entry) return false;
|
|
132
|
+
// Compare canonical realpaths so symlinked prefixes (e.g. macOS /tmp -> /private/tmp)
|
|
133
|
+
// don't cause the CLI to silently no-op when it is directly executed.
|
|
134
|
+
try {
|
|
135
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(entry);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (isExecutableEntry()) {
|
|
142
|
+
createCliProgram()
|
|
143
|
+
.parseAsync()
|
|
144
|
+
.catch((error) => {
|
|
145
|
+
process.stderr.write(`coden: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
146
|
+
// 2 = configuration/setup failure, 1 = execution failure (see design §11.4).
|
|
147
|
+
process.exitCode = error instanceof ConfigError ? 2 : 1;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type {
|
|
3
|
+
InstalledPluginSummary,
|
|
4
|
+
ListedPlugin,
|
|
5
|
+
PluginOperationOptions,
|
|
6
|
+
} from "../plugins/installer.js";
|
|
7
|
+
|
|
8
|
+
export interface PluginCommandService {
|
|
9
|
+
install(raw: string, options: PluginOperationOptions): Promise<InstalledPluginSummary>;
|
|
10
|
+
remove(packageName: string, options: PluginOperationOptions): Promise<void>;
|
|
11
|
+
sync(options: PluginOperationOptions): Promise<InstalledPluginSummary[]>;
|
|
12
|
+
list(): Promise<{ project: ListedPlugin[]; global: ListedPlugin[] }>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PluginCommandDependencies {
|
|
16
|
+
service: PluginCommandService;
|
|
17
|
+
confirm(message: string): Promise<boolean>;
|
|
18
|
+
stdout: Pick<NodeJS.WriteStream, "write">;
|
|
19
|
+
stderr: Pick<NodeJS.WriteStream, "write">;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ScopeOptions {
|
|
23
|
+
global?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface MutatingOptions extends ScopeOptions {
|
|
27
|
+
allowScripts?: boolean;
|
|
28
|
+
yes?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ListOptions {
|
|
32
|
+
project?: boolean;
|
|
33
|
+
global?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function registerPluginCommand(
|
|
37
|
+
program: Command,
|
|
38
|
+
dependencies: PluginCommandDependencies,
|
|
39
|
+
): void {
|
|
40
|
+
const plugin = program.command("plugin").description("manage installed npm plugins");
|
|
41
|
+
|
|
42
|
+
plugin
|
|
43
|
+
.command("install")
|
|
44
|
+
.argument("<specifier>", "npm:<package> or npm:<package>@<version-or-tag>")
|
|
45
|
+
.option("--global", "install in the user-global scope")
|
|
46
|
+
.option(
|
|
47
|
+
"--allow-scripts",
|
|
48
|
+
"allow npm lifecycle scripts with full user permissions during dependency install",
|
|
49
|
+
)
|
|
50
|
+
.option("--yes", "skip confirmation prompts")
|
|
51
|
+
.action((specifier: string, options: MutatingOptions) =>
|
|
52
|
+
runPluginAction(dependencies, async () => {
|
|
53
|
+
const operation = operationOptions(options);
|
|
54
|
+
if (!(await confirmMutation(dependencies, "Install", specifier, options))) return;
|
|
55
|
+
const result = await dependencies.service.install(specifier, operation);
|
|
56
|
+
dependencies.stdout.write(renderInstallSummary(result, operation.allowScripts));
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
plugin
|
|
61
|
+
.command("remove")
|
|
62
|
+
.argument("<package>", "installed npm package name")
|
|
63
|
+
.option("--global", "remove from the user-global scope")
|
|
64
|
+
.option(
|
|
65
|
+
"--allow-scripts",
|
|
66
|
+
"allow npm lifecycle scripts with full user permissions while rebuilding dependencies",
|
|
67
|
+
)
|
|
68
|
+
.option("--yes", "skip confirmation prompts")
|
|
69
|
+
.action((packageName: string, options: MutatingOptions) =>
|
|
70
|
+
runPluginAction(dependencies, async () => {
|
|
71
|
+
const operation = operationOptions(options);
|
|
72
|
+
if (!(await confirmMutation(dependencies, "Remove", packageName, options))) return;
|
|
73
|
+
await dependencies.service.remove(packageName, operation);
|
|
74
|
+
dependencies.stdout.write(
|
|
75
|
+
`Removed ${packageName} from ${operation.scope} plugins.\nRestart CodeN to use npm plugin changes.\n`,
|
|
76
|
+
);
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
plugin
|
|
81
|
+
.command("list")
|
|
82
|
+
.option("--project", "show only project plugins")
|
|
83
|
+
.option("--global", "show only user-global plugins")
|
|
84
|
+
.action((options: ListOptions) =>
|
|
85
|
+
runPluginAction(dependencies, async () => {
|
|
86
|
+
if (options.project && options.global) {
|
|
87
|
+
throw new Error("plugin list accepts only one of --project or --global");
|
|
88
|
+
}
|
|
89
|
+
const result = await dependencies.service.list();
|
|
90
|
+
dependencies.stdout.write(renderList(result, options));
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
plugin
|
|
95
|
+
.command("sync")
|
|
96
|
+
.option("--global", "sync the user-global plugin runtime")
|
|
97
|
+
.option(
|
|
98
|
+
"--allow-scripts",
|
|
99
|
+
"allow npm lifecycle scripts with full user permissions during dependency install",
|
|
100
|
+
)
|
|
101
|
+
.option("--yes", "skip confirmation prompts")
|
|
102
|
+
.action((options: MutatingOptions) =>
|
|
103
|
+
runPluginAction(dependencies, async () => {
|
|
104
|
+
const operation = operationOptions(options);
|
|
105
|
+
if (!(await confirmMutation(dependencies, "Sync", `${operation.scope} plugins`, options)))
|
|
106
|
+
return;
|
|
107
|
+
const synced = await dependencies.service.sync(operation);
|
|
108
|
+
dependencies.stdout.write(
|
|
109
|
+
renderSyncSummary(operation.scope, synced, operation.allowScripts),
|
|
110
|
+
);
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function operationOptions(options: MutatingOptions): PluginOperationOptions {
|
|
116
|
+
return {
|
|
117
|
+
scope: options.global ? "global" : "project",
|
|
118
|
+
allowScripts: options.allowScripts === true,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function confirmMutation(
|
|
123
|
+
dependencies: PluginCommandDependencies,
|
|
124
|
+
verb: string,
|
|
125
|
+
target: string,
|
|
126
|
+
options: MutatingOptions,
|
|
127
|
+
): Promise<boolean> {
|
|
128
|
+
if (!options.yes) {
|
|
129
|
+
const allowed = await dependencies.confirm(
|
|
130
|
+
`${verb} ${target}? npm plugins run with full process permissions; validation imports plugin top-level code with full permissions. Continue?`,
|
|
131
|
+
);
|
|
132
|
+
if (!allowed) return false;
|
|
133
|
+
}
|
|
134
|
+
if (options.allowScripts) {
|
|
135
|
+
dependencies.stderr.write(
|
|
136
|
+
"Warning: --allow-scripts lets this package and transitive dependencies run lifecycle scripts with your user permissions.\n",
|
|
137
|
+
);
|
|
138
|
+
if (!options.yes) {
|
|
139
|
+
const allowed = await dependencies.confirm(
|
|
140
|
+
"Allow npm lifecycle scripts to run with full user permissions?",
|
|
141
|
+
);
|
|
142
|
+
if (!allowed) return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function runPluginAction(
|
|
149
|
+
dependencies: PluginCommandDependencies,
|
|
150
|
+
action: () => Promise<void>,
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
try {
|
|
153
|
+
await action();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
dependencies.stderr.write(`coden: ${sanitizeError(error)}\n`);
|
|
156
|
+
process.exitCode = 2;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function renderInstallSummary(result: InstalledPluginSummary, allowScripts: boolean): string {
|
|
161
|
+
return [
|
|
162
|
+
`Installed ${result.packageName}@${result.version} (${result.scope})`,
|
|
163
|
+
`Requested: ${result.requested}`,
|
|
164
|
+
`Tools: ${result.tools.length > 0 ? result.tools.slice().sort().join(", ") : "none"}`,
|
|
165
|
+
`Lifecycle scripts: ${allowScripts ? "enabled" : "disabled"}`,
|
|
166
|
+
"Restart CodeN to use npm plugin changes.",
|
|
167
|
+
"",
|
|
168
|
+
].join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderSyncSummary(
|
|
172
|
+
scope: "project" | "global",
|
|
173
|
+
synced: InstalledPluginSummary[],
|
|
174
|
+
allowScripts: boolean,
|
|
175
|
+
): string {
|
|
176
|
+
const packages = synced
|
|
177
|
+
.slice()
|
|
178
|
+
.sort((left, right) => left.packageName.localeCompare(right.packageName))
|
|
179
|
+
.map((item) => `${item.packageName}@${item.version}`);
|
|
180
|
+
return [
|
|
181
|
+
`Synced ${scope} plugins: ${packages.join(", ") || "none"}`,
|
|
182
|
+
`Lifecycle scripts: ${allowScripts ? "enabled" : "disabled"}`,
|
|
183
|
+
"Restart CodeN to use npm plugin changes.",
|
|
184
|
+
"",
|
|
185
|
+
].join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function renderList(
|
|
189
|
+
result: { project: ListedPlugin[]; global: ListedPlugin[] },
|
|
190
|
+
options: ListOptions,
|
|
191
|
+
): string {
|
|
192
|
+
const sections: string[] = [];
|
|
193
|
+
if (!options.global) sections.push(renderListSection("Project plugins", result.project));
|
|
194
|
+
if (!options.project) sections.push(renderListSection("Global plugins", result.global));
|
|
195
|
+
return `${sections.join("\n")}\n`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderListSection(title: string, plugins: ListedPlugin[]): string {
|
|
199
|
+
const sorted = plugins
|
|
200
|
+
.slice()
|
|
201
|
+
.sort((left, right) => left.packageName.localeCompare(right.packageName));
|
|
202
|
+
if (sorted.length === 0) return `${title}: none\n`;
|
|
203
|
+
const lines = sorted.map((plugin) => {
|
|
204
|
+
const shadowed = plugin.shadowedByProject ? " (shadowed by project)" : "";
|
|
205
|
+
return ` ${plugin.packageName}@${plugin.version} requested ${plugin.requested}; ${plugin.tools.length} tool(s)${shadowed}`;
|
|
206
|
+
});
|
|
207
|
+
return `${title}:\n${lines.join("\n")}\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sanitizeError(error: unknown): string {
|
|
211
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
212
|
+
for (const value of Object.values(process.env)) {
|
|
213
|
+
if (!value || value.length < 8) continue;
|
|
214
|
+
message = message.split(value).join("[redacted]");
|
|
215
|
+
}
|
|
216
|
+
return message;
|
|
217
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export type ProviderName = "openai" | "anthropic";
|
|
6
|
+
export interface CodeNConfig {
|
|
7
|
+
provider: ProviderName;
|
|
8
|
+
model: string;
|
|
9
|
+
maxSteps: number;
|
|
10
|
+
contextWindow: number;
|
|
11
|
+
reservedOutputTokens: number;
|
|
12
|
+
safetyMargin: number;
|
|
13
|
+
plugins: string[];
|
|
14
|
+
dataDir: string;
|
|
15
|
+
}
|
|
16
|
+
export type ConfigOverrides = Partial<Omit<CodeNConfig, "plugins" | "dataDir">> & {
|
|
17
|
+
plugins?: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function userConfigDir(): string {
|
|
21
|
+
return process.env.XDG_CONFIG_HOME
|
|
22
|
+
? path.join(process.env.XDG_CONFIG_HOME, "coden")
|
|
23
|
+
: path.join(os.homedir(), ".config", "coden");
|
|
24
|
+
}
|
|
25
|
+
export function userDataDir(): string {
|
|
26
|
+
return process.env.XDG_DATA_HOME
|
|
27
|
+
? path.join(process.env.XDG_DATA_HOME, "coden")
|
|
28
|
+
: path.join(os.homedir(), ".local", "share", "coden");
|
|
29
|
+
}
|
|
30
|
+
async function readJson(file: string): Promise<ConfigOverrides> {
|
|
31
|
+
try {
|
|
32
|
+
const raw = JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>;
|
|
33
|
+
return pickOverrides(raw);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
36
|
+
throw new Error(
|
|
37
|
+
`Cannot read config ${file}: ${error instanceof Error ? error.message : String(error)}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function pickOverrides(raw: Record<string, unknown>): ConfigOverrides {
|
|
43
|
+
const overrides: ConfigOverrides = {};
|
|
44
|
+
if (raw.provider === "openai" || raw.provider === "anthropic") overrides.provider = raw.provider;
|
|
45
|
+
if (typeof raw.model === "string") overrides.model = raw.model;
|
|
46
|
+
if (typeof raw.maxSteps === "number") overrides.maxSteps = raw.maxSteps;
|
|
47
|
+
if (typeof raw.contextWindow === "number") overrides.contextWindow = raw.contextWindow;
|
|
48
|
+
if (typeof raw.reservedOutputTokens === "number")
|
|
49
|
+
overrides.reservedOutputTokens = raw.reservedOutputTokens;
|
|
50
|
+
if (typeof raw.safetyMargin === "number") overrides.safetyMargin = raw.safetyMargin;
|
|
51
|
+
if (Array.isArray(raw.plugins))
|
|
52
|
+
overrides.plugins = raw.plugins.filter((item): item is string => typeof item === "string");
|
|
53
|
+
return overrides;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function loadConfig(
|
|
57
|
+
workspace: string,
|
|
58
|
+
cli: ConfigOverrides = {},
|
|
59
|
+
): Promise<CodeNConfig> {
|
|
60
|
+
const defaults: CodeNConfig = {
|
|
61
|
+
provider: "openai",
|
|
62
|
+
model: "gpt-5-mini",
|
|
63
|
+
maxSteps: 20,
|
|
64
|
+
contextWindow: 128000,
|
|
65
|
+
reservedOutputTokens: 8192,
|
|
66
|
+
safetyMargin: 4096,
|
|
67
|
+
plugins: [],
|
|
68
|
+
dataDir: userDataDir(),
|
|
69
|
+
};
|
|
70
|
+
const user = await readJson(path.join(userConfigDir(), "config.json"));
|
|
71
|
+
const project = await readJson(path.join(workspace, ".coden", "config.json"));
|
|
72
|
+
const env: ConfigOverrides = {};
|
|
73
|
+
if (process.env.CODEN_PROVIDER === "openai" || process.env.CODEN_PROVIDER === "anthropic")
|
|
74
|
+
env.provider = process.env.CODEN_PROVIDER;
|
|
75
|
+
if (process.env.CODEN_MODEL) env.model = process.env.CODEN_MODEL;
|
|
76
|
+
if (process.env.CODEN_MAX_STEPS) env.maxSteps = Number(process.env.CODEN_MAX_STEPS);
|
|
77
|
+
const merged = { ...defaults, ...user, ...project, ...env, ...cli };
|
|
78
|
+
merged.plugins = [...(user.plugins ?? []), ...(project.plugins ?? []), ...(cli.plugins ?? [])];
|
|
79
|
+
if (merged.provider !== "openai" && merged.provider !== "anthropic")
|
|
80
|
+
throw new Error("provider must be openai or anthropic");
|
|
81
|
+
if (typeof merged.model !== "string" || !merged.model.trim())
|
|
82
|
+
throw new Error("model must be a non-empty string");
|
|
83
|
+
if (!Number.isInteger(merged.maxSteps) || merged.maxSteps < 1)
|
|
84
|
+
throw new Error("maxSteps must be a positive integer");
|
|
85
|
+
for (const key of ["contextWindow", "reservedOutputTokens", "safetyMargin"] as const) {
|
|
86
|
+
if (!Number.isInteger(merged[key]) || merged[key] < 0)
|
|
87
|
+
throw new Error(`${key} must be a non-negative integer`);
|
|
88
|
+
}
|
|
89
|
+
if (merged.contextWindow === 0 || merged.reservedOutputTokens === 0)
|
|
90
|
+
throw new Error("contextWindow and reservedOutputTokens must be positive");
|
|
91
|
+
if (merged.contextWindow <= merged.reservedOutputTokens + merged.safetyMargin)
|
|
92
|
+
throw new Error("contextWindow must exceed reservedOutputTokens plus safetyMargin");
|
|
93
|
+
if (!Array.isArray(merged.plugins) || merged.plugins.some((item) => typeof item !== "string"))
|
|
94
|
+
throw new Error("plugins must be an array of paths");
|
|
95
|
+
return merged;
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export class TrustStore {
|
|
5
|
+
constructor(private readonly file: string) {}
|
|
6
|
+
async isTrusted(realPath: string): Promise<boolean> {
|
|
7
|
+
return (await this.read()).includes(realPath);
|
|
8
|
+
}
|
|
9
|
+
async isWorkspaceTrusted(workspace: string): Promise<boolean> {
|
|
10
|
+
return this.isTrusted(await realpath(workspace));
|
|
11
|
+
}
|
|
12
|
+
async trustWorkspace(workspace: string): Promise<void> {
|
|
13
|
+
return this.trust(await realpath(workspace));
|
|
14
|
+
}
|
|
15
|
+
async trust(realPath: string): Promise<void> {
|
|
16
|
+
const values = await this.read();
|
|
17
|
+
if (!values.includes(realPath)) values.push(realPath);
|
|
18
|
+
await mkdir(path.dirname(this.file), { recursive: true });
|
|
19
|
+
await writeFile(this.file, `${JSON.stringify(values, null, 2)}\n`, {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
mode: 0o600,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
private async read(): Promise<string[]> {
|
|
25
|
+
try {
|
|
26
|
+
const value = JSON.parse(await readFile(this.file, "utf8"));
|
|
27
|
+
return Array.isArray(value)
|
|
28
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
29
|
+
: [];
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { AgentMessage, ModelRequest, ToolDefinition } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export interface ContextBudget {
|
|
4
|
+
contextWindow: number;
|
|
5
|
+
reservedOutputTokens: number;
|
|
6
|
+
safetyMargin: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PreparedContext {
|
|
9
|
+
messages: AgentMessage[];
|
|
10
|
+
estimatedTokens: number;
|
|
11
|
+
compacted: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface CompactionRange {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface MessageUnit {
|
|
19
|
+
messages: AgentMessage[];
|
|
20
|
+
start: number;
|
|
21
|
+
end: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class TokenEstimator {
|
|
25
|
+
estimateText(text: string): number {
|
|
26
|
+
return Math.ceil(text.length / 3.5);
|
|
27
|
+
}
|
|
28
|
+
estimateMessages(messages: AgentMessage[]): number {
|
|
29
|
+
return messages.reduce(
|
|
30
|
+
(sum, message) => sum + 6 + this.estimateText(JSON.stringify(message)),
|
|
31
|
+
0,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
estimateTools(tools: ToolDefinition[]): number {
|
|
35
|
+
return this.estimateText(JSON.stringify(tools.map(({ execute: _, ...tool }) => tool)));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class ContextManager {
|
|
40
|
+
readonly estimator = new TokenEstimator();
|
|
41
|
+
private summary: AgentMessage | undefined;
|
|
42
|
+
private compactionRange: CompactionRange | undefined;
|
|
43
|
+
private compactedThrough = 0;
|
|
44
|
+
constructor(
|
|
45
|
+
readonly budget: ContextBudget,
|
|
46
|
+
private readonly threshold = 0.8,
|
|
47
|
+
) {}
|
|
48
|
+
inputBudget(): number {
|
|
49
|
+
return this.budget.contextWindow - this.budget.reservedOutputTokens - this.budget.safetyMargin;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
prepare(messages: AgentMessage[], tools: ToolDefinition[]): PreparedContext {
|
|
53
|
+
const system = messages[0] ?? { role: "system", content: "You are CodeN." };
|
|
54
|
+
const units = buildMessageUnits(messages.slice(1));
|
|
55
|
+
const toolTokens = this.estimator.estimateTools(tools);
|
|
56
|
+
const limit = this.inputBudget();
|
|
57
|
+
let retained = this.summary
|
|
58
|
+
? units.filter((unit) => unit.end > this.compactedThrough)
|
|
59
|
+
: [...units];
|
|
60
|
+
const recentCount = Math.min(3, retained.length);
|
|
61
|
+
let projected = this.project(system, retained);
|
|
62
|
+
let estimated = this.estimator.estimateMessages(projected) + toolTokens;
|
|
63
|
+
let compacted = false;
|
|
64
|
+
|
|
65
|
+
if (estimated > limit * this.threshold && retained.length > recentCount) {
|
|
66
|
+
const old = retained.slice(0, -recentCount);
|
|
67
|
+
retained = retained.slice(-recentCount);
|
|
68
|
+
const oldMessages = [
|
|
69
|
+
...(this.summary ? [this.summary] : []),
|
|
70
|
+
...old.flatMap((unit) => unit.messages),
|
|
71
|
+
];
|
|
72
|
+
this.compactionRange = { start: old[0]?.start ?? 1, end: old.at(-1)?.end ?? 1 };
|
|
73
|
+
this.compactedThrough = this.compactionRange.end;
|
|
74
|
+
this.summary = {
|
|
75
|
+
role: "system",
|
|
76
|
+
content: `Compacted conversation summary:\n${summarizeDeterministically(oldMessages)}`,
|
|
77
|
+
};
|
|
78
|
+
projected = this.project(system, retained);
|
|
79
|
+
estimated = this.estimator.estimateMessages(projected) + toolTokens;
|
|
80
|
+
compacted = true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
while (estimated > limit && retained.length > 1) {
|
|
84
|
+
retained = retained.slice(1);
|
|
85
|
+
projected = this.project(system, retained);
|
|
86
|
+
estimated = this.estimator.estimateMessages(projected) + toolTokens;
|
|
87
|
+
compacted = true;
|
|
88
|
+
}
|
|
89
|
+
return { messages: projected, estimatedTokens: estimated, compacted };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
forceCompact(messages: AgentMessage[], tools: ToolDefinition[]): PreparedContext {
|
|
93
|
+
const system = messages[0] ?? { role: "system", content: "You are CodeN." };
|
|
94
|
+
const units = buildMessageUnits(messages.slice(1));
|
|
95
|
+
const unsummarized = this.summary
|
|
96
|
+
? units.filter((unit) => unit.end > this.compactedThrough)
|
|
97
|
+
: units;
|
|
98
|
+
const retained = unsummarized.slice(-1);
|
|
99
|
+
const old = unsummarized.slice(0, -1);
|
|
100
|
+
const oldMessages = [
|
|
101
|
+
...(this.summary ? [this.summary] : []),
|
|
102
|
+
...old.flatMap((unit) => unit.messages),
|
|
103
|
+
];
|
|
104
|
+
if (old.length) {
|
|
105
|
+
this.compactionRange = { start: old[0]?.start ?? 1, end: old.at(-1)?.end ?? 1 };
|
|
106
|
+
this.compactedThrough = this.compactionRange.end;
|
|
107
|
+
}
|
|
108
|
+
this.summary = {
|
|
109
|
+
role: "system",
|
|
110
|
+
content: `Emergency compacted summary:\n${summarizeDeterministically(oldMessages)}`,
|
|
111
|
+
};
|
|
112
|
+
const projected = this.project(system, retained);
|
|
113
|
+
const estimated =
|
|
114
|
+
this.estimator.estimateMessages(projected) + this.estimator.estimateTools(tools);
|
|
115
|
+
return { messages: projected, estimatedTokens: estimated, compacted: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
setSummary(content: string, compactedThrough = 0): void {
|
|
119
|
+
this.summary = { role: "system", content };
|
|
120
|
+
this.compactedThrough = compactedThrough;
|
|
121
|
+
}
|
|
122
|
+
getSummary(): string | undefined {
|
|
123
|
+
return this.summary?.content;
|
|
124
|
+
}
|
|
125
|
+
getCompactionRange(): CompactionRange | undefined {
|
|
126
|
+
return this.compactionRange;
|
|
127
|
+
}
|
|
128
|
+
clearSummary(): void {
|
|
129
|
+
this.summary = undefined;
|
|
130
|
+
this.compactionRange = undefined;
|
|
131
|
+
this.compactedThrough = 0;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private project(system: AgentMessage, units: MessageUnit[]): AgentMessage[] {
|
|
135
|
+
return [
|
|
136
|
+
system,
|
|
137
|
+
...(this.summary ? [this.summary] : []),
|
|
138
|
+
...units.flatMap((unit) => unit.messages),
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function buildMessageUnits(messages: AgentMessage[]): MessageUnit[] {
|
|
144
|
+
const units: MessageUnit[] = [];
|
|
145
|
+
let current: MessageUnit | undefined;
|
|
146
|
+
for (let index = 0; index < messages.length; index++) {
|
|
147
|
+
const message = messages[index];
|
|
148
|
+
if (!message) continue;
|
|
149
|
+
const sourceIndex = index + 1;
|
|
150
|
+
if (message.role === "user" || !current) {
|
|
151
|
+
if (current) units.push(current);
|
|
152
|
+
current = { messages: [message], start: sourceIndex, end: sourceIndex };
|
|
153
|
+
} else {
|
|
154
|
+
current.messages.push(message);
|
|
155
|
+
current.end = sourceIndex;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (current) units.push(current);
|
|
159
|
+
return units;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function summarizeDeterministically(messages: AgentMessage[]): string {
|
|
163
|
+
const lines = messages.map((message) => {
|
|
164
|
+
if (message.role === "tool")
|
|
165
|
+
return `Tool ${message.name} (${message.isError ? "error" : "ok"}): ${message.content.slice(0, 300)}`;
|
|
166
|
+
return `${message.role}: ${message.content.slice(0, 500)}`;
|
|
167
|
+
});
|
|
168
|
+
return lines.join("\n").slice(0, 6000);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function toModelRequest(
|
|
172
|
+
model: string,
|
|
173
|
+
prepared: PreparedContext,
|
|
174
|
+
tools: ToolDefinition[],
|
|
175
|
+
budget: ContextBudget,
|
|
176
|
+
signal?: AbortSignal,
|
|
177
|
+
): ModelRequest {
|
|
178
|
+
const request: ModelRequest = {
|
|
179
|
+
model,
|
|
180
|
+
messages: prepared.messages,
|
|
181
|
+
tools,
|
|
182
|
+
maxOutputTokens: budget.reservedOutputTokens,
|
|
183
|
+
};
|
|
184
|
+
if (signal) request.signal = signal;
|
|
185
|
+
return request;
|
|
186
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function truncateOutput(value: string, maxChars: number): string {
|
|
2
|
+
if (value.length <= maxChars) return value;
|
|
3
|
+
const markerBudget = 80;
|
|
4
|
+
const kept = Math.max(0, maxChars - markerBudget);
|
|
5
|
+
const head = Math.ceil(kept / 2);
|
|
6
|
+
const tail = Math.floor(kept / 2);
|
|
7
|
+
const omitted = value.length - head - tail;
|
|
8
|
+
return `${value.slice(0, head)}\n... [${omitted} characters omitted] ...\n${value.slice(value.length - tail)}`;
|
|
9
|
+
}
|