jeo-code 0.1.0 → 0.4.5

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.
Files changed (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +808 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +624 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -0,0 +1,64 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+
4
+ export const TOOL_SPILL_THRESHOLD = 32_000;
5
+
6
+ export function truncateToolOutput(output: string, limit = 8000): string {
7
+ if (output.length <= limit) return output;
8
+ const half = Math.floor(limit / 2);
9
+ return output.slice(0, half) + "\n\n... (elided " + (output.length - limit) + " chars) ...\n\n" + output.slice(-half);
10
+ }
11
+
12
+ export async function spillToolResult(tool: string, output: string, cwd: string): Promise<string> {
13
+ const artifactsDir = path.join(cwd, ".jeo", "artifacts");
14
+ await fs.mkdir(artifactsDir, { recursive: true });
15
+
16
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
17
+ const filename = "result-" + tool + "-" + ts + ".txt";
18
+ const fullPath = path.join(artifactsDir, filename);
19
+
20
+ await fs.writeFile(fullPath, output, "utf-8");
21
+ return path.relative(cwd, fullPath);
22
+ }
23
+
24
+ /** One tool execution's performance record (consumed by dev self-analysis). */
25
+ export interface PerfMetric {
26
+ tool: string;
27
+ duration: number;
28
+ success: boolean;
29
+ error?: string;
30
+ }
31
+
32
+ /** Append a tool performance metric to `.jeo/state/performance-metrics.json`
33
+ * (bounded to the most recent 200 records). Best-effort: failures are ignored
34
+ * so metrics can never break an agent turn. */
35
+ export async function logPerformanceMetric(cwd: string, metric: PerfMetric): Promise<void> {
36
+ try {
37
+ const dir = path.join(cwd, ".jeo", "state");
38
+ const file = path.join(dir, "performance-metrics.json");
39
+ await fs.mkdir(dir, { recursive: true });
40
+ let records: PerfMetric[] = [];
41
+ try {
42
+ const parsed = JSON.parse(await fs.readFile(file, "utf-8"));
43
+ if (Array.isArray(parsed)) records = parsed;
44
+ } catch { /* first write or corrupt file → start fresh */ }
45
+ records.push(metric);
46
+ if (records.length > 200) records = records.slice(-200);
47
+ await fs.writeFile(file, JSON.stringify(records), "utf-8");
48
+ } catch { /* best-effort */ }
49
+ }
50
+
51
+ /** Read spec-kit project context (`.specify/memory/constitution.md`) when the repo
52
+ * was initialized with GitHub spec-kit, so the agent honors the project
53
+ * constitution. Returns null when absent/unreadable; content is capped. */
54
+ export async function loadSpecKitContext(cwd: string): Promise<string | null> {
55
+ try {
56
+ const file = path.join(cwd, ".specify", "memory", "constitution.md");
57
+ const content = (await fs.readFile(file, "utf-8")).trim();
58
+ if (!content) return null;
59
+ const capped = content.length > 4000 ? content.slice(0, 4000) + "…" : content;
60
+ return "\n\n<spec-kit-constitution>\n" + capped + "\n</spec-kit-constitution>";
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Shared plan-document schema + minimal YAML parser for jeo workflow plans.
3
+ * Used by both `jeo team` (execution) and `jeo ralplan` (plan generation/validation).
4
+ */
5
+ import { z } from "zod";
6
+
7
+ /** Dependency-shaped keys the SERIAL executor cannot honor: `jeo team` runs steps
8
+ * in array order, so a plan that EXPRESSES ordering constraints must fail loudly
9
+ * instead of silently creating the illusion they are enforced (round-10 LOW). */
10
+ const UNSUPPORTED_DEP_KEYS = ["depends_on", "dependsOn", "after", "needs", "requires", "dependencies"] as const;
11
+
12
+ export const StepSchema = z.object({
13
+ name: z.string(),
14
+ /** Optional subagent role for this step (executor/planner/architect/critic). */
15
+ role: z.string().optional(),
16
+ }).passthrough().superRefine((step, ctx) => {
17
+ for (const key of UNSUPPORTED_DEP_KEYS) {
18
+ if (key in step) {
19
+ ctx.addIssue({
20
+ code: z.ZodIssueCode.custom,
21
+ message: `step "${String((step as Record<string, unknown>).name ?? "?")}" declares "${key}", but jeo team executes steps strictly in array order and does NOT honor dependency metadata — reorder the steps array instead and remove "${key}".`,
22
+ });
23
+ }
24
+ }
25
+ });
26
+
27
+ export const PlanSchema = z.object({
28
+ name: z.string().optional(),
29
+ steps: z.array(StepSchema).min(1),
30
+ }).passthrough();
31
+
32
+ /**
33
+ * Tolerate common planning-model deviations so a valid-enough plan still executes:
34
+ * a top-level list of tasks, a `tasks:` alias for `steps:`, bare-string tasks, and
35
+ * step name under `task`/`title`/`description`/`step`.
36
+ */
37
+ export function normalizePlanShape(raw: any): any {
38
+ let plan = raw;
39
+ if (Array.isArray(plan)) plan = { steps: plan };
40
+ if (plan && typeof plan === "object" && !Array.isArray(plan)) {
41
+ if (!Array.isArray(plan.steps) && Array.isArray(plan.tasks)) plan = { ...plan, steps: plan.tasks };
42
+ if (Array.isArray(plan.steps)) {
43
+ plan = {
44
+ ...plan,
45
+ steps: plan.steps.map((s: any) =>
46
+ typeof s === "string"
47
+ ? { name: s }
48
+ : s && typeof s === "object" && !s.name
49
+ ? { ...s, name: s.task ?? s.title ?? s.description ?? s.step ?? "" }
50
+ : s,
51
+ ).filter((s: any) => s && typeof s.name === "string" && s.name.trim() !== ""),
52
+ };
53
+ }
54
+ }
55
+ return plan;
56
+ }
57
+
58
+ function parseValue(v: string): any {
59
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
60
+ return v.slice(1, -1);
61
+ }
62
+ if (v === "true") return true;
63
+ if (v === "false") return false;
64
+ if (v === "null") return null;
65
+ if (v === "") return "";
66
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
67
+ return v;
68
+ }
69
+
70
+ export function parseYaml(yamlStr: string): any {
71
+ const lines = yamlStr.split(/\r?\n/).map(line => {
72
+ const commentIdx = line.indexOf('#');
73
+ const cleanLine = commentIdx !== -1 ? line.slice(0, commentIdx) : line;
74
+ return {
75
+ trimmed: cleanLine.trim(),
76
+ indent: cleanLine.length - cleanLine.trimStart().length
77
+ };
78
+ }).filter(l => l.trimmed !== '');
79
+
80
+ let idx = 0;
81
+
82
+ function parseBlock(baseIndent: number): any {
83
+ let result: any = null;
84
+ let isArray = false;
85
+
86
+ if (idx < lines.length) {
87
+ if (lines[idx].trimmed.startsWith('-')) {
88
+ isArray = true;
89
+ result = [];
90
+ } else {
91
+ result = {};
92
+ }
93
+ }
94
+
95
+ while (idx < lines.length) {
96
+ const line = lines[idx];
97
+ if (line.indent < baseIndent) {
98
+ break;
99
+ }
100
+
101
+ if (isArray) {
102
+ if (!line.trimmed.startsWith('-')) {
103
+ if (result.length > 0 && typeof result[result.length - 1] === 'object') {
104
+ const colonIdx = line.trimmed.indexOf(':');
105
+ if (colonIdx !== -1) {
106
+ const k = line.trimmed.slice(0, colonIdx).trim();
107
+ const rawVal = line.trimmed.slice(colonIdx + 1).trim();
108
+ if (rawVal === '') {
109
+ idx++;
110
+ result[result.length - 1][k] = parseBlock(line.indent + 1);
111
+ continue;
112
+ } else {
113
+ result[result.length - 1][k] = parseValue(rawVal);
114
+ }
115
+ } else {
116
+ throw new Error(`Invalid line inside array block: "${line.trimmed}"`);
117
+ }
118
+ } else {
119
+ throw new Error(`Invalid line in array: "${line.trimmed}"`);
120
+ }
121
+ idx++;
122
+ continue;
123
+ }
124
+
125
+ const rest = line.trimmed.slice(1).trim();
126
+ if (rest === '') {
127
+ idx++;
128
+ const nested = parseBlock(line.indent + 1);
129
+ result.push(nested);
130
+ } else if (rest.includes(':')) {
131
+ const colonIdx = rest.indexOf(':');
132
+ const k = rest.slice(0, colonIdx).trim();
133
+ const rawVal = rest.slice(colonIdx + 1).trim();
134
+ if (rawVal === '') {
135
+ idx++;
136
+ const nestedObj = { [k]: parseBlock(line.indent + 2) };
137
+ result.push(nestedObj);
138
+ } else {
139
+ const item: any = { [k]: parseValue(rawVal) };
140
+ result.push(item);
141
+ idx++;
142
+ while (idx < lines.length && !lines[idx].trimmed.startsWith('-') && lines[idx].indent >= line.indent + 2) {
143
+ const subLine = lines[idx];
144
+ const subColonIdx = subLine.trimmed.indexOf(':');
145
+ if (subColonIdx !== -1) {
146
+ const subK = subLine.trimmed.slice(0, subColonIdx).trim();
147
+ const rawSubVal = subLine.trimmed.slice(subColonIdx + 1).trim();
148
+ if (rawSubVal === '') {
149
+ idx++;
150
+ item[subK] = parseBlock(subLine.indent + 1);
151
+ } else {
152
+ item[subK] = parseValue(rawSubVal);
153
+ idx++;
154
+ }
155
+ } else {
156
+ throw new Error(`Invalid sub-line in block mapping: "${subLine.trimmed}"`);
157
+ }
158
+ }
159
+ }
160
+ } else {
161
+ result.push(parseValue(rest));
162
+ idx++;
163
+ }
164
+ } else {
165
+ const colonIdx = line.trimmed.indexOf(':');
166
+ if (colonIdx === -1) {
167
+ throw new Error(`Invalid line: "${line.trimmed}"`);
168
+ }
169
+
170
+ const k = line.trimmed.slice(0, colonIdx).trim();
171
+ const rawVal = line.trimmed.slice(colonIdx + 1).trim();
172
+
173
+ if (rawVal === '') {
174
+ idx++;
175
+ result[k] = parseBlock(line.indent + 1);
176
+ } else {
177
+ result[k] = parseValue(rawVal);
178
+ idx++;
179
+ }
180
+ }
181
+ }
182
+
183
+ return result;
184
+ }
185
+
186
+ return parseBlock(0);
187
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared seed-document helpers (round-12, architect ref 8-Round10Planning #5).
3
+ *
4
+ * The deep-interview WRITER and the ultragoal READER of seed lists used to live
5
+ * in different files with different rules — the writer JSON-encoded each value
6
+ * while the reader stripped EVERY double quote, so a criterion like
7
+ * `Display "Done" message` was mangled to `Display \Done\ message` by the time
8
+ * it reached the verification report. Writer and parser now share one module
9
+ * and one encoding, and deep-interview asserts the round-trip at freeze time so
10
+ * any future drift fails loudly instead of corrupting the ledger silently.
11
+ */
12
+
13
+ /** Serialize a named YAML list with JSON-encoded scalar items. */
14
+ export function yamlList(name: string, values: string[]): string {
15
+ if (values.length === 0) return `${name}: []`;
16
+ return `${name}:\n${values.map(value => ` - ${JSON.stringify(value)}`).join("\n")}`;
17
+ }
18
+
19
+ /** Parse a named list out of a seed document written by `yamlList` (with a
20
+ * lenient fallback for legacy / hand-edited unquoted items). */
21
+ export function parseSeedList(content: string, name: string): string[] {
22
+ const out: string[] = [];
23
+ let inList = false;
24
+ for (const line of content.split("\n")) {
25
+ const trimmed = line.trim();
26
+ if (trimmed.startsWith(`${name}:`)) {
27
+ inList = true;
28
+ continue;
29
+ }
30
+ if (!inList) continue;
31
+ if (trimmed.startsWith("- ")) {
32
+ const rawValue = trimmed.replace(/^-\s*/, "");
33
+ if (rawValue.startsWith('"')) {
34
+ try {
35
+ out.push(JSON.parse(rawValue) as string);
36
+ continue;
37
+ } catch { /* not a clean JSON string — fall through to the lenient path */ }
38
+ }
39
+ // Legacy/hand-written item: strip only a MATCHED outer quote pair, never
40
+ // interior quotes (the old reader's replace(/"/g,"") mangled those).
41
+ out.push(rawValue.replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1").trim());
42
+ } else if (trimmed === "" || /^[A-Za-z_][\w-]*:/.test(trimmed)) {
43
+ inList = false; // blank line or next section header ends the list
44
+ }
45
+ }
46
+ return out.filter(v => v.length > 0);
47
+ }
48
+
49
+ /** The acceptance-criteria list ultragoal verifies against. */
50
+ export function parseSeedAcceptanceCriteria(content: string): string[] {
51
+ return parseSeedList(content, "acceptance_criteria");
52
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Message } from "./loop";
2
- import { getLocalJocDir } from "./state";
2
+ import { getLocalJeoDir } from "./state";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
 
@@ -9,6 +9,7 @@ export interface SessionHeader {
9
9
  id: string;
10
10
  timestamp: string;
11
11
  cwd: string;
12
+ title?: string;
12
13
  }
13
14
 
14
15
  export interface SessionEntry {
@@ -17,6 +18,13 @@ export interface SessionEntry {
17
18
  message: Message;
18
19
  }
19
20
 
21
+ export interface CompactionEntry {
22
+ type: "compaction";
23
+ timestamp: string;
24
+ seq: number;
25
+ summary: string;
26
+ replacesThrough: number;
27
+ }
20
28
  export interface SessionSummary {
21
29
  id: string;
22
30
  timestamp: string;
@@ -24,6 +32,7 @@ export interface SessionSummary {
24
32
  messageCount: number;
25
33
  preview: string;
26
34
  mtimeMs?: number;
35
+ title?: string;
27
36
  }
28
37
 
29
38
  export const SESSION_VERSION = 1;
@@ -33,7 +42,7 @@ export function newSessionId(): string {
33
42
  }
34
43
 
35
44
  export function sessionsDir(cwd = process.cwd()): string {
36
- return path.join(getLocalJocDir(cwd), "sessions");
45
+ return path.join(getLocalJeoDir(cwd), "sessions");
37
46
  }
38
47
 
39
48
  export function sessionPath(id: string, cwd = process.cwd()): string {
@@ -76,6 +85,41 @@ export async function appendMessage(
76
85
  await fs.appendFile(file, JSON.stringify(entry) + "\n", "utf8");
77
86
  }
78
87
 
88
+ /** Append a batch of messages with ONE fs append (turn-end persistence: a long
89
+ * turn previously issued one sequential appendFile per intermediate message). */
90
+ export async function appendMessages(
91
+ id: string,
92
+ messages: readonly Message[],
93
+ cwd = process.cwd()
94
+ ): Promise<void> {
95
+ if (messages.length === 0) return;
96
+ const file = sessionPath(id, cwd);
97
+ const timestamp = new Date().toISOString();
98
+ const chunk = messages
99
+ .map(message => JSON.stringify({ type: "message", timestamp, message } satisfies SessionEntry))
100
+ .join("\n") + "\n";
101
+ await fs.appendFile(file, chunk, "utf8");
102
+ }
103
+
104
+ export async function appendCompaction(
105
+ id: string,
106
+ seq: number,
107
+ summary: string,
108
+ replacesThrough: number,
109
+ cwd = process.cwd()
110
+ ): Promise<void> {
111
+ const file = sessionPath(id, cwd);
112
+ const entry: CompactionEntry = {
113
+ type: "compaction",
114
+ timestamp: new Date().toISOString(),
115
+ seq,
116
+ summary,
117
+ replacesThrough,
118
+ };
119
+
120
+ await fs.appendFile(file, JSON.stringify(entry) + "\n", "utf8");
121
+ }
122
+
79
123
  export async function loadSession(
80
124
  id: string,
81
125
  cwd = process.cwd()
@@ -93,7 +137,8 @@ export async function loadSession(
93
137
 
94
138
  const lines = content.split("\n");
95
139
  let header: SessionHeader | undefined;
96
- const messages: Message[] = [];
140
+ const rawMessages: Message[] = [];
141
+ const compactions: CompactionEntry[] = [];
97
142
 
98
143
  for (const line of lines) {
99
144
  if (!line.trim()) continue;
@@ -103,7 +148,9 @@ export async function loadSession(
103
148
  if (entry.type === "session" && !header) {
104
149
  header = entry as SessionHeader;
105
150
  } else if (entry.type === "message") {
106
- messages.push(entry.message);
151
+ rawMessages.push(entry.message);
152
+ } else if (entry.type === "compaction") {
153
+ compactions.push(entry as CompactionEntry);
107
154
  }
108
155
  }
109
156
  } catch (err) {
@@ -118,6 +165,23 @@ export async function loadSession(
118
165
  throw new Error(`Session header missing in session ${id}`);
119
166
  }
120
167
 
168
+ let messages = rawMessages;
169
+ if (compactions.length > 0) {
170
+ const lastComp = compactions[compactions.length - 1];
171
+ const { summary, replacesThrough } = lastComp;
172
+
173
+ const hasSystem = rawMessages.length > 0 && rawMessages[0].role === "system";
174
+ const systemPrompt = hasSystem ? [rawMessages[0]] : [];
175
+
176
+ const summaryMessage: Message = {
177
+ role: "user",
178
+ content: `[Earlier conversation summary]\n${summary}`,
179
+ };
180
+
181
+ const remaining = rawMessages.slice(replacesThrough + 1);
182
+ messages = [...systemPrompt, summaryMessage, ...remaining];
183
+ }
184
+
121
185
  return { header, messages };
122
186
  }
123
187
 
@@ -143,33 +207,76 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
143
207
  const content = await fs.readFile(filePath, "utf8");
144
208
  const lines = content.split("\n");
145
209
  let header: SessionHeader | undefined;
146
- let messageCount = 0;
147
- let firstUserMessageContent: string | undefined;
148
210
 
211
+ // 1. 헤더만 JSON.parse하여 획득 (가장 첫 valid JSON)
149
212
  for (const line of lines) {
150
213
  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") {
214
+ if (line.includes('"type":"session"')) {
215
+ try {
216
+ header = JSON.parse(line);
217
+ break;
218
+ } catch {
219
+ // continue
220
+ }
221
+ }
222
+ }
223
+
224
+ if (!header) {
225
+ continue;
226
+ }
227
+
228
+ // 2. 마지막 compaction 마커 라인을 역순 탐색
229
+ let lastCompaction: CompactionEntry | undefined;
230
+ for (let i = lines.length - 1; i >= 0; i--) {
231
+ const line = lines[i];
232
+ if (line.includes('"type":"compaction"')) {
233
+ try {
234
+ lastCompaction = JSON.parse(line);
235
+ break;
236
+ } catch {
237
+ // continue
238
+ }
239
+ }
240
+ }
241
+
242
+ // 3. JSON.parse 오버헤드 최소화하며 카운팅 및 프리뷰 추출
243
+ let messageCount = 0;
244
+ let firstUserMessageContent: string | undefined;
245
+
246
+ if (lastCompaction) {
247
+ let msgIndex = 0;
248
+ for (const line of lines) {
249
+ if (!line.trim()) continue;
250
+ if (line.includes('"type":"message"')) {
251
+ if (msgIndex > lastCompaction.replacesThrough) {
157
252
  messageCount++;
158
- if (!firstUserMessageContent && entry.message?.role === "user") {
159
- firstUserMessageContent = entry.message.content;
160
- }
161
253
  }
254
+ msgIndex++;
162
255
  }
163
- } catch (err) {
164
- if (!header) {
165
- throw err;
256
+ }
257
+ messageCount += 1; // summary message
258
+ } else {
259
+ for (const line of lines) {
260
+ if (!line.trim()) continue;
261
+ if (line.includes('"type":"message"')) {
262
+ messageCount++;
166
263
  }
167
- continue;
168
264
  }
169
265
  }
170
266
 
171
- if (!header) {
172
- continue;
267
+ // 4. preview를 위한 첫 user message 추출
268
+ for (const line of lines) {
269
+ if (line.includes('"type":"message"') && line.includes('"role":"user"')) {
270
+ try {
271
+ const parsed = JSON.parse(line);
272
+ if (parsed?.message?.content) {
273
+ firstUserMessageContent = parsed.message.content;
274
+ break;
275
+ }
276
+ } catch {
277
+ // continue
278
+ }
279
+ }
173
280
  }
174
281
 
175
282
  const preview = firstUserMessageContent ? firstUserMessageContent.slice(0, 60) : "";
@@ -181,6 +288,7 @@ export async function listSessions(cwd = process.cwd()): Promise<SessionSummary[
181
288
  messageCount,
182
289
  preview,
183
290
  mtimeMs: stat.mtimeMs,
291
+ title: header.title,
184
292
  });
185
293
  } catch {
186
294
  // Tolerate malformed files (skip them)
@@ -196,3 +304,109 @@ export async function latestSessionId(cwd = process.cwd()): Promise<string | und
196
304
  const list = await listSessions(cwd);
197
305
  return list[0]?.id;
198
306
  }
307
+
308
+ /**
309
+ * Rename a session by updating the title in its JSONL header.
310
+ * Throws a clear Error if the session file does not exist.
311
+ */
312
+ export async function renameSession(id: string, title: string, cwd = process.cwd()): Promise<void> {
313
+ const file = sessionPath(id, cwd);
314
+ let content: string;
315
+ try {
316
+ content = await fs.readFile(file, "utf8");
317
+ } catch (err: any) {
318
+ if (err.code === "ENOENT") {
319
+ throw new Error(`Session ${id} does not exist: ${err.message}`);
320
+ }
321
+ throw err;
322
+ }
323
+
324
+ const lines = content.split("\n");
325
+ let headerIndex = -1;
326
+ let header: SessionHeader | undefined;
327
+
328
+ for (let i = 0; i < lines.length; i++) {
329
+ if (lines[i].trim()) {
330
+ try {
331
+ const parsed = JSON.parse(lines[i]);
332
+ if (parsed && typeof parsed === "object" && parsed.type === "session") {
333
+ header = parsed as SessionHeader;
334
+ headerIndex = i;
335
+ break;
336
+ }
337
+ } catch {
338
+ // tolerate parsing error or check next line
339
+ }
340
+ }
341
+ }
342
+
343
+ if (headerIndex === -1 || !header) {
344
+ throw new Error(`Session header missing in session ${id}`);
345
+ }
346
+
347
+ header.title = title;
348
+ lines[headerIndex] = JSON.stringify(header);
349
+ await fs.writeFile(file, lines.join("\n"), "utf8");
350
+ }
351
+
352
+ /**
353
+ * Delete a session file.
354
+ * Returns false on ENOENT, true on success.
355
+ */
356
+ export async function deleteSession(id: string, cwd = process.cwd()): Promise<boolean> {
357
+ const file = sessionPath(id, cwd);
358
+ try {
359
+ await fs.unlink(file);
360
+ return true;
361
+ } catch (err: any) {
362
+ if (err.code === "ENOENT") {
363
+ return false;
364
+ }
365
+ throw err;
366
+ }
367
+ }
368
+
369
+ export interface ExportOptions {
370
+ /** Include system messages in the export (default false — they're boilerplate). */
371
+ includeSystem?: boolean;
372
+ }
373
+
374
+ /**
375
+ * Render a saved session to Markdown or JSON for handoff, bug reports, and audit
376
+ * trails. Reuses `loadSession` (which tolerates a malformed trailing line).
377
+ */
378
+ export async function exportSession(
379
+ id: string,
380
+ format: "markdown" | "json" = "markdown",
381
+ cwd = process.cwd(),
382
+ opts: ExportOptions = {},
383
+ ): Promise<string> {
384
+ const { header, messages } = await loadSession(id, cwd);
385
+ const picked = opts.includeSystem ? messages : messages.filter(m => m.role !== "system");
386
+
387
+ if (format === "json") {
388
+ return JSON.stringify(
389
+ { id: header.id, timestamp: header.timestamp, cwd: header.cwd, messageCount: picked.length, messages: picked },
390
+ null,
391
+ 2,
392
+ );
393
+ }
394
+
395
+ const lines: string[] = [
396
+ `# jeo session ${header.id}`,
397
+ "",
398
+ `- Started: ${header.timestamp}`,
399
+ `- Workspace: ${header.cwd}`,
400
+ `- Messages: ${picked.length}`,
401
+ "",
402
+ ];
403
+ for (const m of picked) {
404
+ const role = m.role.charAt(0).toUpperCase() + m.role.slice(1);
405
+ // Fence longer than the longest backtick run in the body (CommonMark) so message
406
+ // content containing ``` doesn't prematurely close the code fence.
407
+ const longest = (m.content.match(/`+/g) ?? []).reduce((mx, r) => Math.max(mx, r.length), 0);
408
+ const fence = "`".repeat(Math.max(3, longest + 1));
409
+ lines.push(`## ${role}`, "", fence, m.content, fence, "");
410
+ }
411
+ return lines.join("\n");
412
+ }