jeo-code 0.6.13 → 0.6.15
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 +20 -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 +2 -1
- package/src/agent/memory.ts +251 -81
- package/src/ai/model-manager.ts +14 -3
- package/src/commands/launch.ts +6 -3
- package/src/tui/app.ts +13 -6
- package/src/util/retry.ts +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,26 @@ 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.15] - 2026-06-17
|
|
10
|
+
_Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Concept-level memory search & budget-aware injection (OKF Sprint 03).** `memoryPromptSection(cwd, query?)` now loads the OKF concept bundle and selects what to inject by priority — high-confidence core facts first, then query relevance (the one-shot task text is wired in as the query), then stable order — dropping whole lowest-priority concepts to fit `MEMORY_INJECT_MAX_CHARS` (3000) instead of truncating mid-string. New exported helpers `loadConcepts` / `scoreConcept` / `searchConcepts`. The `index.md` rebuild now emits progressive-disclosure `- [title](/relpath) — description` rows. Injection-hardening (DATA framing, fence neutralization) and the `MEMORY.md` fallback are retained.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **End-of-turn Todos receipt tells the truth.** A successful `finish` shows the Todos checklist fully complete so it agrees with the `done` badge (the model's last `todo` call often forgets to flip the final items, and the once-per-turn done gate can't force it); cancel/error finishes pass `ok:false` so any unfinished items stay honestly shown. The live frame is unchanged, so in-progress work still renders truthfully.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## [0.6.14] - 2026-06-16
|
|
20
|
+
_Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn._
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Malformed `concepts` arrays no longer discard the whole distillation batch.** A text-only / small model can emit stray non-object array elements (`null`, strings, numbers) or non-string `type`/`title` fields. Each element is now validated and its persistence wrapped in a per-concept `try`/`catch`, so one bad concept is skipped instead of throwing out of the loop into the outer catch — which previously silently dropped every valid learning distilled in that run. Junk frontmatter fields (`description`/`tags`/`body`/`confidence`/`links`) are coerced to safe defaults so the written file stays OKF-conformant.
|
|
24
|
+
- **Per-chunk stream-idle stalls now retry instead of failing the turn.** A `stream idle for <ms>ms (no chunk)` stall (provider load or long time-to-first-token) is treated as transient and retried like a timeout, while the hard overall wall-clock cap (`stream exceeded the overall deadline`) still fails fast. The idle-stall error message now explains the cause and remediation.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **`JEO_STREAM_IDLE_MS` opt-in override.** Reasoning workloads whose "thinking" phase can legitimately emit no visible token for longer than the 120s default can raise the per-chunk idle threshold without a code change.
|
|
28
|
+
|
|
9
29
|
## [0.6.13] - 2026-06-16
|
|
10
30
|
_`team` engine: concrete uncommitted-work reporting and stricter empty-run handling._
|
|
11
31
|
|
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.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
|
+
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
161
163
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
162
164
|
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
163
165
|
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
164
|
-
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
165
|
-
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
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.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
|
+
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
161
163
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
162
164
|
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
163
165
|
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
164
|
-
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
165
|
-
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
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.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
|
+
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
161
163
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
162
164
|
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
163
165
|
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
164
|
-
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
165
|
-
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
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.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
162
|
+
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
161
163
|
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
162
164
|
- **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
|
|
163
165
|
- **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
|
|
164
|
-
- **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
|
|
165
|
-
- **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
|
|
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
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
* (plan/gjc-inheritance.md B6; gjc memories/ 2-phase consolidation 참조).
|
|
4
4
|
*
|
|
5
5
|
* Session end distills durable learnings (repo facts, commands that work,
|
|
6
|
-
* gotchas, user preferences) into `.jeo/memory
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* gotchas, user preferences) into the OKF concept bundle under `.jeo/memory/`
|
|
7
|
+
* (type-partitioned `facts/`, `commands/`, … dirs) with ONE model call, upserting
|
|
8
|
+
* each concept; a legacy single `MEMORY.md` doc is the fallback when the model
|
|
9
|
+
* returns plain text. The next session reads the bundle back (bundle-first, then
|
|
10
|
+
* MEMORY.md) and injects it into the system prompt under a hard char cap —
|
|
11
|
+
* local-first (nullclaw/zeroclaw), no remote backend, disable with JEO_NO_MEMORY=1.
|
|
10
12
|
*/
|
|
11
13
|
import * as fs from "node:fs/promises";
|
|
12
14
|
import { spawn as nodeSpawn } from "node:child_process";
|
|
@@ -47,16 +49,181 @@ export async function loadMemory(cwd: string): Promise<string> {
|
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
/** Render a single index.md-style section: a `## header` followed by one bullet
|
|
53
|
+
* per concept (`**title**: description`), with the concept body indented beneath. */
|
|
54
|
+
function renderConceptSection(header: string, list: { title: string; description: string; body: string }[]): string {
|
|
55
|
+
const lines = [`## ${header}`];
|
|
56
|
+
for (const c of list) {
|
|
57
|
+
lines.push(`- **${c.title}**${c.description ? `: ${c.description}` : ""}`);
|
|
58
|
+
if (c.body) {
|
|
59
|
+
for (const bodyLine of c.body.split("\n")) lines.push(` ${bodyLine}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A loaded OKF concept: frontmatter fields + body + bundle-relative path. */
|
|
66
|
+
export interface Concept {
|
|
67
|
+
type: string;
|
|
68
|
+
title: string;
|
|
69
|
+
description: string;
|
|
70
|
+
body: string;
|
|
71
|
+
tags: string[];
|
|
72
|
+
/** high | medium | low — distiller defaults to "high"; drives core selection. */
|
|
73
|
+
confidence: string;
|
|
74
|
+
/** Bundle-relative path, e.g. `commands/bun-test.md`. */
|
|
75
|
+
relPath: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Read every concept document in the bundle into structured `Concept`s. Reserved
|
|
79
|
+
* files (index.md/log.md) and raw/ payloads are skipped; unparseable or
|
|
80
|
+
* frontmatter-less files are ignored (lenient consumption). */
|
|
81
|
+
export async function loadConcepts(cwd: string): Promise<Concept[]> {
|
|
82
|
+
return loadConceptsFromBundle(path.join(cwd, ".jeo", "memory"));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
|
|
86
|
+
const files = await findMarkdownFiles(bundleDir);
|
|
87
|
+
const concepts: Concept[] = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
const relPath = path.relative(bundleDir, file).replace(/\\/g, "/");
|
|
90
|
+
if (isReservedFile(relPath)) continue;
|
|
91
|
+
let parsed;
|
|
92
|
+
try {
|
|
93
|
+
parsed = parseConcept(await fs.readFile(file, "utf-8"));
|
|
94
|
+
} catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!parsed.hasFrontmatter) continue;
|
|
98
|
+
const fm = parsed.frontmatter;
|
|
99
|
+
concepts.push({
|
|
100
|
+
type: (fm.type as string) || "RepoFact",
|
|
101
|
+
title: (fm.title as string) || path.basename(file, ".md"),
|
|
102
|
+
description: (fm.description as string) || "",
|
|
103
|
+
body: parsed.body.trim(),
|
|
104
|
+
tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
|
|
105
|
+
confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
|
|
106
|
+
relPath,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return concepts;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Tokenize a free-text query into distinct lowercased keywords (len ≥ 3). */
|
|
113
|
+
function tokenize(query?: string): string[] {
|
|
114
|
+
if (!query) return [];
|
|
115
|
+
return Array.from(new Set((query.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter(t => t.length >= 3)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Relevance score of a concept against query tokens. Field weights mirror
|
|
119
|
+
* llm-wiki's retrieval bias (title ≫ tags ≫ type/description ≫ body). 0 = no hit. */
|
|
120
|
+
export function scoreConcept(concept: Concept, tokens: string[]): number {
|
|
121
|
+
if (tokens.length === 0) return 0;
|
|
122
|
+
const title = concept.title.toLowerCase();
|
|
123
|
+
const desc = concept.description.toLowerCase();
|
|
124
|
+
const body = concept.body.toLowerCase();
|
|
125
|
+
const type = concept.type.toLowerCase();
|
|
126
|
+
const tags = concept.tags.map(t => t.toLowerCase());
|
|
127
|
+
let score = 0;
|
|
128
|
+
for (const t of tokens) {
|
|
129
|
+
if (title.includes(t)) score += 5;
|
|
130
|
+
if (tags.some(tag => tag.includes(t))) score += 3;
|
|
131
|
+
if (type.includes(t)) score += 2;
|
|
132
|
+
if (desc.includes(t)) score += 2;
|
|
133
|
+
if (body.includes(t)) score += 1;
|
|
134
|
+
}
|
|
135
|
+
return score;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Search the bundle's concepts for a query, returning the relevant ones (score > 0)
|
|
139
|
+
* highest-score first. A type/tags/title/body keyword match all contribute. */
|
|
140
|
+
export function searchConcepts(concepts: Concept[], query: string): { concept: Concept; score: number }[] {
|
|
141
|
+
const tokens = tokenize(query);
|
|
142
|
+
return concepts
|
|
143
|
+
.map(concept => ({ concept, score: scoreConcept(concept, tokens) }))
|
|
144
|
+
.filter(r => r.score > 0)
|
|
145
|
+
.sort((a, b) => b.score - a.score);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Priority order for injection: high-confidence "core" concepts first, then by
|
|
149
|
+
* query relevance (descending), preserving input order as a stable tiebreak. */
|
|
150
|
+
function priorityOrder(concepts: Concept[], query?: string): Concept[] {
|
|
151
|
+
const tokens = tokenize(query);
|
|
152
|
+
return concepts
|
|
153
|
+
.map((concept, i) => ({ concept, i, core: concept.confidence === "high", score: scoreConcept(concept, tokens) }))
|
|
154
|
+
.sort((a, b) => {
|
|
155
|
+
if (a.core !== b.core) return a.core ? -1 : 1;
|
|
156
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
157
|
+
return a.i - b.i;
|
|
158
|
+
})
|
|
159
|
+
.map(s => s.concept);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Group items by their `type` into ordered `{ header, list }` sections: TYPE_LAYOUT
|
|
163
|
+
* order first, then any unknown types under their raw type name (lenient). The one
|
|
164
|
+
* place that encodes the section ordering — shared by render and index. */
|
|
165
|
+
function groupByTypeLayout<T extends { type: string }>(items: T[]): { header: string; list: T[] }[] {
|
|
166
|
+
const byType = new Map<string, T[]>();
|
|
167
|
+
for (const it of items) {
|
|
168
|
+
const list = byType.get(it.type) ?? [];
|
|
169
|
+
list.push(it);
|
|
170
|
+
byType.set(it.type, list);
|
|
171
|
+
}
|
|
172
|
+
const sections: { header: string; list: T[] }[] = [];
|
|
173
|
+
const rendered = new Set<string>();
|
|
174
|
+
for (const { type, header } of TYPE_LAYOUT) {
|
|
175
|
+
rendered.add(type);
|
|
176
|
+
const list = byType.get(type);
|
|
177
|
+
if (list && list.length > 0) sections.push({ header, list });
|
|
178
|
+
}
|
|
179
|
+
for (const [type, list] of byType) {
|
|
180
|
+
if (rendered.has(type) || list.length === 0) continue;
|
|
181
|
+
sections.push({ header: type, list });
|
|
182
|
+
}
|
|
183
|
+
return sections;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Render a set of concepts as a compact markdown block grouped by type in
|
|
187
|
+
* TYPE_LAYOUT order, with any unknown types appended under their raw type name. */
|
|
188
|
+
function renderConcepts(concepts: Concept[]): string {
|
|
189
|
+
return groupByTypeLayout(concepts)
|
|
190
|
+
.map(({ header, list }) => renderConceptSection(header, list))
|
|
191
|
+
.join("\n\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Greedily select concepts (in priority order) whose grouped render stays within
|
|
195
|
+
* `budget` chars, dropping the lowest-priority concepts first. At least the
|
|
196
|
+
* top-priority concept is always kept (the framing/backstop cap still applies). */
|
|
197
|
+
function selectWithinBudget(concepts: Concept[], query: string | undefined, budget: number): Concept[] {
|
|
198
|
+
const ordered = priorityOrder(concepts, query);
|
|
199
|
+
const selected: Concept[] = [];
|
|
200
|
+
for (const c of ordered) {
|
|
201
|
+
if (renderConcepts([...selected, c]).length <= budget) selected.push(c);
|
|
202
|
+
}
|
|
203
|
+
if (selected.length === 0 && ordered.length > 0) selected.push(ordered[0]!);
|
|
204
|
+
return selected;
|
|
205
|
+
}
|
|
206
|
+
|
|
50
207
|
/** System-prompt block carrying prior-session learnings; "" when empty or disabled.
|
|
208
|
+
* Selection (Sprint 03): always-included high-confidence core + concepts most
|
|
209
|
+
* relevant to `query` (the current task), chosen whole within MEMORY_INJECT_MAX_CHARS
|
|
210
|
+
* (lowest-priority dropped first) — never a mid-concept string truncation. Falls
|
|
211
|
+
* back to the legacy single MEMORY.md doc when no concept bundle exists.
|
|
51
212
|
* The memory text is MODEL-DISTILLED from session transcripts (which include tool
|
|
52
213
|
* outputs — file contents, web results), so it is injection-hardened like subagent
|
|
53
214
|
* reports: tag-breakout sequences are neutralized and the block is framed as DATA. */
|
|
54
|
-
export async function memoryPromptSection(cwd: string): Promise<string> {
|
|
215
|
+
export async function memoryPromptSection(cwd: string, query?: string): Promise<string> {
|
|
55
216
|
if (jeoEnv("NO_MEMORY") === "1") return "";
|
|
56
|
-
|
|
217
|
+
// Prefer the OKF concept bundle (budget-selected); fall back to legacy MEMORY.md.
|
|
218
|
+
const concepts = await loadConcepts(cwd);
|
|
219
|
+
let memory = concepts.length > 0
|
|
220
|
+
? renderConcepts(selectWithinBudget(concepts, query, MEMORY_INJECT_MAX_CHARS))
|
|
221
|
+
: await loadMemory(cwd);
|
|
57
222
|
if (!memory) return "";
|
|
223
|
+
// Backstop: legacy MEMORY.md is a single blob (not concept-selectable), and a
|
|
224
|
+
// pathological single concept can exceed the budget — hard-cap either way.
|
|
58
225
|
if (memory.length > MEMORY_INJECT_MAX_CHARS) {
|
|
59
|
-
memory = memory.slice(0, MEMORY_INJECT_MAX_CHARS) + "\n…(memory truncated — full doc in .jeo/memory/
|
|
226
|
+
memory = memory.slice(0, MEMORY_INJECT_MAX_CHARS) + "\n…(memory truncated — full doc in .jeo/memory/)";
|
|
60
227
|
}
|
|
61
228
|
// Neutralize the fence tags so distilled content can never close the block and
|
|
62
229
|
// smuggle instruction-shaped text into the bare system prompt.
|
|
@@ -120,33 +287,20 @@ async function findMarkdownFiles(dir: string): Promise<string[]> {
|
|
|
120
287
|
}
|
|
121
288
|
|
|
122
289
|
async function rebuildIndex(bundleDir: string): Promise<void> {
|
|
123
|
-
const
|
|
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
|
-
}
|
|
290
|
+
const concepts = await loadConceptsFromBundle(bundleDir);
|
|
140
291
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
body += `## ${header}\n`;
|
|
292
|
+
// Progressive-disclosure index: a link per concept plus its one-line description,
|
|
293
|
+
// grouped by type (TYPE_LAYOUT order first, then any unknown types — lenient).
|
|
294
|
+
const section = (header: string, list: Concept[]): string => {
|
|
295
|
+
let out = `## ${header}\n`;
|
|
146
296
|
for (const c of list) {
|
|
147
|
-
|
|
297
|
+
out += `- [${c.title}](/${c.relPath})${c.description ? ` — ${c.description}` : ""}\n`;
|
|
148
298
|
}
|
|
149
|
-
|
|
299
|
+
return out + "\n";
|
|
300
|
+
};
|
|
301
|
+
let body = "# Index\n\n";
|
|
302
|
+
for (const { header, list } of groupByTypeLayout(concepts)) {
|
|
303
|
+
body += section(header, list);
|
|
150
304
|
}
|
|
151
305
|
|
|
152
306
|
const indexContent = serializeConcept({ okf_version: "0.1" }, body.trim());
|
|
@@ -302,60 +456,76 @@ export async function distillSessionMemory(
|
|
|
302
456
|
await fs.mkdir(bundleDir, { recursive: true });
|
|
303
457
|
const updatedConcepts: { title: string; type: string }[] = [];
|
|
304
458
|
|
|
305
|
-
for (const
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
459
|
+
for (const raw of parsedJson.concepts) {
|
|
460
|
+
// A text-only / small model (the default antigravity backend) can emit
|
|
461
|
+
// stray non-object array elements (null, strings, numbers) or non-string
|
|
462
|
+
// type/title fields. Validate each element and isolate per-concept failures:
|
|
463
|
+
// one malformed concept must NEVER throw out of the loop, because the outer
|
|
464
|
+
// catch would then discard every valid learning distilled in this run.
|
|
465
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
|
|
466
|
+
const concept = raw as {
|
|
467
|
+
type?: unknown; title?: unknown; description?: unknown; body?: unknown;
|
|
468
|
+
tags?: unknown; confidence?: unknown; links?: unknown;
|
|
469
|
+
};
|
|
470
|
+
const type = typeof concept.type === "string" ? concept.type.trim() : "";
|
|
471
|
+
const title = typeof concept.title === "string" ? concept.title.trim() : "";
|
|
472
|
+
if (!type || !title) continue;
|
|
473
|
+
try {
|
|
474
|
+
// Unknown types fall back to facts/ (lenient — OKF tolerates extra types).
|
|
475
|
+
const dir = DIR_BY_TYPE[type] ?? "facts";
|
|
476
|
+
|
|
477
|
+
const targetDir = path.join(bundleDir, dir);
|
|
478
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
479
|
+
|
|
480
|
+
let slug = slugify(title);
|
|
481
|
+
let relPath = `${dir}/${slug}.md`;
|
|
482
|
+
let fullPath = path.join(bundleDir, relPath);
|
|
483
|
+
|
|
484
|
+
let suffix = 1;
|
|
485
|
+
while (true) {
|
|
486
|
+
try {
|
|
487
|
+
const existingContent = await fs.readFile(fullPath, "utf-8");
|
|
488
|
+
const parsed = parseConcept(existingContent);
|
|
489
|
+
const existingTitle = parsed.frontmatter.title || "";
|
|
490
|
+
if (existingTitle === title) {
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
slug = `${slugify(title)}-${suffix}`;
|
|
494
|
+
relPath = `${dir}/${slug}.md`;
|
|
495
|
+
fullPath = path.join(bundleDir, relPath);
|
|
496
|
+
suffix++;
|
|
497
|
+
} catch {
|
|
324
498
|
break;
|
|
325
499
|
}
|
|
326
|
-
slug = `${slugify(concept.title)}-${suffix}`;
|
|
327
|
-
relPath = `${dir}/${slug}.md`;
|
|
328
|
-
fullPath = path.join(bundleDir, relPath);
|
|
329
|
-
suffix++;
|
|
330
|
-
} catch {
|
|
331
|
-
break;
|
|
332
500
|
}
|
|
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
501
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
502
|
+
let existingFm = {};
|
|
503
|
+
try {
|
|
504
|
+
const existingContent = await fs.readFile(fullPath, "utf-8");
|
|
505
|
+
existingFm = parseConcept(existingContent).frontmatter;
|
|
506
|
+
} catch {}
|
|
507
|
+
|
|
508
|
+
const frontmatter = {
|
|
509
|
+
...existingFm,
|
|
510
|
+
type,
|
|
511
|
+
title,
|
|
512
|
+
description: typeof concept.description === "string" ? concept.description : "",
|
|
513
|
+
tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
|
|
514
|
+
timestamp: new Date().toISOString(),
|
|
515
|
+
confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
|
|
516
|
+
last_verified: new Date().toISOString().split("T")[0],
|
|
517
|
+
links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const serialized = serializeConcept(frontmatter, typeof concept.body === "string" ? concept.body : "");
|
|
521
|
+
const tmpPath = `${fullPath}.tmp-${process.pid}`;
|
|
522
|
+
await fs.writeFile(tmpPath, serialized, "utf-8");
|
|
523
|
+
await fs.rename(tmpPath, fullPath);
|
|
524
|
+
|
|
525
|
+
updatedConcepts.push({ title, type });
|
|
526
|
+
} catch {
|
|
527
|
+
// Skip just this concept; keep distilling the rest of the batch.
|
|
528
|
+
}
|
|
359
529
|
}
|
|
360
530
|
|
|
361
531
|
await rebuildIndex(bundleDir);
|
package/src/ai/model-manager.ts
CHANGED
|
@@ -356,7 +356,9 @@ const DEFAULT_CALL_TIMEOUT_MS = 120_000;
|
|
|
356
356
|
|
|
357
357
|
/** Per-chunk idle cap for streaming: a stream that emits NOTHING for this long is
|
|
358
358
|
* aborted, but a healthy long generation (chunks keep arriving) runs unbounded —
|
|
359
|
-
* unlike a single wall-clock cap that would kill a long-but-active stream.
|
|
359
|
+
* unlike a single wall-clock cap that would kill a long-but-active stream.
|
|
360
|
+
* Opt-in override via JEO_STREAM_IDLE_MS for reasoning workloads whose "thinking"
|
|
361
|
+
* phase can legitimately emit no visible token for longer than the default. */
|
|
360
362
|
const STREAM_IDLE_TIMEOUT_MS = 120_000;
|
|
361
363
|
|
|
362
364
|
/** Combine two abort signals into one. Preserves BOTH even when `AbortSignal.any`
|
|
@@ -418,7 +420,7 @@ async function nextMaybeIdle(iter: AsyncIterator<string>, idle?: StreamIdleOptio
|
|
|
418
420
|
idle.onIdle?.();
|
|
419
421
|
reject(new Error(deadlineFires
|
|
420
422
|
? `stream exceeded the overall deadline (JEO_STREAM_MAX_MS) — slow-drip stream aborted`
|
|
421
|
-
: `stream idle for ${idle.idleMs}ms (no chunk)
|
|
423
|
+
: `stream idle for ${idle.idleMs}ms (no chunk) — provider sent no token within the idle window (load or long thinking); retrying. Raise JEO_STREAM_IDLE_MS or lower the thinking level if this persists.`));
|
|
422
424
|
}, waitMs);
|
|
423
425
|
});
|
|
424
426
|
try {
|
|
@@ -435,6 +437,15 @@ export function streamMaxMs(env?: Record<string, string | undefined>): number |
|
|
|
435
437
|
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
436
438
|
}
|
|
437
439
|
|
|
440
|
+
/** Per-chunk idle cap (ms) from the environment, falling back to the built-in default.
|
|
441
|
+
* Lets reasoning workloads whose "thinking" phase emits no visible token for a long
|
|
442
|
+
* time raise the stall threshold via JEO_STREAM_IDLE_MS without a code change. */
|
|
443
|
+
export function streamIdleMs(env?: Record<string, string | undefined>): number {
|
|
444
|
+
const raw = jeoEnv("STREAM_IDLE_MS", env);
|
|
445
|
+
const n = raw !== undefined ? parseInt(raw, 10) : NaN;
|
|
446
|
+
return Number.isFinite(n) && n > 0 ? n : STREAM_IDLE_TIMEOUT_MS;
|
|
447
|
+
}
|
|
448
|
+
|
|
438
449
|
export async function* retryableStream(
|
|
439
450
|
makeIter: () => AsyncIterator<string>,
|
|
440
451
|
retry: RetryOptions,
|
|
@@ -475,7 +486,7 @@ export function createModelManager(): ModelManager {
|
|
|
475
486
|
};
|
|
476
487
|
const maxMs = streamMaxMs();
|
|
477
488
|
yield* retryableStream(makeIter, retry, {
|
|
478
|
-
idleMs:
|
|
489
|
+
idleMs: streamIdleMs(),
|
|
479
490
|
...(maxMs !== undefined ? { deadlineAt: Date.now() + maxMs } : {}),
|
|
480
491
|
onIdle: () => attempt?.abort(),
|
|
481
492
|
});
|
package/src/commands/launch.ts
CHANGED
|
@@ -396,7 +396,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
396
396
|
const protocol = buildToolProtocol(allowedTools);
|
|
397
397
|
const preamble = flags.systemPrompt ?? "You are the jeo, an interactive coding agent.\nAccomplish the user's request by calling tools and verifying your work.";
|
|
398
398
|
// Prior-session learnings (B6 경험 증류) — "" when absent or JEO_NO_MEMORY=1.
|
|
399
|
-
|
|
399
|
+
// The one-shot task text (flags.message) seeds relevance search so the most
|
|
400
|
+
// pertinent concepts win the injection budget; interactive boots with no query
|
|
401
|
+
// (high-confidence core concepts are always prioritized regardless).
|
|
402
|
+
const memoryBlock = await memoryPromptSection(cwd, flags.message || undefined);
|
|
400
403
|
|
|
401
404
|
const baseSystemPrompt =
|
|
402
405
|
preamble + "\n\n" + protocol + "\n\n" +
|
|
@@ -679,7 +682,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
679
682
|
else console.log(msg);
|
|
680
683
|
},
|
|
681
684
|
onHardExit: () => {
|
|
682
|
-
if (tui) tui.finish("Cancelled.");
|
|
685
|
+
if (tui) tui.finish("Cancelled.", { ok: false });
|
|
683
686
|
process.exit(130);
|
|
684
687
|
},
|
|
685
688
|
});
|
|
@@ -812,7 +815,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
812
815
|
}
|
|
813
816
|
} catch (err) {
|
|
814
817
|
if (tui) {
|
|
815
|
-
tui.finish(`! ${friendlyProviderError(err)}
|
|
818
|
+
tui.finish(`! ${friendlyProviderError(err)}`, { ok: false });
|
|
816
819
|
interactiveTurnActive = false;
|
|
817
820
|
}
|
|
818
821
|
throw err;
|
package/src/tui/app.ts
CHANGED
|
@@ -359,12 +359,16 @@ export class LaunchTui {
|
|
|
359
359
|
try { this.write(`\x1b]2;jeo: ${this.turnTitle}\x07`); } catch { /* terminal gone */ }
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
-
/** Render the task plan as a status-colored checklist; empty when no plan.
|
|
363
|
-
|
|
362
|
+
/** Render the task plan as a status-colored checklist; empty when no plan. When
|
|
363
|
+
* `complete` (the success-finish receipt), every still-open item is shown done so the
|
|
364
|
+
* checklist agrees with the `done` badge — the model's last `todo` call often forgets
|
|
365
|
+
* to flip the final items, and the once-per-turn done gate can't force it. The LIVE
|
|
366
|
+
* frame never passes `complete`, so in-progress work still renders truthfully. */
|
|
367
|
+
private renderPlan(color: boolean, complete = false): string[] {
|
|
364
368
|
if (this.todos.length === 0) return [];
|
|
365
369
|
const steps = this.todos.map(t => ({
|
|
366
370
|
label: t.title,
|
|
367
|
-
state: (t.status === "done" ? "done" : t.status === "in_progress" ? "active" : "pending") as StepState,
|
|
371
|
+
state: (complete || t.status === "done" ? "done" : t.status === "in_progress" ? "active" : "pending") as StepState,
|
|
368
372
|
}));
|
|
369
373
|
const header = formatStepHeader(steps, { unicode: this.unicode, color, label: "Todos" });
|
|
370
374
|
return [header, ...formatStepTimeline(steps, { unicode: this.unicode, color, highlightActive: true, maxRows: 8, badges: false })];
|
|
@@ -984,8 +988,11 @@ export class LaunchTui {
|
|
|
984
988
|
return this.theme.color ? chalk.bold(accentPaint(this.theme)("jeo")) : "jeo";
|
|
985
989
|
}
|
|
986
990
|
|
|
987
|
-
/** Collapse the live region to static final output.
|
|
988
|
-
|
|
991
|
+
/** Collapse the live region to static final output. `ok` (default true) marks a
|
|
992
|
+
* SUCCESSFUL turn — its Todos receipt is shown fully complete. Cancel/error finishes
|
|
993
|
+
* pass `ok:false` so the checklist truthfully keeps any unfinished items. */
|
|
994
|
+
finish(reply: string, opts: { ok?: boolean } = {}): void {
|
|
995
|
+
const ok = opts.ok !== false;
|
|
989
996
|
this.finished = true;
|
|
990
997
|
this.hudPhase = "done";
|
|
991
998
|
if (this.timer) {
|
|
@@ -1014,7 +1021,7 @@ export class LaunchTui {
|
|
|
1014
1021
|
const finalLines: string[] = [];
|
|
1015
1022
|
// jeo-ref final-report order: the ANSWER leads; the Todos checklist follows it
|
|
1016
1023
|
// (done = checked + struck through), so the plan reads as a completion receipt.
|
|
1017
|
-
const planLines = this.renderPlan(this.theme.color);
|
|
1024
|
+
const planLines = this.renderPlan(this.theme.color, ok);
|
|
1018
1025
|
if (!this.inline) {
|
|
1019
1026
|
// Inline scrollback already reads as a ✓/✗ checklist; the step timeline +
|
|
1020
1027
|
// compact strip + flow line would just repeat it (gjc-style slim summary).
|
package/src/util/retry.ts
CHANGED
|
@@ -56,7 +56,13 @@ export function defaultRetryable(err: unknown): boolean {
|
|
|
56
56
|
lowerMessage.includes("timeout") ||
|
|
57
57
|
lowerMessage.includes("overloaded") ||
|
|
58
58
|
lowerMessage.includes("rate limit") ||
|
|
59
|
-
lowerMessage.includes("rate_limit")
|
|
59
|
+
lowerMessage.includes("rate_limit") ||
|
|
60
|
+
// A per-chunk stream-idle stall ("stream idle for <ms>ms (no chunk)") is a
|
|
61
|
+
// transient stall (provider load / long TTFT) — retry it like a timeout. The
|
|
62
|
+
// OVERALL-deadline message ("stream exceeded the overall deadline") is a hard
|
|
63
|
+
// wall-clock cap and is deliberately NOT matched here (it must fail fast).
|
|
64
|
+
lowerMessage.includes("stream idle") ||
|
|
65
|
+
lowerMessage.includes("no chunk")
|
|
60
66
|
) {
|
|
61
67
|
return true;
|
|
62
68
|
}
|