zero-ai-cli 1.0.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/MERGE_REPORT.md +265 -0
- package/README.md +127 -0
- package/package.json +55 -0
- package/packages/cli/src/index.ts +271 -0
- package/packages/cli/src/interactive.ts +190 -0
- package/packages/cli/src/once.ts +50 -0
- package/packages/core/src/agent/config-loader.ts +174 -0
- package/packages/core/src/agent/engine.ts +266 -0
- package/packages/core/src/agent/registry-tools.ts +58 -0
- package/packages/core/src/agent/registry.ts +213 -0
- package/packages/core/src/commands/index.ts +116 -0
- package/packages/core/src/config/index.ts +139 -0
- package/packages/core/src/confirmation/message-bus.ts +206 -0
- package/packages/core/src/diff/index.ts +232 -0
- package/packages/core/src/diff-detect/index.ts +207 -0
- package/packages/core/src/edit-coder/index.ts +194 -0
- package/packages/core/src/events/index.ts +151 -0
- package/packages/core/src/file-tracker/index.ts +183 -0
- package/packages/core/src/git/index.ts +305 -0
- package/packages/core/src/history/index.ts +236 -0
- package/packages/core/src/image/index.ts +131 -0
- package/packages/core/src/index.ts +131 -0
- package/packages/core/src/lsp/index.ts +251 -0
- package/packages/core/src/mcp/index.ts +186 -0
- package/packages/core/src/memory/index.ts +141 -0
- package/packages/core/src/memory/prompts.ts +52 -0
- package/packages/core/src/orchestrator/protocol.ts +140 -0
- package/packages/core/src/orchestrator/server.ts +319 -0
- package/packages/core/src/output/index.ts +164 -0
- package/packages/core/src/permissions/index.ts +126 -0
- package/packages/core/src/prompts/engine.ts +188 -0
- package/packages/core/src/providers/registry.ts +687 -0
- package/packages/core/src/routing/model-router.ts +160 -0
- package/packages/core/src/server/index.ts +232 -0
- package/packages/core/src/session/index.ts +174 -0
- package/packages/core/src/session/service.ts +322 -0
- package/packages/core/src/skills/index.ts +205 -0
- package/packages/core/src/streaming/index.ts +166 -0
- package/packages/core/src/sub-agent/index.ts +179 -0
- package/packages/core/src/tools/builtin.ts +568 -0
- package/packages/core/src/types.ts +356 -0
- package/packages/sdk/src/index.ts +247 -0
- package/packages/tui/src/index.ts +141 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO Model Router Service
|
|
3
|
+
* Adapted from: gemini-cli (Google) - Apache 2.0
|
|
4
|
+
*
|
|
5
|
+
* A centralized service for making intelligent model routing decisions.
|
|
6
|
+
* Supports multiple routing strategies with fallback chains.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Message } from "../types.js";
|
|
10
|
+
|
|
11
|
+
export interface RoutingDecision {
|
|
12
|
+
model: string;
|
|
13
|
+
metadata: {
|
|
14
|
+
source: string;
|
|
15
|
+
latencyMs: number;
|
|
16
|
+
reasoning: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RoutingContext {
|
|
22
|
+
history: readonly Message[];
|
|
23
|
+
request: string;
|
|
24
|
+
signal: AbortSignal;
|
|
25
|
+
requestedModel?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RoutingStrategy {
|
|
29
|
+
readonly name: string;
|
|
30
|
+
route(context: RoutingContext): Promise<RoutingDecision | null>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TerminalStrategy extends RoutingStrategy {
|
|
34
|
+
route(context: RoutingContext): Promise<RoutingDecision>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Default routing strategy that always returns the configured model
|
|
39
|
+
*/
|
|
40
|
+
export class DefaultStrategy implements TerminalStrategy {
|
|
41
|
+
readonly name = "default";
|
|
42
|
+
|
|
43
|
+
constructor(private defaultModel: string) {}
|
|
44
|
+
|
|
45
|
+
async route(context: RoutingContext): Promise<RoutingDecision> {
|
|
46
|
+
return {
|
|
47
|
+
model: context.requestedModel || this.defaultModel,
|
|
48
|
+
metadata: {
|
|
49
|
+
source: "default",
|
|
50
|
+
latencyMs: 0,
|
|
51
|
+
reasoning: "Default model selection",
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Override strategy that forces a specific model
|
|
59
|
+
*/
|
|
60
|
+
export class OverrideStrategy implements RoutingStrategy {
|
|
61
|
+
readonly name = "override";
|
|
62
|
+
|
|
63
|
+
constructor(private overrideModel?: string) {}
|
|
64
|
+
|
|
65
|
+
async route(context: RoutingContext): Promise<RoutingDecision | null> {
|
|
66
|
+
if (!this.overrideModel) return null;
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
model: this.overrideModel,
|
|
70
|
+
metadata: {
|
|
71
|
+
source: "override",
|
|
72
|
+
latencyMs: 0,
|
|
73
|
+
reasoning: `Override model: ${this.overrideModel}`,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Composite strategy that chains multiple strategies
|
|
81
|
+
*/
|
|
82
|
+
export class CompositeStrategy implements TerminalStrategy {
|
|
83
|
+
readonly name = "composite";
|
|
84
|
+
|
|
85
|
+
constructor(
|
|
86
|
+
private strategies: (RoutingStrategy | TerminalStrategy)[],
|
|
87
|
+
private fallbackModel: string
|
|
88
|
+
) {}
|
|
89
|
+
|
|
90
|
+
async route(context: RoutingContext): Promise<RoutingDecision> {
|
|
91
|
+
const startTime = Date.now();
|
|
92
|
+
|
|
93
|
+
for (const strategy of this.strategies) {
|
|
94
|
+
try {
|
|
95
|
+
const decision = await strategy.route(context);
|
|
96
|
+
if (decision) {
|
|
97
|
+
return {
|
|
98
|
+
...decision,
|
|
99
|
+
metadata: {
|
|
100
|
+
...decision.metadata,
|
|
101
|
+
latencyMs: Date.now() - startTime,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Continue to next strategy
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Fallback
|
|
111
|
+
return {
|
|
112
|
+
model: this.fallbackModel,
|
|
113
|
+
metadata: {
|
|
114
|
+
source: "composite-fallback",
|
|
115
|
+
latencyMs: Date.now() - startTime,
|
|
116
|
+
reasoning: "All strategies declined, using fallback",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Main Model Router Service
|
|
124
|
+
*/
|
|
125
|
+
export class ModelRouterService {
|
|
126
|
+
private strategy: TerminalStrategy;
|
|
127
|
+
|
|
128
|
+
constructor(
|
|
129
|
+
private defaultModel: string,
|
|
130
|
+
private strategies: RoutingStrategy[] = []
|
|
131
|
+
) {
|
|
132
|
+
const allStrategies: (RoutingStrategy | TerminalStrategy)[] = [
|
|
133
|
+
...strategies,
|
|
134
|
+
new DefaultStrategy(defaultModel),
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
this.strategy = new CompositeStrategy(allStrategies, defaultModel);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async route(context: RoutingContext): Promise<RoutingDecision> {
|
|
141
|
+
const startTime = Date.now();
|
|
142
|
+
let decision: RoutingDecision;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
decision = await this.strategy.route(context);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
decision = {
|
|
148
|
+
model: this.defaultModel,
|
|
149
|
+
metadata: {
|
|
150
|
+
source: "router-exception",
|
|
151
|
+
latencyMs: Date.now() - startTime,
|
|
152
|
+
reasoning: "Exception during routing, using default",
|
|
153
|
+
error: error instanceof Error ? error.message : String(error),
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return decision;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO HTTP API Server
|
|
3
|
+
* Adapted from: opencode (SST) - MIT
|
|
4
|
+
*
|
|
5
|
+
* REST API server for programmatic access to ZERO.
|
|
6
|
+
* Supports SSE (Server-Sent Events) for streaming.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as http from "node:http";
|
|
10
|
+
import type { AddressInfo } from "node:net";
|
|
11
|
+
import type { AgentResult, SessionConfig } from "../types.js";
|
|
12
|
+
|
|
13
|
+
export interface ServerOptions {
|
|
14
|
+
port?: number;
|
|
15
|
+
host?: string;
|
|
16
|
+
cors?: boolean;
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RouteHandler {
|
|
21
|
+
(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class ZeroServer {
|
|
25
|
+
private server?: http.Server;
|
|
26
|
+
private routes = new Map<string, { method: string; handler: RouteHandler }>();
|
|
27
|
+
private options: ServerOptions;
|
|
28
|
+
|
|
29
|
+
constructor(options: ServerOptions = {}) {
|
|
30
|
+
this.options = {
|
|
31
|
+
port: options.port || 3700,
|
|
32
|
+
host: options.host || "localhost",
|
|
33
|
+
cors: options.cors !== false,
|
|
34
|
+
apiKey: options.apiKey,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register a route
|
|
40
|
+
*/
|
|
41
|
+
route(method: string, path: string, handler: RouteHandler): void {
|
|
42
|
+
this.routes.set(`${method}:${path}`, { method, handler });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Start the server
|
|
47
|
+
*/
|
|
48
|
+
async start(): Promise<{ host: string; port: number }> {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
this.server = http.createServer(async (req, res) => {
|
|
51
|
+
// CORS
|
|
52
|
+
if (this.options.cors) {
|
|
53
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
54
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
55
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
56
|
+
if (req.method === "OPTIONS") {
|
|
57
|
+
res.writeHead(204);
|
|
58
|
+
res.end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Auth
|
|
64
|
+
if (this.options.apiKey) {
|
|
65
|
+
const auth = req.headers.authorization;
|
|
66
|
+
if (!auth || auth !== `Bearer ${this.options.apiKey}`) {
|
|
67
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
68
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Route matching
|
|
74
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
75
|
+
const routeKey = `${req.method}:${url.pathname}`;
|
|
76
|
+
const route = this.routes.get(routeKey);
|
|
77
|
+
|
|
78
|
+
if (route) {
|
|
79
|
+
try {
|
|
80
|
+
await route.handler(req, res, {});
|
|
81
|
+
} catch (error) {
|
|
82
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
83
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
// Try parameterized routes
|
|
87
|
+
let matched = false;
|
|
88
|
+
for (const [key, { handler }] of this.routes) {
|
|
89
|
+
const [method, pattern] = key.split(":");
|
|
90
|
+
if (method !== req.method) continue;
|
|
91
|
+
|
|
92
|
+
const params = this.matchRoute(pattern, url.pathname);
|
|
93
|
+
if (params) {
|
|
94
|
+
try {
|
|
95
|
+
await handler(req, res, params);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
98
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
99
|
+
}
|
|
100
|
+
matched = true;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!matched) {
|
|
106
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
107
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.server.listen(this.options.port, this.options.host, () => {
|
|
113
|
+
const addr = this.server!.address() as AddressInfo;
|
|
114
|
+
resolve({ host: addr.address, port: addr.port });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
this.server.on("error", reject);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Stop the server
|
|
123
|
+
*/
|
|
124
|
+
async stop(): Promise<void> {
|
|
125
|
+
return new Promise((resolve) => {
|
|
126
|
+
if (this.server) {
|
|
127
|
+
this.server.close(() => resolve());
|
|
128
|
+
} else {
|
|
129
|
+
resolve();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Send SSE (Server-Sent Events) stream
|
|
136
|
+
*/
|
|
137
|
+
static sendSSE(res: http.ServerResponse, event: string, data: unknown): void {
|
|
138
|
+
res.write(`event: ${event}\n`);
|
|
139
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Send JSON response
|
|
144
|
+
*/
|
|
145
|
+
static sendJSON(res: http.ServerResponse, data: unknown, status = 200): void {
|
|
146
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
147
|
+
res.end(JSON.stringify(data));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Read request body as JSON
|
|
152
|
+
*/
|
|
153
|
+
static async readBody<T = unknown>(req: http.IncomingMessage): Promise<T> {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
let body = "";
|
|
156
|
+
req.on("data", (chunk) => { body += chunk; });
|
|
157
|
+
req.on("end", () => {
|
|
158
|
+
try {
|
|
159
|
+
resolve(body ? JSON.parse(body) : {});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
reject(new Error("Invalid JSON body"));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
req.on("error", reject);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private matchRoute(pattern: string, pathname: string): Record<string, string> | null {
|
|
169
|
+
const patternParts = pattern.split("/");
|
|
170
|
+
const pathParts = pathname.split("/");
|
|
171
|
+
|
|
172
|
+
if (patternParts.length !== pathParts.length) return null;
|
|
173
|
+
|
|
174
|
+
const params: Record<string, string> = {};
|
|
175
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
176
|
+
if (patternParts[i].startsWith(":")) {
|
|
177
|
+
params[patternParts[i].slice(1)] = pathParts[i];
|
|
178
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return params;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create a pre-configured ZERO API server
|
|
189
|
+
*/
|
|
190
|
+
export function createAPIServer(options?: ServerOptions): ZeroServer {
|
|
191
|
+
const server = new ZeroServer(options);
|
|
192
|
+
|
|
193
|
+
// Health check
|
|
194
|
+
server.route("GET", "/health", async (_, res) => {
|
|
195
|
+
ZeroServer.sendJSON(res, { status: "ok", version: "1.0.0", uptime: process.uptime() });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// List agents
|
|
199
|
+
server.route("GET", "/agents", async (_, res) => {
|
|
200
|
+
ZeroServer.sendJSON(res, {
|
|
201
|
+
agents: [
|
|
202
|
+
{ id: "coder", name: "Coder", description: "Primary coding agent" },
|
|
203
|
+
{ id: "reviewer", name: "Reviewer", description: "Code review specialist" },
|
|
204
|
+
{ id: "researcher", name: "Researcher", description: "Documentation research" },
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// List tools
|
|
210
|
+
server.route("GET", "/tools", async (_, res) => {
|
|
211
|
+
ZeroServer.sendJSON(res, {
|
|
212
|
+
tools: [
|
|
213
|
+
"read_file", "write_file", "edit_file", "list_directory",
|
|
214
|
+
"search_files", "search_code", "execute_shell",
|
|
215
|
+
"git_status", "git_diff", "git_commit",
|
|
216
|
+
"web_fetch", "web_search", "lint", "repo_map",
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// List providers
|
|
222
|
+
server.route("GET", "/providers", async (_, res) => {
|
|
223
|
+
ZeroServer.sendJSON(res, {
|
|
224
|
+
providers: [
|
|
225
|
+
"openai", "anthropic", "google", "ollama", "custom",
|
|
226
|
+
"deepseek", "grok", "openrouter", "together", "fireworks",
|
|
227
|
+
],
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return server;
|
|
232
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZERO Session Manager
|
|
3
|
+
* Manages conversation sessions with persistence
|
|
4
|
+
*
|
|
5
|
+
* Sources:
|
|
6
|
+
* - opencode: session management with SQLite
|
|
7
|
+
* - crush: session state management
|
|
8
|
+
* - gemini-cli: context window management
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import * as fs from "node:fs/promises";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import type { Session, SessionConfig, Message, TokenUsage } from "../types.js";
|
|
14
|
+
|
|
15
|
+
export interface SessionManagerOptions {
|
|
16
|
+
storagePath: string;
|
|
17
|
+
defaultConfig: Partial<SessionConfig>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class SessionManager {
|
|
21
|
+
private sessions = new Map<string, Session>();
|
|
22
|
+
private storagePath: string;
|
|
23
|
+
private defaultConfig: Partial<SessionConfig>;
|
|
24
|
+
|
|
25
|
+
constructor(options: SessionManagerOptions) {
|
|
26
|
+
this.storagePath = options.storagePath;
|
|
27
|
+
this.defaultConfig = options.defaultConfig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create a new session
|
|
32
|
+
*/
|
|
33
|
+
async create(name?: string, configOverrides?: Partial<SessionConfig>): Promise<Session> {
|
|
34
|
+
const id = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
const session: Session = {
|
|
38
|
+
id,
|
|
39
|
+
name: name || `Session ${this.sessions.size + 1}`,
|
|
40
|
+
createdAt: now,
|
|
41
|
+
updatedAt: now,
|
|
42
|
+
messages: [],
|
|
43
|
+
config: {
|
|
44
|
+
provider: configOverrides?.provider || this.defaultConfig.provider || "openai",
|
|
45
|
+
model: configOverrides?.model || this.defaultConfig.model || "gpt-4o",
|
|
46
|
+
systemPrompt: configOverrides?.systemPrompt || this.defaultConfig.systemPrompt || "",
|
|
47
|
+
workingDirectory: configOverrides?.workingDirectory || this.defaultConfig.workingDirectory || process.cwd(),
|
|
48
|
+
permissions: configOverrides?.permissions || this.defaultConfig.permissions || {
|
|
49
|
+
fileRead: "allow",
|
|
50
|
+
fileWrite: "ask",
|
|
51
|
+
shellExecution: "ask",
|
|
52
|
+
networkAccess: "ask",
|
|
53
|
+
mcpAccess: "allow",
|
|
54
|
+
},
|
|
55
|
+
tools: configOverrides?.tools || this.defaultConfig.tools || [],
|
|
56
|
+
mcpServers: configOverrides?.mcpServers || this.defaultConfig.mcpServers || [],
|
|
57
|
+
},
|
|
58
|
+
state: {
|
|
59
|
+
status: "idle",
|
|
60
|
+
tokenUsage: { inputTokens: 0, outputTokens: 0 },
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
this.sessions.set(id, session);
|
|
65
|
+
await this.save(session);
|
|
66
|
+
return session;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get an existing session
|
|
71
|
+
*/
|
|
72
|
+
get(id: string): Session | undefined {
|
|
73
|
+
return this.sessions.get(id);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* List all sessions
|
|
78
|
+
*/
|
|
79
|
+
list(): Session[] {
|
|
80
|
+
return Array.from(this.sessions.values()).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add a message to a session
|
|
85
|
+
*/
|
|
86
|
+
async addMessage(sessionId: string, message: Message): Promise<void> {
|
|
87
|
+
const session = this.sessions.get(sessionId);
|
|
88
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
89
|
+
|
|
90
|
+
session.messages.push(message);
|
|
91
|
+
session.updatedAt = Date.now();
|
|
92
|
+
await this.save(session);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Update session state
|
|
97
|
+
*/
|
|
98
|
+
async updateState(sessionId: string, updates: Partial<Session["state"]>): Promise<void> {
|
|
99
|
+
const session = this.sessions.get(sessionId);
|
|
100
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
101
|
+
|
|
102
|
+
Object.assign(session.state, updates);
|
|
103
|
+
session.updatedAt = Date.now();
|
|
104
|
+
await this.save(session);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Delete a session
|
|
109
|
+
*/
|
|
110
|
+
async delete(id: string): Promise<void> {
|
|
111
|
+
this.sessions.delete(id);
|
|
112
|
+
try {
|
|
113
|
+
await fs.unlink(path.join(this.storagePath, `${id}.json`));
|
|
114
|
+
} catch {}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Load sessions from storage
|
|
119
|
+
*/
|
|
120
|
+
async loadAll(): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
await fs.mkdir(this.storagePath, { recursive: true });
|
|
123
|
+
const files = await fs.readdir(this.storagePath);
|
|
124
|
+
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
if (file.endsWith(".json")) {
|
|
127
|
+
try {
|
|
128
|
+
const data = await fs.readFile(path.join(this.storagePath, file), "utf-8");
|
|
129
|
+
const session = JSON.parse(data) as Session;
|
|
130
|
+
this.sessions.set(session.id, session);
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Save session to storage
|
|
139
|
+
*/
|
|
140
|
+
private async save(session: Session): Promise<void> {
|
|
141
|
+
await fs.mkdir(this.storagePath, { recursive: true });
|
|
142
|
+
await fs.writeFile(
|
|
143
|
+
path.join(this.storagePath, `${session.id}.json`),
|
|
144
|
+
JSON.stringify(session, null, 2)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Get session statistics
|
|
150
|
+
*/
|
|
151
|
+
getStats(sessionId: string): {
|
|
152
|
+
messageCount: number;
|
|
153
|
+
totalTokens: TokenUsage;
|
|
154
|
+
duration: number;
|
|
155
|
+
toolsUsed: string[];
|
|
156
|
+
} {
|
|
157
|
+
const session = this.sessions.get(sessionId);
|
|
158
|
+
if (!session) throw new Error(`Session "${sessionId}" not found`);
|
|
159
|
+
|
|
160
|
+
const toolsUsed = new Set<string>();
|
|
161
|
+
for (const msg of session.messages) {
|
|
162
|
+
for (const content of msg.content) {
|
|
163
|
+
if (content.type === "tool_call") toolsUsed.add(content.name);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
messageCount: session.messages.length,
|
|
169
|
+
totalTokens: session.state.tokenUsage,
|
|
170
|
+
duration: session.updatedAt - session.createdAt,
|
|
171
|
+
toolsUsed: Array.from(toolsUsed),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|