jeo-code 0.6.10 → 0.6.12
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/CHANGELOG.md +15 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/memory.ts +273 -18
- package/src/ai/model-manager.ts +5 -5
- package/src/ai/providers/anthropic.ts +2 -2
- package/src/ai/providers/gemini.ts +3 -3
- package/src/commands/launch/input.ts +35 -6
- package/src/commands/launch.ts +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.6.12] - 2026-06-16
|
|
10
|
+
_OKF-backed memory distillation — session learnings become structured concept files._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **OKF-backed memory distillation (OKF Sprint 02).** Session distillation now emits structured OKF v0.1 concept files — each with `type` / `title` / `description` / `body` / `tags` / `confidence` / `links` — filed into typed directories (unknown types fall back to `facts/`), deduped by title, merged with the existing concept bundle, then indexed and logged. The distiller prompts the model for a `{ concepts: [...] }` payload and parses it leniently (`tryExtractJsonObject`) so text-only providers still land. Builds on the 0.6.10 OKF format layer.
|
|
14
|
+
|
|
15
|
+
## [0.6.11] - 2026-06-16
|
|
16
|
+
_Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt._
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **Larger thinking-token budgets across every level.** `thinkingMaxTokens` is raised (minimal 1k→4k, low 2k→8k, default 4k→16k, high 8k→24k, xhigh 16k→32k), along with the Anthropic (medium 4k→10k, high 10k→24k) and Gemini (low→4k, medium→10k, high→24k) per-provider budgets — so each reasoning level actually gets room to think.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- **Terminal capability-response sequences no longer corrupt the prompt.** The mouse-report filter (0.6.7) now also swallows Device-Attributes / mode replies that the outer terminal sends when tmux probes it on attach, on both the idle keypress path and the live-turn raw-stdin drain.
|
|
23
|
+
|
|
9
24
|
## [0.6.10] - 2026-06-16
|
|
10
25
|
_OKF memory-format foundation and a hardened bashTool subprocess drain._
|
|
11
26
|
|
package/README.ja.md
CHANGED
|
@@ -158,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
158
158
|
## 変更履歴 (Changelog)
|
|
159
159
|
|
|
160
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
162
|
+
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
161
163
|
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
164
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
163
165
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
164
|
-
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
165
|
-
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -158,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
158
158
|
## 변경 이력 (Changelog)
|
|
159
159
|
|
|
160
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
162
|
+
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
161
163
|
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
164
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
163
165
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
164
|
-
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
165
|
-
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -158,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
158
158
|
## Changelog
|
|
159
159
|
|
|
160
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
162
|
+
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
161
163
|
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
164
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
163
165
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
164
|
-
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
165
|
-
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -158,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
158
158
|
## 更新日志 (Changelog)
|
|
159
159
|
|
|
160
160
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
161
|
+
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
162
|
+
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
161
163
|
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
162
164
|
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
163
165
|
- **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
|
|
164
|
-
- **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
|
|
165
|
-
- **[0.6.6]** (2026-06-16) — Vertical caret movement between input-box rows, a centered welcome banner, and a leaner `parseFlags`.
|
|
166
166
|
|
|
167
167
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
168
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/memory.ts
CHANGED
|
@@ -13,6 +13,8 @@ import { spawn as nodeSpawn } from "node:child_process";
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import { callLlm, type Message } from "./loop";
|
|
15
15
|
import { jeoEnv } from "../util/env";
|
|
16
|
+
import { parseConcept, serializeConcept, slugify, isReservedFile } from "./memory-okf";
|
|
17
|
+
import { tryExtractJsonObject } from "./json";
|
|
16
18
|
|
|
17
19
|
/** On-disk document cap — the distill prompt instructs the model to stay under it. */
|
|
18
20
|
export const MEMORY_MAX_CHARS = 6_000;
|
|
@@ -22,6 +24,16 @@ export const MEMORY_INJECT_MAX_CHARS = 3_000;
|
|
|
22
24
|
const TRANSCRIPT_MAX_CHARS = 12_000;
|
|
23
25
|
/** A session shorter than this has nothing durable to learn. */
|
|
24
26
|
const MIN_HISTORY_MESSAGES = 4;
|
|
27
|
+
/** Single source of truth for the four jeo concept types the distiller files:
|
|
28
|
+
* type → on-disk subdir → index.md section header, in display order. Add a
|
|
29
|
+
* row here (one place) to introduce a new filed/rendered type. */
|
|
30
|
+
const TYPE_LAYOUT = [
|
|
31
|
+
{ type: "RepoFact", dir: "facts", header: "Repo Facts" },
|
|
32
|
+
{ type: "Command", dir: "commands", header: "Commands" },
|
|
33
|
+
{ type: "Gotcha", dir: "gotchas", header: "Gotchas" },
|
|
34
|
+
{ type: "UserPreference", dir: "preferences", header: "User Preferences" },
|
|
35
|
+
] as const;
|
|
36
|
+
const DIR_BY_TYPE: Record<string, string> = Object.fromEntries(TYPE_LAYOUT.map(t => [t.type, t.dir]));
|
|
25
37
|
|
|
26
38
|
export function memoryFilePath(cwd: string): string {
|
|
27
39
|
return path.join(cwd, ".jeo", "memory", "MEMORY.md");
|
|
@@ -83,6 +95,135 @@ export interface DistillResult {
|
|
|
83
95
|
* Best-effort by design: any failure is reported in the result, never thrown —
|
|
84
96
|
* a memory write must not be able to break session exit.
|
|
85
97
|
*/
|
|
98
|
+
|
|
99
|
+
async function findMarkdownFiles(dir: string): Promise<string[]> {
|
|
100
|
+
const files: string[] = [];
|
|
101
|
+
async function recurse(currentDir: string) {
|
|
102
|
+
let entries;
|
|
103
|
+
try {
|
|
104
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
if (entry.name === "raw") continue;
|
|
112
|
+
await recurse(fullPath);
|
|
113
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
114
|
+
files.push(fullPath);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await recurse(dir);
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function rebuildIndex(bundleDir: string): Promise<void> {
|
|
123
|
+
const files = await findMarkdownFiles(bundleDir);
|
|
124
|
+
const concepts: { type: string; title: string; relPath: string }[] = [];
|
|
125
|
+
for (const file of files) {
|
|
126
|
+
const relPath = path.relative(bundleDir, file);
|
|
127
|
+
if (isReservedFile(relPath)) continue;
|
|
128
|
+
try {
|
|
129
|
+
const content = await fs.readFile(file, "utf-8");
|
|
130
|
+
const parsed = parseConcept(content);
|
|
131
|
+
concepts.push({
|
|
132
|
+
type: (parsed.frontmatter.type as string) || "RepoFact",
|
|
133
|
+
title: (parsed.frontmatter.title as string) || path.basename(file, ".md"),
|
|
134
|
+
relPath,
|
|
135
|
+
});
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let body = "# Index\n\n";
|
|
142
|
+
for (const { type, header } of TYPE_LAYOUT) {
|
|
143
|
+
const list = concepts.filter(c => c.type === type);
|
|
144
|
+
if (list.length === 0) continue;
|
|
145
|
+
body += `## ${header}\n`;
|
|
146
|
+
for (const c of list) {
|
|
147
|
+
body += `- [${c.title}](/${c.relPath.replace(/\\/g, "/")})\n`;
|
|
148
|
+
}
|
|
149
|
+
body += "\n";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const indexContent = serializeConcept({ okf_version: "0.1" }, body.trim());
|
|
153
|
+
const indexPath = path.join(bundleDir, "index.md");
|
|
154
|
+
const tmpPath = `${indexPath}.tmp-${process.pid}`;
|
|
155
|
+
await fs.writeFile(tmpPath, indexContent, "utf-8");
|
|
156
|
+
await fs.rename(tmpPath, indexPath);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function updateLog(bundleDir: string, updatedConcepts: { title: string; type: string }[]): Promise<void> {
|
|
160
|
+
const logPath = path.join(bundleDir, "log.md");
|
|
161
|
+
let existingContent = "";
|
|
162
|
+
try {
|
|
163
|
+
existingContent = await fs.readFile(logPath, "utf-8");
|
|
164
|
+
} catch {
|
|
165
|
+
existingContent = "# Directory Update Log\n";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const today = new Date().toISOString().split("T")[0];
|
|
169
|
+
const heading = `## ${today}`;
|
|
170
|
+
|
|
171
|
+
let entry = "";
|
|
172
|
+
for (const c of updatedConcepts) {
|
|
173
|
+
entry += `* **${c.type}**: ${c.title}\n`;
|
|
174
|
+
}
|
|
175
|
+
if (!entry) return;
|
|
176
|
+
|
|
177
|
+
let newContent = "";
|
|
178
|
+
if (existingContent.includes(heading)) {
|
|
179
|
+
const lines = existingContent.split("\n");
|
|
180
|
+
const idx = lines.findIndex(l => l.trim() === heading);
|
|
181
|
+
lines.splice(idx + 1, 0, entry.trim());
|
|
182
|
+
newContent = lines.join("\n");
|
|
183
|
+
} else {
|
|
184
|
+
const lines = existingContent.split("\n");
|
|
185
|
+
let insertIdx = 0;
|
|
186
|
+
if (lines[0]?.startsWith("# ")) {
|
|
187
|
+
insertIdx = 1;
|
|
188
|
+
while (insertIdx < lines.length && lines[insertIdx].trim() === "") {
|
|
189
|
+
insertIdx++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
lines.splice(insertIdx, 0, `${heading}\n${entry}`);
|
|
193
|
+
newContent = lines.join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tmpPath = `${logPath}.tmp-${process.pid}`;
|
|
197
|
+
await fs.writeFile(tmpPath, newContent, "utf-8");
|
|
198
|
+
await fs.rename(tmpPath, logPath);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function saveRawPayload(bundleDir: string, payload: any): Promise<void> {
|
|
202
|
+
const rawDir = path.join(bundleDir, "raw");
|
|
203
|
+
await fs.mkdir(rawDir, { recursive: true });
|
|
204
|
+
const filename = `session-${Date.now()}-${process.pid}.json`;
|
|
205
|
+
const filePath = path.join(rawDir, filename);
|
|
206
|
+
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function cleanupStalePendingFiles(dir: string): Promise<void> {
|
|
210
|
+
try {
|
|
211
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
212
|
+
const now = Date.now();
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (entry.isFile() && entry.name.startsWith("pending-distill-") && entry.name.endsWith(".json")) {
|
|
215
|
+
const filePath = path.join(dir, entry.name);
|
|
216
|
+
const stat = await fs.stat(filePath);
|
|
217
|
+
if (now - stat.mtimeMs > 24 * 60 * 60 * 1000) {
|
|
218
|
+
await fs.unlink(filePath).catch(() => {});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// ignore
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
86
227
|
export async function distillSessionMemory(
|
|
87
228
|
history: Message[],
|
|
88
229
|
cwd: string,
|
|
@@ -92,44 +233,154 @@ export async function distillSessionMemory(
|
|
|
92
233
|
const body = history.filter(m => m.role !== "system");
|
|
93
234
|
if (body.length < MIN_HISTORY_MESSAGES) return { updated: false, skipped: "session too short" };
|
|
94
235
|
try {
|
|
95
|
-
const
|
|
236
|
+
const bundleDir = path.join(cwd, ".jeo", "memory");
|
|
237
|
+
const existingConcepts: any[] = [];
|
|
238
|
+
try {
|
|
239
|
+
const files = await findMarkdownFiles(bundleDir);
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
const relPath = path.relative(bundleDir, file);
|
|
242
|
+
if (isReservedFile(relPath)) continue;
|
|
243
|
+
try {
|
|
244
|
+
const content = await fs.readFile(file, "utf-8");
|
|
245
|
+
const parsed = parseConcept(content);
|
|
246
|
+
existingConcepts.push({
|
|
247
|
+
type: parsed.frontmatter.type,
|
|
248
|
+
title: parsed.frontmatter.title || "",
|
|
249
|
+
description: parsed.frontmatter.description,
|
|
250
|
+
body: parsed.body,
|
|
251
|
+
tags: parsed.frontmatter.tags,
|
|
252
|
+
confidence: parsed.frontmatter.confidence,
|
|
253
|
+
links: parsed.frontmatter.links,
|
|
254
|
+
path: relPath,
|
|
255
|
+
});
|
|
256
|
+
} catch {}
|
|
257
|
+
}
|
|
258
|
+
} catch {}
|
|
259
|
+
|
|
96
260
|
const prompt: Message[] = [
|
|
97
261
|
{
|
|
98
262
|
role: "system",
|
|
99
263
|
content:
|
|
100
|
-
"You maintain a compact project memory
|
|
101
|
-
"
|
|
264
|
+
"You maintain a compact project memory bundle for a coding agent. " +
|
|
265
|
+
"Extract durable learnings from the session transcript and merge them with the existing concepts. " +
|
|
102
266
|
"Keep ONLY what helps future sessions in THIS repository: repo facts (structure, conventions, key files), " +
|
|
103
267
|
"commands that work (build/test/run), gotchas (failures and their fixes), and user preferences. " +
|
|
104
268
|
"Drop session-specific noise (one-off tasks, transient errors, conversational detail). " +
|
|
105
|
-
|
|
106
|
-
"
|
|
269
|
+
"You must output a JSON object with a single key \"concepts\", which is an array of concept objects. " +
|
|
270
|
+
"Each concept object must have the following fields:\n" +
|
|
271
|
+
" - \"type\": one of \"RepoFact\", \"Command\", \"Gotcha\", \"UserPreference\"\n" +
|
|
272
|
+
" - \"title\": a short, descriptive title (e.g., \"Bun test runner\")\n" +
|
|
273
|
+
" - \"description\": a brief one-line summary of the concept\n" +
|
|
274
|
+
" - \"body\": the detailed markdown content/body of the concept\n" +
|
|
275
|
+
" - \"tags\": an array of string tags (optional)\n" +
|
|
276
|
+
" - \"confidence\": one of \"high\", \"medium\", \"low\" (optional)\n" +
|
|
277
|
+
" - \"links\": an array of other concept paths/IDs this concept links to (optional)\n\n" +
|
|
278
|
+
"Output ONLY the JSON object. Do not include any markdown formatting, preamble, or explanation."
|
|
107
279
|
},
|
|
108
280
|
{
|
|
109
281
|
role: "user",
|
|
110
282
|
content:
|
|
111
|
-
`Existing
|
|
112
|
-
`Session transcript (tail):\n${transcriptTail(history)}
|
|
113
|
-
}
|
|
283
|
+
`Existing concepts:\n${JSON.stringify(existingConcepts, null, 2)}\n\n` +
|
|
284
|
+
`Session transcript (tail):\n${transcriptTail(history)}`
|
|
285
|
+
}
|
|
114
286
|
];
|
|
287
|
+
|
|
115
288
|
const timeoutMs = opts.timeoutMs ?? 20_000;
|
|
116
289
|
const distilled = await Promise.race([
|
|
117
|
-
callLlm(prompt, { model: opts.model, jsonMode:
|
|
290
|
+
callLlm(prompt, { model: opts.model, jsonMode: true, maxTokens: 2_000 }),
|
|
118
291
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`memory distill timed out after ${timeoutMs}ms`)), timeoutMs)),
|
|
119
292
|
]);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
293
|
+
|
|
294
|
+
// Robust extraction: the distill prompt requests JSON, but text-only providers
|
|
295
|
+
// (the default antigravity backend) routinely wrap it in prose or fences.
|
|
296
|
+
// tryExtractJsonObject recovers the first balanced {...}, tolerating that noise;
|
|
297
|
+
// a null result means the model gave plain text → old MEMORY.md fallback below.
|
|
298
|
+
const parsedJson = tryExtractJsonObject<{ concepts?: unknown }>(distilled);
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if (parsedJson && Array.isArray(parsedJson.concepts)) {
|
|
302
|
+
await fs.mkdir(bundleDir, { recursive: true });
|
|
303
|
+
const updatedConcepts: { title: string; type: string }[] = [];
|
|
304
|
+
|
|
305
|
+
for (const concept of parsedJson.concepts) {
|
|
306
|
+
if (!concept.type || !concept.title) continue;
|
|
307
|
+
// Unknown types fall back to facts/ (lenient — OKF tolerates extra types).
|
|
308
|
+
const dir = DIR_BY_TYPE[concept.type] ?? "facts";
|
|
309
|
+
|
|
310
|
+
const targetDir = path.join(bundleDir, dir);
|
|
311
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
312
|
+
|
|
313
|
+
let slug = slugify(concept.title);
|
|
314
|
+
let relPath = `${dir}/${slug}.md`;
|
|
315
|
+
let fullPath = path.join(bundleDir, relPath);
|
|
316
|
+
|
|
317
|
+
let suffix = 1;
|
|
318
|
+
while (true) {
|
|
319
|
+
try {
|
|
320
|
+
const existingContent = await fs.readFile(fullPath, "utf-8");
|
|
321
|
+
const parsed = parseConcept(existingContent);
|
|
322
|
+
const existingTitle = parsed.frontmatter.title || "";
|
|
323
|
+
if (existingTitle === concept.title) {
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
slug = `${slugify(concept.title)}-${suffix}`;
|
|
327
|
+
relPath = `${dir}/${slug}.md`;
|
|
328
|
+
fullPath = path.join(bundleDir, relPath);
|
|
329
|
+
suffix++;
|
|
330
|
+
} catch {
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let existingFm = {};
|
|
336
|
+
try {
|
|
337
|
+
const existingContent = await fs.readFile(fullPath, "utf-8");
|
|
338
|
+
existingFm = parseConcept(existingContent).frontmatter;
|
|
339
|
+
} catch {}
|
|
340
|
+
|
|
341
|
+
const frontmatter = {
|
|
342
|
+
...existingFm,
|
|
343
|
+
type: concept.type,
|
|
344
|
+
title: concept.title,
|
|
345
|
+
description: concept.description || "",
|
|
346
|
+
tags: concept.tags || [],
|
|
347
|
+
timestamp: new Date().toISOString(),
|
|
348
|
+
confidence: concept.confidence || "high",
|
|
349
|
+
last_verified: new Date().toISOString().split("T")[0],
|
|
350
|
+
links: concept.links || [],
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const serialized = serializeConcept(frontmatter, concept.body || "");
|
|
354
|
+
const tmpPath = `${fullPath}.tmp-${process.pid}`;
|
|
355
|
+
await fs.writeFile(tmpPath, serialized, "utf-8");
|
|
356
|
+
await fs.rename(tmpPath, fullPath);
|
|
357
|
+
|
|
358
|
+
updatedConcepts.push({ title: concept.title, type: concept.type });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await rebuildIndex(bundleDir);
|
|
362
|
+
if (updatedConcepts.length > 0) {
|
|
363
|
+
await updateLog(bundleDir, updatedConcepts);
|
|
364
|
+
}
|
|
365
|
+
await cleanupStalePendingFiles(bundleDir);
|
|
366
|
+
return { updated: true };
|
|
367
|
+
} else {
|
|
368
|
+
const doc = distilled.trim().slice(0, MEMORY_MAX_CHARS);
|
|
369
|
+
if (!doc) return { updated: false, skipped: "model returned an empty document" };
|
|
370
|
+
const file = memoryFilePath(cwd);
|
|
371
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
372
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
373
|
+
await fs.writeFile(tmp, doc + "\n", "utf-8");
|
|
374
|
+
await fs.rename(tmp, file);
|
|
375
|
+
return { updated: true };
|
|
376
|
+
}
|
|
128
377
|
} catch (err: any) {
|
|
129
378
|
return { updated: false, skipped: `distill failed: ${err?.message ?? String(err)}` };
|
|
130
379
|
}
|
|
131
380
|
}
|
|
132
381
|
|
|
382
|
+
|
|
383
|
+
|
|
133
384
|
// ── Detached background distillation (round-16) ──
|
|
134
385
|
// The exit-path `await distillSessionMemory(...)` blocked /exit and ^C^C for up
|
|
135
386
|
// to 20s on a final LLM call. Quitting must be INSTANT: the parent now writes a
|
|
@@ -189,7 +440,10 @@ export async function runMemoryDistillCommand(args: string[]): Promise<void> {
|
|
|
189
440
|
const payloadPath = (args[0] ?? "").trim();
|
|
190
441
|
if (!payloadPath) return;
|
|
191
442
|
try {
|
|
192
|
-
const
|
|
443
|
+
const payloadContent = await fs.readFile(payloadPath, "utf-8");
|
|
444
|
+
const payload = JSON.parse(payloadContent) as { model?: string; messages?: Message[] };
|
|
445
|
+
const bundleDir = path.join(process.cwd(), ".jeo", "memory");
|
|
446
|
+
await saveRawPayload(bundleDir, payload);
|
|
193
447
|
if (Array.isArray(payload.messages)) {
|
|
194
448
|
await distillSessionMemory(payload.messages, process.cwd(), { model: payload.model });
|
|
195
449
|
}
|
|
@@ -199,3 +453,4 @@ export async function runMemoryDistillCommand(args: string[]): Promise<void> {
|
|
|
199
453
|
await fs.unlink(payloadPath).catch(() => {});
|
|
200
454
|
}
|
|
201
455
|
}
|
|
456
|
+
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -68,11 +68,11 @@ export function providerModelFor(model: string): string {
|
|
|
68
68
|
|
|
69
69
|
/** Map the configured thinking level to a default max-token budget. */
|
|
70
70
|
export function thinkingMaxTokens(level?: "minimal" | "low" | "medium" | "high" | "xhigh"): number {
|
|
71
|
-
if (level === "minimal") return
|
|
72
|
-
if (level === "low") return
|
|
73
|
-
if (level === "high") return
|
|
74
|
-
if (level === "xhigh") return
|
|
75
|
-
return
|
|
71
|
+
if (level === "minimal") return 4000;
|
|
72
|
+
if (level === "low") return 8000;
|
|
73
|
+
if (level === "high") return 24000;
|
|
74
|
+
if (level === "xhigh") return 31999;
|
|
75
|
+
return 16000;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** Map the thinking level to an OpenAI reasoning-effort tier. `minimal` maps to `low`
|
|
@@ -77,8 +77,8 @@ function anthropicSystemBlocks(
|
|
|
77
77
|
function anthropicThinkingBudget(effort: CallOptions["reasoningEffort"], maxTokens: number): number | undefined {
|
|
78
78
|
let budget: number;
|
|
79
79
|
switch (effort) {
|
|
80
|
-
case "medium": budget =
|
|
81
|
-
case "high": budget =
|
|
80
|
+
case "medium": budget = 10000; break;
|
|
81
|
+
case "high": budget = 24000; break;
|
|
82
82
|
default: return undefined;
|
|
83
83
|
}
|
|
84
84
|
return Math.min(budget, Math.max(1024, maxTokens - 1024));
|
|
@@ -17,9 +17,9 @@ export function geminiThinkingBudget(model: string, effort?: CallOptions["reason
|
|
|
17
17
|
const floor = m.includes("pro") ? 128 : 0; // pro-class cannot fully disable thinking
|
|
18
18
|
let budget: number;
|
|
19
19
|
switch (effort) {
|
|
20
|
-
case "low": budget =
|
|
21
|
-
case "medium": budget =
|
|
22
|
-
case "high": budget =
|
|
20
|
+
case "low": budget = 4000; break;
|
|
21
|
+
case "medium": budget = 10000; break;
|
|
22
|
+
case "high": budget = 24000; break;
|
|
23
23
|
case "minimal":
|
|
24
24
|
default: budget = floor;
|
|
25
25
|
}
|
|
@@ -104,16 +104,45 @@ export function matchMouseReport(data: string, i: number): number {
|
|
|
104
104
|
return 0;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* on the
|
|
107
|
+
/** Byte length of a terminal CAPABILITY-RESPONSE sequence beginning at `data[i]`, else 0.
|
|
108
|
+
* These are REPLIES the terminal sends to capability queries — Primary/Secondary Device
|
|
109
|
+
* Attributes (`ESC[?…c` / `ESC[>…c` / `ESC[=…c`), XTVERSION and other DCS replies
|
|
110
|
+
* (`ESC P…ST`), and OSC replies like a color query (`ESC]11;rgb:…ST`). jeo never sends
|
|
111
|
+
* these queries, but tmux probes the OUTER terminal on attach, and the outer terminal's
|
|
112
|
+
* answers can land on stdin (the leaked `62;4;9;22c…>|xterm.js(…)` garbage in the prompt).
|
|
113
|
+
* They are never typed input, so the whole sequence is swallowed. A reply split across
|
|
114
|
+
* chunks (no terminator yet) consumes the tail rather than leaking it. */
|
|
115
|
+
export function matchTerminalReport(data: string, i: number): number {
|
|
116
|
+
// CSI device-attribute / mode replies: ESC [ (? | > | =) … <final letter>.
|
|
117
|
+
if (data.startsWith("\u001b[?", i) || data.startsWith("\u001b[>", i) || data.startsWith("\u001b[=", i)) {
|
|
118
|
+
let j = i + 3;
|
|
119
|
+
while (j < data.length && !/[A-Za-z]/.test(data[j]!)) j++; // params: digits ; : $ → final letter
|
|
120
|
+
return (j < data.length ? j + 1 : data.length) - i;
|
|
121
|
+
}
|
|
122
|
+
// DCS (ESC P … ) or OSC (ESC ] … ) reply — terminated by ST (ESC \) or BEL.
|
|
123
|
+
if (data.startsWith("\u001bP", i) || data.startsWith("\u001b]", i)) {
|
|
124
|
+
let j = i + 2;
|
|
125
|
+
while (j < data.length) {
|
|
126
|
+
if (data[j] === "\u0007") return j + 1 - i; // BEL
|
|
127
|
+
if (data[j] === "\u001b" && data[j + 1] === "\\") return j + 2 - i; // ST
|
|
128
|
+
j++;
|
|
129
|
+
}
|
|
130
|
+
return data.length - i; // unterminated tail (split chunk) — consume the rest
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Remove every terminal MOUSE-REPORT and CAPABILITY-RESPONSE sequence from a plain
|
|
136
|
+
* (non-paste) input segment. The live-turn drain (`queuePromptInputChunk`) reads RAW
|
|
137
|
+
* stdin, so a wheel/click report or a tmux-probed device-attribute reply buffered during
|
|
138
|
+
* a running turn would otherwise have its printable remnant (`[M`, SGR digits, or
|
|
139
|
+
* `62;4;9;22c…`) fed into the next prompt — the same "값 입력" corruption the keyFilter
|
|
140
|
+
* blocks on the idle path. */
|
|
112
141
|
export function stripMouseReports(s: string): string {
|
|
113
142
|
let out = "";
|
|
114
143
|
let i = 0;
|
|
115
144
|
while (i < s.length) {
|
|
116
|
-
const m = matchMouseReport(s, i);
|
|
145
|
+
const m = matchMouseReport(s, i) || matchTerminalReport(s, i);
|
|
117
146
|
if (m > 0) { i += m; continue; }
|
|
118
147
|
out += s[i];
|
|
119
148
|
i += 1;
|
package/src/commands/launch.ts
CHANGED
|
@@ -127,6 +127,7 @@ import {
|
|
|
127
127
|
CURSOR_COMBO_REWRITES,
|
|
128
128
|
matchCursorCombo,
|
|
129
129
|
matchMouseReport,
|
|
130
|
+
matchTerminalReport,
|
|
130
131
|
stripMouseReports,
|
|
131
132
|
rewriteCursorCombos,
|
|
132
133
|
queuePromptInputChunk,
|
|
@@ -175,6 +176,7 @@ export {
|
|
|
175
176
|
CURSOR_COMBO_REWRITES,
|
|
176
177
|
matchCursorCombo,
|
|
177
178
|
matchMouseReport,
|
|
179
|
+
matchTerminalReport,
|
|
178
180
|
stripMouseReports,
|
|
179
181
|
rewriteCursorCombos,
|
|
180
182
|
queuePromptInputChunk,
|
|
@@ -1268,6 +1270,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1268
1270
|
// their payload bytes would otherwise be typed into the prompt. Never in a paste.
|
|
1269
1271
|
const mouse = matchMouseReport(data, i);
|
|
1270
1272
|
if (mouse > 0) { i += mouse; continue; }
|
|
1273
|
+
// Swallow TERMINAL CAPABILITY-RESPONSE sequences (DA1/DA2/XTVERSION/OSC replies):
|
|
1274
|
+
// tmux probes the outer terminal on attach and its answers (`62;4;9;22c…>|xterm.js…`)
|
|
1275
|
+
// can land on stdin; without this they spray into the prompt as typed text.
|
|
1276
|
+
const report = matchTerminalReport(data, i);
|
|
1277
|
+
if (report > 0) { i += report; continue; }
|
|
1271
1278
|
let matched = false;
|
|
1272
1279
|
for (const seq of SHIFT_ENTER_SEQS) {
|
|
1273
1280
|
if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
|