jeo-code 0.6.13 → 0.6.14

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 CHANGED
@@ -6,6 +6,16 @@ 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.14] - 2026-06-16
10
+ _Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn._
11
+
12
+ ### Fixed
13
+ - **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.
14
+ - **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.
15
+
16
+ ### Added
17
+ - **`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.
18
+
9
19
  ## [0.6.13] - 2026-06-16
10
20
  _`team` engine: concrete uncommitted-work reporting and stricter empty-run handling._
11
21
 
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.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
161
162
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
163
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
163
164
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
164
165
  - **[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.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
161
162
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
163
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
163
164
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
164
165
  - **[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.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
161
162
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
163
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
163
164
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
164
165
  - **[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.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
161
162
  - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
163
  - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
163
164
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
164
165
  - **[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
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.13",
3
+ "version": "0.6.14",
4
+
4
5
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
6
  "type": "module",
6
7
  "main": "src/cli.ts",
@@ -302,60 +302,76 @@ export async function distillSessionMemory(
302
302
  await fs.mkdir(bundleDir, { recursive: true });
303
303
  const updatedConcepts: { title: string; type: string }[] = [];
304
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) {
305
+ for (const raw of parsedJson.concepts) {
306
+ // A text-only / small model (the default antigravity backend) can emit
307
+ // stray non-object array elements (null, strings, numbers) or non-string
308
+ // type/title fields. Validate each element and isolate per-concept failures:
309
+ // one malformed concept must NEVER throw out of the loop, because the outer
310
+ // catch would then discard every valid learning distilled in this run.
311
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
312
+ const concept = raw as {
313
+ type?: unknown; title?: unknown; description?: unknown; body?: unknown;
314
+ tags?: unknown; confidence?: unknown; links?: unknown;
315
+ };
316
+ const type = typeof concept.type === "string" ? concept.type.trim() : "";
317
+ const title = typeof concept.title === "string" ? concept.title.trim() : "";
318
+ if (!type || !title) continue;
319
+ try {
320
+ // Unknown types fall back to facts/ (lenient — OKF tolerates extra types).
321
+ const dir = DIR_BY_TYPE[type] ?? "facts";
322
+
323
+ const targetDir = path.join(bundleDir, dir);
324
+ await fs.mkdir(targetDir, { recursive: true });
325
+
326
+ let slug = slugify(title);
327
+ let relPath = `${dir}/${slug}.md`;
328
+ let fullPath = path.join(bundleDir, relPath);
329
+
330
+ let suffix = 1;
331
+ while (true) {
332
+ try {
333
+ const existingContent = await fs.readFile(fullPath, "utf-8");
334
+ const parsed = parseConcept(existingContent);
335
+ const existingTitle = parsed.frontmatter.title || "";
336
+ if (existingTitle === title) {
337
+ break;
338
+ }
339
+ slug = `${slugify(title)}-${suffix}`;
340
+ relPath = `${dir}/${slug}.md`;
341
+ fullPath = path.join(bundleDir, relPath);
342
+ suffix++;
343
+ } catch {
324
344
  break;
325
345
  }
326
- slug = `${slugify(concept.title)}-${suffix}`;
327
- relPath = `${dir}/${slug}.md`;
328
- fullPath = path.join(bundleDir, relPath);
329
- suffix++;
330
- } catch {
331
- break;
332
346
  }
333
- }
334
347
 
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 });
348
+ let existingFm = {};
349
+ try {
350
+ const existingContent = await fs.readFile(fullPath, "utf-8");
351
+ existingFm = parseConcept(existingContent).frontmatter;
352
+ } catch {}
353
+
354
+ const frontmatter = {
355
+ ...existingFm,
356
+ type,
357
+ title,
358
+ description: typeof concept.description === "string" ? concept.description : "",
359
+ tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
360
+ timestamp: new Date().toISOString(),
361
+ confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
362
+ last_verified: new Date().toISOString().split("T")[0],
363
+ links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
364
+ };
365
+
366
+ const serialized = serializeConcept(frontmatter, typeof concept.body === "string" ? concept.body : "");
367
+ const tmpPath = `${fullPath}.tmp-${process.pid}`;
368
+ await fs.writeFile(tmpPath, serialized, "utf-8");
369
+ await fs.rename(tmpPath, fullPath);
370
+
371
+ updatedConcepts.push({ title, type });
372
+ } catch {
373
+ // Skip just this concept; keep distilling the rest of the batch.
374
+ }
359
375
  }
360
376
 
361
377
  await rebuildIndex(bundleDir);
@@ -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: STREAM_IDLE_TIMEOUT_MS,
489
+ idleMs: streamIdleMs(),
479
490
  ...(maxMs !== undefined ? { deadlineAt: Date.now() + maxMs } : {}),
480
491
  onIdle: () => attempt?.abort(),
481
492
  });
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
  }