jeo-code 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/README.md +342 -0
- package/package.json +57 -0
- package/scripts/install.sh +322 -0
- package/scripts/uninstall.sh +30 -0
- package/src/agent/compaction.ts +75 -0
- package/src/agent/config-schema.ts +87 -0
- package/src/agent/context-files.ts +51 -0
- package/src/agent/engine.ts +208 -0
- package/src/agent/json.ts +87 -0
- package/src/agent/loop.ts +22 -0
- package/src/agent/session.ts +198 -0
- package/src/agent/state.ts +199 -0
- package/src/agent/subagents.ts +149 -0
- package/src/agent/tools.ts +355 -0
- package/src/ai/index.ts +11 -0
- package/src/ai/model-catalog-compat.ts +119 -0
- package/src/ai/model-catalog.ts +97 -0
- package/src/ai/model-discovery.ts +148 -0
- package/src/ai/model-enrich.ts +75 -0
- package/src/ai/model-manager.ts +178 -0
- package/src/ai/model-picker.ts +73 -0
- package/src/ai/model-registry.ts +83 -0
- package/src/ai/provider-status.ts +77 -0
- package/src/ai/providers/anthropic.ts +87 -0
- package/src/ai/providers/errors.ts +47 -0
- package/src/ai/providers/gemini.ts +77 -0
- package/src/ai/providers/ollama.ts +54 -0
- package/src/ai/providers/openai.ts +67 -0
- package/src/ai/sse.ts +46 -0
- package/src/ai/types.ts +37 -0
- package/src/auth/callback-server.ts +195 -0
- package/src/auth/flows/anthropic.ts +114 -0
- package/src/auth/flows/google.ts +120 -0
- package/src/auth/flows/index.ts +50 -0
- package/src/auth/flows/openai.ts +130 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +80 -0
- package/src/auth/pkce.ts +24 -0
- package/src/auth/refresh.ts +60 -0
- package/src/auth/storage.ts +113 -0
- package/src/auth/types.ts +26 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/runner.ts +245 -0
- package/src/cli.ts +17 -0
- package/src/commands/approve.ts +63 -0
- package/src/commands/auth.ts +144 -0
- package/src/commands/chat.ts +37 -0
- package/src/commands/deep-interview.ts +239 -0
- package/src/commands/doctor.ts +250 -0
- package/src/commands/evolve.ts +191 -0
- package/src/commands/launch.ts +745 -0
- package/src/commands/mcp.ts +18 -0
- package/src/commands/models.ts +104 -0
- package/src/commands/ralplan.ts +86 -0
- package/src/commands/resume.ts +6 -0
- package/src/commands/setup-helpers.ts +93 -0
- package/src/commands/setup.ts +190 -0
- package/src/commands/skills.ts +38 -0
- package/src/commands/team.ts +337 -0
- package/src/commands/ultragoal.ts +102 -0
- package/src/index.ts +31 -0
- package/src/mcp/index.ts +3 -0
- package/src/mcp/protocol.ts +45 -0
- package/src/mcp/server.ts +97 -0
- package/src/mcp/tools.ts +156 -0
- package/src/skills/catalog.ts +61 -0
- package/src/tui/app.ts +297 -0
- package/src/tui/components/ascii-art.ts +340 -0
- package/src/tui/components/autocomplete.ts +165 -0
- package/src/tui/components/capability.ts +29 -0
- package/src/tui/components/code-view.ts +146 -0
- package/src/tui/components/color.ts +172 -0
- package/src/tui/components/config-panel.ts +193 -0
- package/src/tui/components/evolution.ts +305 -0
- package/src/tui/components/footer.ts +95 -0
- package/src/tui/components/forge.ts +167 -0
- package/src/tui/components/index.ts +7 -0
- package/src/tui/components/layout.ts +105 -0
- package/src/tui/components/meter.ts +61 -0
- package/src/tui/components/model-picker.ts +82 -0
- package/src/tui/components/provider-picker.ts +42 -0
- package/src/tui/components/select-list.ts +199 -0
- package/src/tui/components/slash.ts +34 -0
- package/src/tui/components/spinner.ts +49 -0
- package/src/tui/components/status.ts +45 -0
- package/src/tui/components/stream.ts +36 -0
- package/src/tui/components/themes.ts +86 -0
- package/src/tui/components/tool-list.ts +67 -0
- package/src/tui/index.ts +2 -0
- package/src/tui/renderer.ts +70 -0
- package/src/tui/terminal.ts +78 -0
- package/src/util/retry.ts +108 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable agentic tool-call loop — the shared core behind `joc team`
|
|
3
|
+
* (per-task executor) and `joc launch` (interactive coding agent).
|
|
4
|
+
*
|
|
5
|
+
* The model is driven in JSON tool-call mode: each step it emits exactly one
|
|
6
|
+
* `{ "tool": "...", "arguments": { ... } }` object; the engine dispatches it,
|
|
7
|
+
* appends the result to history, and continues until the model calls `done`
|
|
8
|
+
* or the step budget is exhausted.
|
|
9
|
+
*/
|
|
10
|
+
import { callLlm, type Message } from "./loop";
|
|
11
|
+
import { extractJsonObject } from "./json";
|
|
12
|
+
import { readTool, writeTool, editTool, bashTool, findTool, searchTool, type ToolResult } from "./tools";
|
|
13
|
+
|
|
14
|
+
export interface ToolInvocation {
|
|
15
|
+
tool: string;
|
|
16
|
+
arguments?: Record<string, any>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ToolHandler = (args: Record<string, any>, cwd: string) => Promise<ToolResult>;
|
|
20
|
+
|
|
21
|
+
/** The default executor toolset (read / write / edit / bash / find / search). */
|
|
22
|
+
export const DEFAULT_TOOLS: Record<string, ToolHandler> = {
|
|
23
|
+
read: (a, cwd) => readTool(a.filePath ?? a.path, a.lineRange, cwd),
|
|
24
|
+
write: (a, cwd) => writeTool(a.filePath ?? a.path, a.content ?? "", cwd),
|
|
25
|
+
edit: (a, cwd) => editTool(a.filePath ?? a.path, a.editBlock ?? a.edit ?? "", cwd),
|
|
26
|
+
bash: (a, cwd) => bashTool(a.command ?? a.cmd, cwd, typeof a.timeoutMs === "number" ? a.timeoutMs : undefined),
|
|
27
|
+
find: (a, cwd) => findTool(a.globPattern ?? a.pattern, cwd),
|
|
28
|
+
search: (a, cwd) => searchTool(a.pattern, a.globPattern ?? "*", cwd),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Tool-protocol description injected into the system prompt. */
|
|
32
|
+
export const TOOL_PROTOCOL = [
|
|
33
|
+
"You have these tools (call exactly ONE per step):",
|
|
34
|
+
"1. read {filePath, lineRange?} — read a file (lineRange: \"start-end\", \"start-\", or \"start\")",
|
|
35
|
+
"2. write {filePath, content} — create/overwrite a file",
|
|
36
|
+
"3. edit {filePath, editBlock} — ≔A..B replace lines; ≔A+ insert after line A; ≔$ append EOF (payload on next line)",
|
|
37
|
+
"4. bash {command, timeoutMs?} — run a shell command (tests, build, mkdir, ...); timeoutMs default 120000",
|
|
38
|
+
"5. find {globPattern} — find files by name",
|
|
39
|
+
"6. search {pattern, globPattern?} — grep for a pattern",
|
|
40
|
+
"7. done {reason?} — call when the task is fully implemented AND verified",
|
|
41
|
+
"",
|
|
42
|
+
"Reply with STRICT JSON only — no prose, no code fences:",
|
|
43
|
+
'{ "tool": "<name>", "arguments": { ... } }',
|
|
44
|
+
].join("\n");
|
|
45
|
+
|
|
46
|
+
export function executorSystemPrompt(role = "Executor Agent, a senior software developer"): string {
|
|
47
|
+
return (
|
|
48
|
+
`You are the ${role}.\n` +
|
|
49
|
+
`Accomplish the user's request by calling tools and verifying your work.\n\n` +
|
|
50
|
+
`${TOOL_PROTOCOL}\n\n` +
|
|
51
|
+
`Always verify (run tests / execute the program) before calling done.`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AgentLoopEvents {
|
|
56
|
+
onStep?(step: number): void;
|
|
57
|
+
onAssistant?(raw: string, invocation: ToolInvocation | null): void;
|
|
58
|
+
onToolResult?(tool: string, success: boolean, output: string): void;
|
|
59
|
+
onError?(message: string): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AgentLoopOptions {
|
|
63
|
+
cwd: string;
|
|
64
|
+
maxSteps?: number;
|
|
65
|
+
model?: string;
|
|
66
|
+
/** Max generation tokens per step (drives the thinking budget). */
|
|
67
|
+
maxTokens?: number;
|
|
68
|
+
tools?: Record<string, ToolHandler>;
|
|
69
|
+
signal?: AbortSignal;
|
|
70
|
+
events?: AgentLoopEvents;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface AgentLoopResult {
|
|
74
|
+
done: boolean;
|
|
75
|
+
steps: number;
|
|
76
|
+
doneReason?: string;
|
|
77
|
+
/** Summed provider token usage across the turn's steps, when reported. */
|
|
78
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cap a tool result fed back to the model, keeping both ends: the head holds the
|
|
83
|
+
* start (e.g. a file's top / a command's invocation) and the tail holds what's
|
|
84
|
+
* usually decisive (test summaries, the final error). A pure head-cut loses that.
|
|
85
|
+
*/
|
|
86
|
+
export function truncateToolOutput(s: string, max = 4000): string {
|
|
87
|
+
if (s.length <= max) return s;
|
|
88
|
+
const head = Math.floor(max * 0.6);
|
|
89
|
+
const tail = max - head;
|
|
90
|
+
return `${s.slice(0, head)}\n…(${s.length - max} chars truncated)…\n${s.slice(s.length - tail)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Drive `history` through the tool-call loop, mutating it in place so callers
|
|
95
|
+
* (e.g. an interactive REPL) can keep the conversation across multiple turns.
|
|
96
|
+
*/
|
|
97
|
+
export async function runAgentLoop(history: Message[], opts: AgentLoopOptions): Promise<AgentLoopResult> {
|
|
98
|
+
const { cwd } = opts;
|
|
99
|
+
const tools = opts.tools ?? DEFAULT_TOOLS;
|
|
100
|
+
const maxSteps = opts.maxSteps ?? 15;
|
|
101
|
+
const ev = opts.events ?? {};
|
|
102
|
+
|
|
103
|
+
let step = 1;
|
|
104
|
+
const acc = { inputTokens: 0, outputTokens: 0 };
|
|
105
|
+
let sawUsage = false;
|
|
106
|
+
const finish = (r: AgentLoopResult): AgentLoopResult => (sawUsage ? { ...r, usage: { ...acc } } : r);
|
|
107
|
+
// No-progress guard: weak/local models often repeat the same tool call without
|
|
108
|
+
// ever emitting `done`. Stop after MAX_REPEAT identical consecutive calls.
|
|
109
|
+
const MAX_REPEAT = 3;
|
|
110
|
+
// Consecutive-failure guard: a model that keeps emitting *different* but failing
|
|
111
|
+
// calls (bad edits, failing commands) would otherwise burn the whole step budget.
|
|
112
|
+
const MAX_FAILURES = 5;
|
|
113
|
+
let consecutiveFailures = 0;
|
|
114
|
+
let lastSig = "";
|
|
115
|
+
let repeatCount = 0;
|
|
116
|
+
while (step <= maxSteps) {
|
|
117
|
+
if (opts.signal?.aborted) {
|
|
118
|
+
return finish({ done: false, steps: step - 1, doneReason: "Cancelled." });
|
|
119
|
+
}
|
|
120
|
+
ev.onStep?.(step);
|
|
121
|
+
|
|
122
|
+
let responseText: string;
|
|
123
|
+
try {
|
|
124
|
+
responseText = await callLlm(history, {
|
|
125
|
+
jsonMode: true,
|
|
126
|
+
model: opts.model,
|
|
127
|
+
maxTokens: opts.maxTokens,
|
|
128
|
+
signal: opts.signal,
|
|
129
|
+
onUsage: u => { acc.inputTokens += u.inputTokens ?? 0; acc.outputTokens += u.outputTokens ?? 0; sawUsage = true; },
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
const message = (err as Error).message;
|
|
133
|
+
ev.onError?.(message);
|
|
134
|
+
// Surface the real cause so callers don't print a misleading "step limit" message.
|
|
135
|
+
return finish({ done: false, steps: step, doneReason: `Error: ${message}` });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let invocation: ToolInvocation;
|
|
139
|
+
try {
|
|
140
|
+
invocation = extractJsonObject<ToolInvocation>(responseText);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Not valid tool-call JSON — show the model the error and let it retry.
|
|
143
|
+
ev.onAssistant?.(responseText, null);
|
|
144
|
+
history.push({ role: "assistant", content: responseText });
|
|
145
|
+
history.push({
|
|
146
|
+
role: "user",
|
|
147
|
+
content:
|
|
148
|
+
`Your last reply was not a valid tool call (${(err as Error).message}). ` +
|
|
149
|
+
`Reply with exactly one JSON object: {"tool":"<name>","arguments":{...}}.`,
|
|
150
|
+
});
|
|
151
|
+
step++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
ev.onAssistant?.(responseText, invocation);
|
|
156
|
+
|
|
157
|
+
if (invocation.tool === "done") {
|
|
158
|
+
return finish({ done: true, steps: step, doneReason: (invocation.arguments?.reason as string) ?? "" });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Detect repeated identical tool calls (no forward progress).
|
|
162
|
+
const sig = `${invocation.tool}:${JSON.stringify(invocation.arguments ?? {})}`;
|
|
163
|
+
if (sig === lastSig) repeatCount++;
|
|
164
|
+
else {
|
|
165
|
+
repeatCount = 1;
|
|
166
|
+
lastSig = sig;
|
|
167
|
+
}
|
|
168
|
+
if (repeatCount >= MAX_REPEAT) {
|
|
169
|
+
return finish({
|
|
170
|
+
done: false,
|
|
171
|
+
steps: step,
|
|
172
|
+
doneReason: `Stopped: repeated the same '${invocation.tool}' call ${MAX_REPEAT}× with no new progress (the model never signaled done).`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const handler = tools[invocation.tool];
|
|
177
|
+
let success: boolean;
|
|
178
|
+
let output: string;
|
|
179
|
+
if (!handler) {
|
|
180
|
+
success = false;
|
|
181
|
+
output = `Unknown tool: ${invocation.tool}. Available: ${Object.keys(tools).join(", ")}, done.`;
|
|
182
|
+
} else {
|
|
183
|
+
const res = await handler(invocation.arguments ?? {}, cwd);
|
|
184
|
+
success = res.success;
|
|
185
|
+
output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
ev.onToolResult?.(invocation.tool, success, output);
|
|
189
|
+
history.push({ role: "assistant", content: responseText });
|
|
190
|
+
history.push({
|
|
191
|
+
role: "user",
|
|
192
|
+
content: `Tool [${invocation.tool}] result (${success ? "ok" : "fail"}):\n${truncateToolOutput(output)}`,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
if (success) {
|
|
196
|
+
consecutiveFailures = 0;
|
|
197
|
+
} else if (++consecutiveFailures >= MAX_FAILURES) {
|
|
198
|
+
return finish({
|
|
199
|
+
done: false,
|
|
200
|
+
steps: step,
|
|
201
|
+
doneReason: `Stopped: ${MAX_FAILURES} consecutive failing tool calls (last '${invocation.tool}'); the model could not recover.`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
step++;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return finish({ done: false, steps: maxSteps });
|
|
208
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Robust JSON object extraction for LLM tool-call responses.
|
|
3
|
+
*
|
|
4
|
+
* Models (especially non-jsonMode backends like Anthropic/Ollama) routinely
|
|
5
|
+
* wrap JSON in prose, ```json fences, or trailing commentary. This recovers the
|
|
6
|
+
* first balanced top-level `{...}` object, respecting strings and escapes.
|
|
7
|
+
*/
|
|
8
|
+
export function extractJsonObject<T = unknown>(text: string): T {
|
|
9
|
+
const raw = text.trim();
|
|
10
|
+
|
|
11
|
+
// Fast path: already pure JSON.
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(raw) as T;
|
|
14
|
+
} catch {
|
|
15
|
+
/* fall through to recovery */
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Strip common code fences and retry.
|
|
19
|
+
const defenced = raw.replace(/```(?:json|JSON)?/g, "").trim();
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(defenced) as T;
|
|
22
|
+
} catch {
|
|
23
|
+
/* fall through to brace scan */
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const parsedFromDefenced = findAndParseBalancedObject<T>(defenced);
|
|
27
|
+
if (parsedFromDefenced !== null) {
|
|
28
|
+
return parsedFromDefenced;
|
|
29
|
+
}
|
|
30
|
+
const parsedFromRaw = findAndParseBalancedObject<T>(raw);
|
|
31
|
+
if (parsedFromRaw !== null) {
|
|
32
|
+
return parsedFromRaw;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`No parseable JSON object found in model output: ${truncate(raw, 200)}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Like {@link extractJsonObject} but returns null instead of throwing. */
|
|
38
|
+
export function tryExtractJsonObject<T = unknown>(text: string): T | null {
|
|
39
|
+
try {
|
|
40
|
+
return extractJsonObject<T>(text);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Scan for a brace-balanced object starting at startIndex, ignoring braces inside strings. */
|
|
47
|
+
function extractBalancedObject(text: string, startIndex: number): string | null {
|
|
48
|
+
let depth = 0;
|
|
49
|
+
let inString = false;
|
|
50
|
+
let escaped = false;
|
|
51
|
+
for (let i = startIndex; i < text.length; i++) {
|
|
52
|
+
const ch = text[i];
|
|
53
|
+
if (inString) {
|
|
54
|
+
if (escaped) escaped = false;
|
|
55
|
+
else if (ch === "\\") escaped = true;
|
|
56
|
+
else if (ch === '"') inString = false;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === '"') inString = true;
|
|
60
|
+
else if (ch === "{") depth++;
|
|
61
|
+
else if (ch === "}") {
|
|
62
|
+
depth--;
|
|
63
|
+
if (depth === 0) return text.slice(startIndex, i + 1);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function findAndParseBalancedObject<T>(text: string): T | null {
|
|
70
|
+
for (let i = 0; i < text.length; i++) {
|
|
71
|
+
if (text[i] === "{") {
|
|
72
|
+
const candidate = extractBalancedObject(text, i);
|
|
73
|
+
if (candidate) {
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(candidate) as T;
|
|
76
|
+
} catch {
|
|
77
|
+
// ignore, try next
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncate(s: string, n: number): string {
|
|
86
|
+
return s.length > n ? s.slice(0, n) + "…" : s;
|
|
87
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createModelManager, type Message as AiMessage } from "../ai";
|
|
2
|
+
|
|
3
|
+
export type Message = AiMessage;
|
|
4
|
+
|
|
5
|
+
export interface ChatOptions {
|
|
6
|
+
model?: string;
|
|
7
|
+
systemPrompt?: string;
|
|
8
|
+
temperature?: number;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
jsonMode?: boolean;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
onUsage?: (usage: import("../ai/types").Usage) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const manager = createModelManager();
|
|
16
|
+
|
|
17
|
+
export async function callLlm(
|
|
18
|
+
messages: Message[],
|
|
19
|
+
options: ChatOptions = {}
|
|
20
|
+
): Promise<string> {
|
|
21
|
+
return manager.call(messages, options);
|
|
22
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { Message } from "./loop";
|
|
2
|
+
import { getLocalJocDir } from "./state";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
export interface SessionHeader {
|
|
7
|
+
type: "session";
|
|
8
|
+
version: number;
|
|
9
|
+
id: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionEntry {
|
|
15
|
+
type: "message";
|
|
16
|
+
timestamp: string;
|
|
17
|
+
message: Message;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionSummary {
|
|
21
|
+
id: string;
|
|
22
|
+
timestamp: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
messageCount: number;
|
|
25
|
+
preview: string;
|
|
26
|
+
mtimeMs?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const SESSION_VERSION = 1;
|
|
30
|
+
|
|
31
|
+
export function newSessionId(): string {
|
|
32
|
+
return crypto.randomUUID();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function sessionsDir(cwd = process.cwd()): string {
|
|
36
|
+
return path.join(getLocalJocDir(cwd), "sessions");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sessionPath(id: string, cwd = process.cwd()): string {
|
|
40
|
+
return path.join(sessionsDir(cwd), `${id}.jsonl`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createSession(
|
|
44
|
+
cwd = process.cwd(),
|
|
45
|
+
id = newSessionId()
|
|
46
|
+
): Promise<{ id: string; path: string }> {
|
|
47
|
+
const dir = sessionsDir(cwd);
|
|
48
|
+
await fs.mkdir(dir, { recursive: true });
|
|
49
|
+
|
|
50
|
+
const header: SessionHeader = {
|
|
51
|
+
type: "session",
|
|
52
|
+
version: SESSION_VERSION,
|
|
53
|
+
id,
|
|
54
|
+
timestamp: new Date().toISOString(),
|
|
55
|
+
cwd,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const file = sessionPath(id, cwd);
|
|
59
|
+
await fs.writeFile(file, JSON.stringify(header) + "\n", "utf8");
|
|
60
|
+
|
|
61
|
+
return { id, path: file };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function appendMessage(
|
|
65
|
+
id: string,
|
|
66
|
+
message: Message,
|
|
67
|
+
cwd = process.cwd()
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const file = sessionPath(id, cwd);
|
|
70
|
+
const entry: SessionEntry = {
|
|
71
|
+
type: "message",
|
|
72
|
+
timestamp: new Date().toISOString(),
|
|
73
|
+
message,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
await fs.appendFile(file, JSON.stringify(entry) + "\n", "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function loadSession(
|
|
80
|
+
id: string,
|
|
81
|
+
cwd = process.cwd()
|
|
82
|
+
): Promise<{ header: SessionHeader; messages: Message[] }> {
|
|
83
|
+
const file = sessionPath(id, cwd);
|
|
84
|
+
let content: string;
|
|
85
|
+
try {
|
|
86
|
+
content = await fs.readFile(file, "utf8");
|
|
87
|
+
} catch (err: any) {
|
|
88
|
+
if (err.code === "ENOENT") {
|
|
89
|
+
throw new Error(`Session ${id} not found: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const lines = content.split("\n");
|
|
95
|
+
let header: SessionHeader | undefined;
|
|
96
|
+
const messages: Message[] = [];
|
|
97
|
+
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (!line.trim()) continue;
|
|
100
|
+
try {
|
|
101
|
+
const entry = JSON.parse(line);
|
|
102
|
+
if (entry && typeof entry === "object") {
|
|
103
|
+
if (entry.type === "session" && !header) {
|
|
104
|
+
header = entry as SessionHeader;
|
|
105
|
+
} else if (entry.type === "message") {
|
|
106
|
+
messages.push(entry.message);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (!header) {
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!header) {
|
|
118
|
+
throw new Error(`Session header missing in session ${id}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { header, messages };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[]> {
|
|
125
|
+
const dir = sessionsDir(cwd);
|
|
126
|
+
let files: string[];
|
|
127
|
+
try {
|
|
128
|
+
files = await fs.readdir(dir);
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
if (err.code === "ENOENT") {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const jsonlFiles = files.filter(f => f.endsWith(".jsonl"));
|
|
137
|
+
const summaries: SessionSummary[] = [];
|
|
138
|
+
|
|
139
|
+
for (const file of jsonlFiles) {
|
|
140
|
+
try {
|
|
141
|
+
const filePath = path.join(dir, file);
|
|
142
|
+
const stat = await fs.stat(filePath);
|
|
143
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
144
|
+
const lines = content.split("\n");
|
|
145
|
+
let header: SessionHeader | undefined;
|
|
146
|
+
let messageCount = 0;
|
|
147
|
+
let firstUserMessageContent: string | undefined;
|
|
148
|
+
|
|
149
|
+
for (const line of lines) {
|
|
150
|
+
if (!line.trim()) continue;
|
|
151
|
+
try {
|
|
152
|
+
const entry = JSON.parse(line);
|
|
153
|
+
if (entry && typeof entry === "object") {
|
|
154
|
+
if (entry.type === "session" && !header) {
|
|
155
|
+
header = entry as SessionHeader;
|
|
156
|
+
} else if (entry.type === "message") {
|
|
157
|
+
messageCount++;
|
|
158
|
+
if (!firstUserMessageContent && entry.message?.role === "user") {
|
|
159
|
+
firstUserMessageContent = entry.message.content;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (!header) {
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!header) {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const preview = firstUserMessageContent ? firstUserMessageContent.slice(0, 60) : "";
|
|
176
|
+
|
|
177
|
+
summaries.push({
|
|
178
|
+
id: header.id,
|
|
179
|
+
timestamp: header.timestamp,
|
|
180
|
+
cwd: header.cwd,
|
|
181
|
+
messageCount,
|
|
182
|
+
preview,
|
|
183
|
+
mtimeMs: stat.mtimeMs,
|
|
184
|
+
});
|
|
185
|
+
} catch {
|
|
186
|
+
// Tolerate malformed files (skip them)
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
summaries.sort((a, b) => (b.mtimeMs ?? 0) - (a.mtimeMs ?? 0));
|
|
192
|
+
return summaries;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function latestSessionId(cwd = process.cwd()): Promise<string | undefined> {
|
|
196
|
+
const list = await listSessions(cwd);
|
|
197
|
+
return list[0]?.id;
|
|
198
|
+
}
|