jeo-code 0.6.17 → 0.6.19
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 +21 -0
- package/README.ja.md +44 -2
- package/README.ko.md +44 -2
- package/README.md +44 -2
- package/README.zh.md +44 -2
- package/package.json +1 -1
- package/src/agent/engine.ts +39 -32
- package/src/agent/hooks.ts +103 -34
- package/src/tui/components/width.ts +42 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,27 @@ 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.19] - 2026-06-18
|
|
10
|
+
_Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint._
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- **Post-turn hooks execute ONCE per multi-call batch instead of once per result.** A project-wide checker hook (`tsc --noEmit`/lint/test) whose `match.tool` matched every edit in a batch previously re-ran N times sequentially — the dominant in-loop latency multiplier on multi-edit turns. `runPostTurnHooksForBatch` now groups the batch's calls, invokes each matching hook a single time, and runs distinct hooks concurrently. A hook matching several calls receives a back-compatible payload (`{event,tool,args,success,output}` plus a `calls[]` array of every matched call) so a payload-aware per-file hook can still iterate the changed files in one invocation; a single match keeps the exact legacy shape. The single-call `runPostTurnHooks` is retained as a thin wrapper for direct callers/tests.
|
|
14
|
+
- **Tool-result bodies are formatted in parallel.** The per-result loop that serialized body formatting (and any oversized-body spill to a disk artifact) is replaced by a `Promise.all` over the batch, so independent formatting/disk writes overlap.
|
|
15
|
+
|
|
16
|
+
### Performance
|
|
17
|
+
- **Local `.jeo/hooks.json` is mtime/size-cached.** The per-project hook override was re-read and re-parsed (`fs.readFile` + `JSON.parse`) on every `loadHooks` call — once per pre-tool check and once per post-turn batch. It is now cached keyed by absolute path → mtime/size (bounded LRU, cap 32) and only re-read when the file actually changes, so any external write is still picked up immediately without a stale serve.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- **Wrapped colored text keeps its color on every continuation row.** `wrapTextWithAnsi` is now SGR-stateful: a color opened before a wrap point is re-applied at the start of each continuation line and closed at its end, so a wrapped colored span stays tinted on every row (the reported "color breaks when the line wraps") instead of losing its tint after the first line — and never bleeds into the padding or box border. Plain uncolored text is left byte-for-byte unchanged.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
## [0.6.18] - 2026-06-17
|
|
24
|
+
_Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior._
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- **Editable memory data-flow diagram (`docs/diagrams/memory-flow.drawio`).** A draw.io swimlane diagram traces the OKF memory system's actual runtime behavior end to end: the **WRITE** lane (session-exit `spawnDetachedDistill` → `distillSessionMemory` → one JSON-mode LLM call → per-concept atomic upsert into `facts/`/`commands/`/`gotchas/`/`preferences/`, with a plain-text legacy fallback, then `rebuildIndex`/`updateLog`), the **STORE** lane (the typed concept bundle, `index.md`/`log.md`, the cross-link graph, and the legacy `MEMORY.md`/`.bak`), the **READ** lane (`memoryPromptSection` → `JEO_NO_MEMORY`/`JEO_MEMORY_LEGACY` gates → `loadConcepts` → `selectWithinBudget` priority order with 1-hop graph expansion → `frameMemory` injection-hardening → `<project_memory>` injection), and the one-shot idempotent **MIGRATION** lane (`jeo memory-migrate`).
|
|
28
|
+
- **README "Memory flow" section (all four languages).** A new section in `README.md` / `README.ko.md` / `README.ja.md` / `README.zh.md` explains the local-first distilled memory model and embeds a GitHub-renderable Mermaid version of the write/store/read/migration flow, links the editable `.drawio`, and documents the `JEO_NO_MEMORY` / `JEO_MEMORY_LEGACY` toggles and the migration's rollback path.
|
|
29
|
+
|
|
9
30
|
## [0.6.17] - 2026-06-17
|
|
10
31
|
_Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle._
|
|
11
32
|
|
package/README.ja.md
CHANGED
|
@@ -119,6 +119,48 @@ jeo ultragoal
|
|
|
119
119
|
|
|
120
120
|
非ゼロ終了したフックの出力はモデルが読むツール結果に付加され(バッチ内で重複排除)、フックが赤のまま `done` を呼ぶとフック名付きでプッシュバックされます。
|
|
121
121
|
|
|
122
|
+
## メモリフロー
|
|
123
|
+
|
|
124
|
+
`jeo` は `.jeo/memory/` 配下に **ローカルファースト・蒸留されたプロジェクトメモリ** を保持します(リモートバックエンドなし、ネイティブ依存ゼロ)。過去のセッションは [OKF](docs/okf_mem/) コンセプトバンドルへ蒸留され、次のセッションは関連性の高い予算内のスライスだけをシステムプロンプトへ再注入します — 指示ではなく DATA として堅牢化されます。`JEO_NO_MEMORY=1` ですべて無効化。
|
|
125
|
+
|
|
126
|
+
📐 **編集可能な図:** [`docs/diagrams/memory-flow.drawio`](docs/diagrams/memory-flow.drawio)([draw.io](https://app.diagrams.net) / デスクトップアプリで開く) — 書き込み/保存/読み込み/移行の全スイムレーン。概要:
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart LR
|
|
130
|
+
subgraph WRITE["WRITE — session-end distill (detached, best-effort)"]
|
|
131
|
+
direction TB
|
|
132
|
+
W1["session exit / ^C^C"] --> W2["spawnDetachedDistill()<br/>payload + detached child, returns instantly"]
|
|
133
|
+
W2 --> W3["distillSessionMemory()<br/>load bundle · transcriptTail · ONE LLM call (JSON)"]
|
|
134
|
+
W3 --> WD{"concepts JSON<br/>parsed?"}
|
|
135
|
+
WD -->|yes| WY["per concept: upsert by title,<br/>atomic write into facts/ commands/<br/>gotchas/ preferences/"]
|
|
136
|
+
WD -->|no| WN["plain text →<br/>legacy MEMORY.md"]
|
|
137
|
+
WY --> WR["rebuildIndex() index.md<br/>updateLog() log.md"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
subgraph STORE[".jeo/memory/ — OKF concept bundle"]
|
|
141
|
+
direction TB
|
|
142
|
+
S1["facts/ · commands/ · gotchas/ · preferences/<br/>(YAML frontmatter + body)"]
|
|
143
|
+
S2["index.md · log.md · cross-link graph (Sprint 04)"]
|
|
144
|
+
S3["MEMORY.md (legacy fallback)<br/>MEMORY.md.bak (rollback)"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
subgraph READ["READ — memoryPromptSection(cwd, query)"]
|
|
148
|
+
direction TB
|
|
149
|
+
R1["session start (query = task text)"] --> R2{"bundle has<br/>concepts?"}
|
|
150
|
+
R2 -->|yes| R3["selectWithinBudget()<br/>core → query relevance → 1-hop graph<br/>≤ MEMORY_INJECT_MAX_CHARS (3000)"]
|
|
151
|
+
R2 -->|no| R3B["legacy loadMemory()"]
|
|
152
|
+
R3 --> R4["frameMemory()<br/>hard cap · fence-neutralize · DATA framing"]
|
|
153
|
+
R3B --> R4
|
|
154
|
+
R4 --> R5["<project_memory> … injected into system prompt"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
WR -->|atomic| STORE
|
|
158
|
+
WN -->|fallback| S3
|
|
159
|
+
STORE -.->|loadConcepts / loadMemory| READ
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**移行(`jeo memory-migrate`、ワンショット・冪等).** レガシーの単一ドキュメント `MEMORY.md` をロスレスでバンドルへ変換します: `## 見出し → タイプ`、各箇条書き → タイプ別コンセプト、インデント行 → 本文; `index.md`/`log.md` を再構築し、元ファイルを `MEMORY.md.bak` にリネームします。バンドルにコンセプトができた後の再実行は no-op です。**ロールバック:** `JEO_MEMORY_LEGACY=1` はバンドルを無視し、同じ注入堅牢化を通して `MEMORY.md`/`.bak` を読みます(`JEO_NO_MEMORY=1` がすべてに優先)。
|
|
163
|
+
|
|
122
164
|
## ローカルモデル
|
|
123
165
|
|
|
124
166
|
```bash
|
|
@@ -158,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
158
200
|
## 変更履歴 (Changelog)
|
|
159
201
|
|
|
160
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
204
|
+
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
161
205
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
206
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
163
207
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
164
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
165
|
-
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
166
208
|
|
|
167
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
210
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -119,6 +119,48 @@ jeo ultragoal
|
|
|
119
119
|
|
|
120
120
|
비정상 종료한 훅의 출력은 모델이 읽는 도구 결과에 첨부되고(배치 내 중복 제거), 훅이 빨간 채로 `done`을 부르면 훅 이름과 함께 푸시백됩니다.
|
|
121
121
|
|
|
122
|
+
## 메모리 흐름
|
|
123
|
+
|
|
124
|
+
`jeo`는 `.jeo/memory/` 아래에 **로컬 우선·증류된 프로젝트 메모리**를 둡니다(원격 백엔드 없음, 네이티브 의존성 0). 지난 세션은 [OKF](docs/okf_mem/) 개념 번들로 증류되고, 다음 세션은 관련성 높은 예산 한도 내 일부만 시스템 프롬프트로 다시 주입합니다 — 지시가 아닌 DATA로 강화 처리됩니다. `JEO_NO_MEMORY=1`로 전체 비활성화.
|
|
125
|
+
|
|
126
|
+
📐 **편집 가능한 다이어그램:** [`docs/diagrams/memory-flow.drawio`](docs/diagrams/memory-flow.drawio) ([draw.io](https://app.diagrams.net) / 데스크톱 앱에서 열기) — 쓰기/저장/읽기/마이그레이션 전체 스윔레인. 요약 보기:
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart LR
|
|
130
|
+
subgraph WRITE["WRITE — session-end distill (detached, best-effort)"]
|
|
131
|
+
direction TB
|
|
132
|
+
W1["session exit / ^C^C"] --> W2["spawnDetachedDistill()<br/>payload + detached child, returns instantly"]
|
|
133
|
+
W2 --> W3["distillSessionMemory()<br/>load bundle · transcriptTail · ONE LLM call (JSON)"]
|
|
134
|
+
W3 --> WD{"concepts JSON<br/>parsed?"}
|
|
135
|
+
WD -->|yes| WY["per concept: upsert by title,<br/>atomic write into facts/ commands/<br/>gotchas/ preferences/"]
|
|
136
|
+
WD -->|no| WN["plain text →<br/>legacy MEMORY.md"]
|
|
137
|
+
WY --> WR["rebuildIndex() index.md<br/>updateLog() log.md"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
subgraph STORE[".jeo/memory/ — OKF concept bundle"]
|
|
141
|
+
direction TB
|
|
142
|
+
S1["facts/ · commands/ · gotchas/ · preferences/<br/>(YAML frontmatter + body)"]
|
|
143
|
+
S2["index.md · log.md · cross-link graph (Sprint 04)"]
|
|
144
|
+
S3["MEMORY.md (legacy fallback)<br/>MEMORY.md.bak (rollback)"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
subgraph READ["READ — memoryPromptSection(cwd, query)"]
|
|
148
|
+
direction TB
|
|
149
|
+
R1["session start (query = task text)"] --> R2{"bundle has<br/>concepts?"}
|
|
150
|
+
R2 -->|yes| R3["selectWithinBudget()<br/>core → query relevance → 1-hop graph<br/>≤ MEMORY_INJECT_MAX_CHARS (3000)"]
|
|
151
|
+
R2 -->|no| R3B["legacy loadMemory()"]
|
|
152
|
+
R3 --> R4["frameMemory()<br/>hard cap · fence-neutralize · DATA framing"]
|
|
153
|
+
R3B --> R4
|
|
154
|
+
R4 --> R5["<project_memory> … injected into system prompt"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
WR -->|atomic| STORE
|
|
158
|
+
WN -->|fallback| S3
|
|
159
|
+
STORE -.->|loadConcepts / loadMemory| READ
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**마이그레이션 (`jeo memory-migrate`, 1회성 · 멱등).** 레거시 단일 문서 `MEMORY.md`를 무손실로 번들로 변환합니다: `## 헤딩 → 타입`, 각 불릿 → 타입별 개념, 들여쓴 줄 → 본문; `index.md`/`log.md`를 재생성하고 원본은 `MEMORY.md.bak`으로 이름을 바꿉니다. 번들에 개념이 생긴 뒤 재실행은 no-op입니다. **롤백:** `JEO_MEMORY_LEGACY=1`은 번들을 무시하고 동일한 주입 강화 처리로 `MEMORY.md`/`.bak`를 읽습니다(`JEO_NO_MEMORY=1`이 모든 것에 우선).
|
|
163
|
+
|
|
122
164
|
## 로컬 모델
|
|
123
165
|
|
|
124
166
|
```bash
|
|
@@ -158,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
158
200
|
## 변경 이력 (Changelog)
|
|
159
201
|
|
|
160
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
204
|
+
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
161
205
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
206
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
163
207
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
164
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
165
|
-
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
166
208
|
|
|
167
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
210
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -119,6 +119,48 @@ Enable hooks once globally (`"hooks": { "enabled": true }` in `~/.jeo/config.jso
|
|
|
119
119
|
|
|
120
120
|
Non-zero hook output is appended to the tool result the model reads (deduped per batch); a still-red hook triggers a `done` pushback naming the hook.
|
|
121
121
|
|
|
122
|
+
## Memory flow
|
|
123
|
+
|
|
124
|
+
`jeo` keeps a **local-first, distilled project memory** under `.jeo/memory/` (no remote backend, zero native deps). Past sessions are distilled into an [OKF](docs/okf_mem/) concept bundle, and the next session injects only the relevant, budget-bounded slice back into the system prompt — hardened as DATA, never as instructions. Disable everything with `JEO_NO_MEMORY=1`.
|
|
125
|
+
|
|
126
|
+
📐 **Editable diagram:** [`docs/diagrams/memory-flow.drawio`](docs/diagrams/memory-flow.drawio) (open in [draw.io](https://app.diagrams.net) / the desktop app) — full write/store/read/migration swimlanes. Quick view:
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart LR
|
|
130
|
+
subgraph WRITE["WRITE — session-end distill (detached, best-effort)"]
|
|
131
|
+
direction TB
|
|
132
|
+
W1["session exit / ^C^C"] --> W2["spawnDetachedDistill()<br/>payload + detached child, returns instantly"]
|
|
133
|
+
W2 --> W3["distillSessionMemory()<br/>load bundle · transcriptTail · ONE LLM call (JSON)"]
|
|
134
|
+
W3 --> WD{"concepts JSON<br/>parsed?"}
|
|
135
|
+
WD -->|yes| WY["per concept: upsert by title,<br/>atomic write into facts/ commands/<br/>gotchas/ preferences/"]
|
|
136
|
+
WD -->|no| WN["plain text →<br/>legacy MEMORY.md"]
|
|
137
|
+
WY --> WR["rebuildIndex() index.md<br/>updateLog() log.md"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
subgraph STORE[".jeo/memory/ — OKF concept bundle"]
|
|
141
|
+
direction TB
|
|
142
|
+
S1["facts/ · commands/ · gotchas/ · preferences/<br/>(YAML frontmatter + body)"]
|
|
143
|
+
S2["index.md · log.md · cross-link graph (Sprint 04)"]
|
|
144
|
+
S3["MEMORY.md (legacy fallback)<br/>MEMORY.md.bak (rollback)"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
subgraph READ["READ — memoryPromptSection(cwd, query)"]
|
|
148
|
+
direction TB
|
|
149
|
+
R1["session start (query = task text)"] --> R2{"bundle has<br/>concepts?"}
|
|
150
|
+
R2 -->|yes| R3["selectWithinBudget()<br/>core → query relevance → 1-hop graph<br/>≤ MEMORY_INJECT_MAX_CHARS (3000)"]
|
|
151
|
+
R2 -->|no| R3B["legacy loadMemory()"]
|
|
152
|
+
R3 --> R4["frameMemory()<br/>hard cap · fence-neutralize · DATA framing"]
|
|
153
|
+
R3B --> R4
|
|
154
|
+
R4 --> R5["<project_memory> … injected into system prompt"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
WR -->|atomic| STORE
|
|
158
|
+
WN -->|fallback| S3
|
|
159
|
+
STORE -.->|loadConcepts / loadMemory| READ
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**Migration (`jeo memory-migrate`, one-shot · idempotent).** A legacy single-doc `MEMORY.md` is converted losslessly into the bundle: `## heading → type`, each bullet → a typed concept, indented lines → body; `index.md`/`log.md` are rebuilt and the original is renamed to `MEMORY.md.bak`. Re-running is a no-op once the bundle has concepts. **Rollback:** `JEO_MEMORY_LEGACY=1` ignores the bundle and reads `MEMORY.md`/`.bak` through the same injection-hardening (`JEO_NO_MEMORY=1` still wins over everything).
|
|
163
|
+
|
|
122
164
|
## Local models
|
|
123
165
|
|
|
124
166
|
```bash
|
|
@@ -158,11 +200,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
|
|
|
158
200
|
## Changelog
|
|
159
201
|
|
|
160
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
204
|
+
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
161
205
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
206
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
163
207
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
164
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
165
|
-
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
166
208
|
|
|
167
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
210
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -119,6 +119,48 @@ jeo ultragoal
|
|
|
119
119
|
|
|
120
120
|
非零退出钩子的输出会附加到模型读取的工具结果中(批内去重);钩子未通过就调用 `done` 会收到带钩子名称的回推。
|
|
121
121
|
|
|
122
|
+
## 内存流程
|
|
123
|
+
|
|
124
|
+
`jeo` 在 `.jeo/memory/` 下保存 **本地优先、蒸馏后的项目内存**(无远程后端,零原生依赖)。过往会话被蒸馏为 [OKF](docs/okf_mem/) 概念包,下一次会话仅把相关的、受预算约束的切片重新注入系统提示 —— 作为 DATA 而非指令加固。用 `JEO_NO_MEMORY=1` 完全禁用。
|
|
125
|
+
|
|
126
|
+
📐 **可编辑图示:** [`docs/diagrams/memory-flow.drawio`](docs/diagrams/memory-flow.drawio)(在 [draw.io](https://app.diagrams.net) / 桌面应用中打开)—— 写入/存储/读取/迁移完整泳道。概览:
|
|
127
|
+
|
|
128
|
+
```mermaid
|
|
129
|
+
flowchart LR
|
|
130
|
+
subgraph WRITE["WRITE — session-end distill (detached, best-effort)"]
|
|
131
|
+
direction TB
|
|
132
|
+
W1["session exit / ^C^C"] --> W2["spawnDetachedDistill()<br/>payload + detached child, returns instantly"]
|
|
133
|
+
W2 --> W3["distillSessionMemory()<br/>load bundle · transcriptTail · ONE LLM call (JSON)"]
|
|
134
|
+
W3 --> WD{"concepts JSON<br/>parsed?"}
|
|
135
|
+
WD -->|yes| WY["per concept: upsert by title,<br/>atomic write into facts/ commands/<br/>gotchas/ preferences/"]
|
|
136
|
+
WD -->|no| WN["plain text →<br/>legacy MEMORY.md"]
|
|
137
|
+
WY --> WR["rebuildIndex() index.md<br/>updateLog() log.md"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
subgraph STORE[".jeo/memory/ — OKF concept bundle"]
|
|
141
|
+
direction TB
|
|
142
|
+
S1["facts/ · commands/ · gotchas/ · preferences/<br/>(YAML frontmatter + body)"]
|
|
143
|
+
S2["index.md · log.md · cross-link graph (Sprint 04)"]
|
|
144
|
+
S3["MEMORY.md (legacy fallback)<br/>MEMORY.md.bak (rollback)"]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
subgraph READ["READ — memoryPromptSection(cwd, query)"]
|
|
148
|
+
direction TB
|
|
149
|
+
R1["session start (query = task text)"] --> R2{"bundle has<br/>concepts?"}
|
|
150
|
+
R2 -->|yes| R3["selectWithinBudget()<br/>core → query relevance → 1-hop graph<br/>≤ MEMORY_INJECT_MAX_CHARS (3000)"]
|
|
151
|
+
R2 -->|no| R3B["legacy loadMemory()"]
|
|
152
|
+
R3 --> R4["frameMemory()<br/>hard cap · fence-neutralize · DATA framing"]
|
|
153
|
+
R3B --> R4
|
|
154
|
+
R4 --> R5["<project_memory> … injected into system prompt"]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
WR -->|atomic| STORE
|
|
158
|
+
WN -->|fallback| S3
|
|
159
|
+
STORE -.->|loadConcepts / loadMemory| READ
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**迁移(`jeo memory-migrate`,一次性 · 幂等).** 把旧版单文档 `MEMORY.md` 无损转换为概念包: `## 标题 → 类型`,每个项目符号 → 一个类型化概念,缩进行 → 正文; 重建 `index.md`/`log.md`,并把原文件重命名为 `MEMORY.md.bak`。一旦概念包中已有概念,再次运行即为 no-op。**回滚:** `JEO_MEMORY_LEGACY=1` 忽略概念包,通过相同的注入加固读取 `MEMORY.md`/`.bak`(`JEO_NO_MEMORY=1` 仍优先于一切)。
|
|
163
|
+
|
|
122
164
|
## 本地模型
|
|
123
165
|
|
|
124
166
|
```bash
|
|
@@ -158,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
158
200
|
## 更新日志 (Changelog)
|
|
159
201
|
|
|
160
202
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
203
|
+
- **[0.6.19]** (2026-06-18) — Post-turn hooks run once per batch (not per edit), local hook reads are mtime-cached, tool-result formatting is parallelized, and wrapped colored text keeps its tint.
|
|
204
|
+
- **[0.6.18]** (2026-06-17) — Memory data-flow diagram and a README "Memory flow" section documenting the actual runtime behavior.
|
|
161
205
|
- **[0.6.17]** (2026-06-17) — Legacy MEMORY.md migrates losslessly into the OKF concept bundle, with a one-shot command and a rollback toggle.
|
|
162
206
|
- **[0.6.16]** (2026-06-17) — OKF memory grows a concept cross-link graph: 1-hop search expansion, bundle lint, graphify-optional.
|
|
163
207
|
- **[0.6.15]** (2026-06-17) — Query-aware OKF memory injection with budget-priority selection, and a truthful end-of-turn Todos receipt.
|
|
164
|
-
- **[0.6.14]** (2026-06-16) — Memory distillation survives malformed model output, and stream-idle stalls retry instead of failing the turn.
|
|
165
|
-
- **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
|
|
166
208
|
|
|
167
209
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
168
210
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/engine.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { readTool, writeTool, editTool, bashTool, findTool, searchTool, lsTool,
|
|
|
16
16
|
import { webSearchTool, setWebSearchActiveModel } from "./web-search";
|
|
17
17
|
import { friendlyProviderError, isContextOverflowError, isRefusalError } from "../util/provider-error";
|
|
18
18
|
import { isRateLimitError } from "../util/retry";
|
|
19
|
-
import { runPreToolHooks,
|
|
19
|
+
import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
|
|
20
20
|
import { truncateToolOutput, formatToolResultBody } from "./tool-output";
|
|
21
21
|
export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
|
|
22
22
|
import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
|
|
@@ -903,46 +903,53 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
903
903
|
}
|
|
904
904
|
|
|
905
905
|
const processAndPushResults = async (indices: number[]) => {
|
|
906
|
-
|
|
907
|
-
// Per-batch dedup of post-turn hook diagnostics: a whole-project `tsc` hook
|
|
908
|
-
// matching every edit in a batch yields identical output N times — show it
|
|
909
|
-
// once, cross-reference the rest (cycle 13).
|
|
910
|
-
const seenHookFeedback = new Set<string>();
|
|
906
|
+
// Surface completion to the TUI ledger in call order.
|
|
911
907
|
for (const idx of indices) {
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
908
|
+
ev.onToolResult?.(toolCalls[idx].tool, results[idx].success, results[idx].output);
|
|
909
|
+
}
|
|
910
|
+
// Format every result body in PARALLEL — independent work, and an oversized
|
|
911
|
+
// body may spill to a disk artifact; the previous per-result loop serialized
|
|
912
|
+
// both the formatting and the disk writes.
|
|
913
|
+
const bodies = await Promise.all(
|
|
914
|
+
indices.map(idx => formatToolResultBody(toolCalls[idx].tool, results[idx].output, cwd)),
|
|
915
|
+
);
|
|
916
|
+
// Run post-turn hooks ONCE for the whole batch instead of once per result: a
|
|
917
|
+
// project-wide `tsc`/lint/test hook matching every edit in the batch no longer
|
|
918
|
+
// re-executes N times sequentially (the dominant in-loop latency multiplier).
|
|
919
|
+
const { diags: hookDiags, ran: hooksRan } = await runPostTurnHooksForBatch(
|
|
920
|
+
cwd,
|
|
921
|
+
indices.map(idx => ({
|
|
922
|
+
tool: toolCalls[idx].tool,
|
|
923
|
+
args: toolCalls[idx].arguments ?? {},
|
|
924
|
+
success: results[idx].success,
|
|
925
|
+
output: results[idx].output,
|
|
926
|
+
})),
|
|
927
|
+
opts.signal,
|
|
928
|
+
ev.onNotice,
|
|
929
|
+
);
|
|
930
|
+
// F1: a red hook becomes a pending failure the done guard enforces; a later
|
|
931
|
+
// batch whose hooks complete CLEAN (ran > 0, zero diags) clears it.
|
|
932
|
+
if (hookDiags.length > 0) pendingHookFailure = hookDiags[hookDiags.length - 1].run;
|
|
933
|
+
else if (hooksRan > 0) pendingHookFailure = null;
|
|
932
934
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
935
|
+
const resultBlocks: string[] = indices.map((idx, i) =>
|
|
936
|
+
`Tool [${toolCalls[idx].tool}] result (${results[idx].success ? "ok" : "fail"}):\n${bodies[i]}`,
|
|
937
|
+
);
|
|
938
|
+
// Append the batch's hook diagnostics once so the model can self-correct. Two
|
|
939
|
+
// DISTINCT hooks with identical output collapse to one full block + a cross-ref.
|
|
940
|
+
if (hookDiags.length > 0) {
|
|
941
|
+
const seenHookFeedback = new Set<string>();
|
|
942
|
+
const diagLines: string[] = [];
|
|
936
943
|
for (const d of hookDiags) {
|
|
937
944
|
const key = `${d.run}\u0000${d.output}`;
|
|
938
945
|
if (seenHookFeedback.has(key)) {
|
|
939
|
-
|
|
946
|
+
diagLines.push(`[post-turn hook "${d.run}" — exit ${d.exitCode}: same diagnostics as above]`);
|
|
940
947
|
} else {
|
|
941
948
|
seenHookFeedback.add(key);
|
|
942
|
-
|
|
949
|
+
diagLines.push(`[post-turn hook "${d.run}" — exit ${d.exitCode}]:\n${truncateToolOutput(d.output)}`);
|
|
943
950
|
}
|
|
944
951
|
}
|
|
945
|
-
resultBlocks.push(
|
|
952
|
+
resultBlocks.push(diagLines.join("\n"));
|
|
946
953
|
}
|
|
947
954
|
|
|
948
955
|
history.push({ role: "assistant", content: responseText });
|
package/src/agent/hooks.ts
CHANGED
|
@@ -22,27 +22,56 @@ export interface PostTurnHookDiag {
|
|
|
22
22
|
output: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// Local `.jeo/hooks.json` read cache, keyed by absolute path → mtime/size. The
|
|
26
|
+
// global config is already mtime-cached in state.ts, but the per-project local
|
|
27
|
+
// override was re-read (fs.readFile + JSON.parse) on every loadHooks call — once
|
|
28
|
+
// per pre-tool hook check and once per post-turn batch. Cache the parsed outcome
|
|
29
|
+
// and re-read only when the file's mtime/size changes (any external write bumps
|
|
30
|
+
// both, so a stale entry is never served).
|
|
31
|
+
type LocalHooks =
|
|
32
|
+
| { kind: "disabled" }
|
|
33
|
+
| { kind: "hooks"; hooks: NonNullable<HookConfig["hooks"]> }
|
|
34
|
+
| { kind: "fallback" };
|
|
35
|
+
const localHooksCache = new Map<string, { mtimeMs: number; size: number; result: LocalHooks }>();
|
|
36
|
+
const LOCAL_HOOKS_CACHE_CAP = 32;
|
|
37
|
+
|
|
38
|
+
async function readLocalHooks(localPath: string): Promise<LocalHooks> {
|
|
39
|
+
let st: { mtimeMs: number; size: number };
|
|
40
|
+
try {
|
|
41
|
+
st = await fs.stat(localPath);
|
|
42
|
+
} catch {
|
|
43
|
+
localHooksCache.delete(localPath);
|
|
44
|
+
return { kind: "fallback" };
|
|
45
|
+
}
|
|
46
|
+
const hit = localHooksCache.get(localPath);
|
|
47
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.result;
|
|
48
|
+
let result: LocalHooks = { kind: "fallback" };
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(await fs.readFile(localPath, "utf-8"));
|
|
51
|
+
if (parsed && typeof parsed === "object") {
|
|
52
|
+
if (parsed.enabled === false) result = { kind: "disabled" };
|
|
53
|
+
else if (Array.isArray(parsed.hooks)) result = { kind: "hooks", hooks: parsed.hooks };
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
// Missing/invalid local file → fall back to the global config hooks.
|
|
57
|
+
}
|
|
58
|
+
if (localHooksCache.size >= LOCAL_HOOKS_CACHE_CAP && !localHooksCache.has(localPath)) {
|
|
59
|
+
const oldest = localHooksCache.keys().next().value;
|
|
60
|
+
if (oldest !== undefined) localHooksCache.delete(oldest);
|
|
61
|
+
}
|
|
62
|
+
localHooksCache.set(localPath, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
export async function loadHooks(cwd: string): Promise<NonNullable<HookConfig["hooks"]>> {
|
|
26
67
|
const config = await readGlobalConfig();
|
|
27
68
|
if (!config.hooks?.enabled) {
|
|
28
69
|
return [];
|
|
29
70
|
}
|
|
30
71
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const parsed = JSON.parse(content);
|
|
35
|
-
if (parsed && typeof parsed === "object") {
|
|
36
|
-
if (parsed.enabled === false) {
|
|
37
|
-
return [];
|
|
38
|
-
}
|
|
39
|
-
if (Array.isArray(parsed.hooks)) {
|
|
40
|
-
return parsed.hooks;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
} catch (e) {
|
|
44
|
-
// If local file is missing or invalid, fall back to global
|
|
45
|
-
}
|
|
72
|
+
const local = await readLocalHooks(path.join(cwd, ".jeo", "hooks.json"));
|
|
73
|
+
if (local.kind === "disabled") return [];
|
|
74
|
+
if (local.kind === "hooks") return local.hooks;
|
|
46
75
|
|
|
47
76
|
return config.hooks?.hooks || [];
|
|
48
77
|
}
|
|
@@ -180,12 +209,32 @@ export async function runPreToolHooks(
|
|
|
180
209
|
return { vetoed: false };
|
|
181
210
|
}
|
|
182
211
|
|
|
183
|
-
|
|
212
|
+
/** One executed tool call fed to the post-turn hooks of a batch. */
|
|
213
|
+
export interface PostTurnCall {
|
|
214
|
+
tool: string;
|
|
215
|
+
args: Record<string, any>;
|
|
216
|
+
success: boolean;
|
|
217
|
+
output: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function outputPreviewOf(output: string): string {
|
|
221
|
+
return output.length > 10000 ? output.slice(0, 10000) + "\n... (truncated)" : output;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Run post-turn hooks for a whole batch of executed tool calls, invoking each
|
|
226
|
+
* matching hook EXACTLY ONCE — not once per result. A project-wide checker
|
|
227
|
+
* (`tsc --noEmit`/lint/test) that matches every edit in a batch previously ran N
|
|
228
|
+
* times sequentially; now it runs a single time. Distinct hooks run concurrently.
|
|
229
|
+
*
|
|
230
|
+
* Payload back-compat: a hook that matches a single call gets the legacy
|
|
231
|
+
* `{event,tool,args,success,output}` shape; a hook matching several gets the same
|
|
232
|
+
* fields plus a `calls[]` array (every matched call) so a payload-aware per-file
|
|
233
|
+
* hook can still iterate the changed files in one invocation.
|
|
234
|
+
*/
|
|
235
|
+
export async function runPostTurnHooksForBatch(
|
|
184
236
|
cwd: string,
|
|
185
|
-
|
|
186
|
-
args: Record<string, any>,
|
|
187
|
-
success: boolean,
|
|
188
|
-
output: string,
|
|
237
|
+
calls: readonly PostTurnCall[],
|
|
189
238
|
signal?: AbortSignal,
|
|
190
239
|
onNotice?: (msg: string) => void
|
|
191
240
|
): Promise<{ diags: PostTurnHookDiag[]; ran: number }> {
|
|
@@ -195,23 +244,29 @@ export async function runPostTurnHooks(
|
|
|
195
244
|
let ran = 0;
|
|
196
245
|
try {
|
|
197
246
|
const hooks = await loadHooks(cwd);
|
|
198
|
-
const
|
|
199
|
-
h => h.event === "post-turn"
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const outputPreview = output.length > 10000 ? output.slice(0, 10000) + "\n... (truncated)" : output;
|
|
203
|
-
|
|
204
|
-
for (const hook of postTurnHooks) {
|
|
205
|
-
const payload = {
|
|
206
|
-
event: "post-turn",
|
|
207
|
-
tool,
|
|
208
|
-
args,
|
|
209
|
-
success,
|
|
210
|
-
output: outputPreview,
|
|
211
|
-
};
|
|
247
|
+
const jobs = hooks
|
|
248
|
+
.filter(h => h.event === "post-turn")
|
|
249
|
+
.map(hook => ({ hook, matched: calls.filter(c => hookMatchesTool(hook.match?.tool, c.tool)) }))
|
|
250
|
+
.filter(j => j.matched.length > 0);
|
|
212
251
|
|
|
252
|
+
// Distinct hooks are independent commands → run them concurrently. Each hook
|
|
253
|
+
// itself runs once for the whole batch (the redundancy this fix removes).
|
|
254
|
+
const settled = await Promise.all(jobs.map(async ({ hook, matched }) => {
|
|
255
|
+
const payload = matched.length === 1
|
|
256
|
+
? { event: "post-turn", tool: matched[0].tool, args: matched[0].args, success: matched[0].success, output: outputPreviewOf(matched[0].output) }
|
|
257
|
+
: {
|
|
258
|
+
event: "post-turn",
|
|
259
|
+
tool: matched.map(c => c.tool).join(","),
|
|
260
|
+
calls: matched.map(c => ({ tool: c.tool, args: c.args, success: c.success })),
|
|
261
|
+
success: matched.every(c => c.success),
|
|
262
|
+
output: outputPreviewOf(matched.map(c => c.output).join("\n")),
|
|
263
|
+
};
|
|
213
264
|
const timeoutMs = hook.timeoutMs || 30000;
|
|
214
265
|
const result = await runHookCommand(hook.run, payload, cwd, timeoutMs, signal);
|
|
266
|
+
return { hook, result };
|
|
267
|
+
}));
|
|
268
|
+
|
|
269
|
+
for (const { hook, result } of settled) {
|
|
215
270
|
if (result.timedOut) {
|
|
216
271
|
onNotice?.(`Post-turn hook "${hook.run}" timed out (advisory).`);
|
|
217
272
|
} else if (result.aborted) {
|
|
@@ -235,6 +290,20 @@ export async function runPostTurnHooks(
|
|
|
235
290
|
return { diags, ran };
|
|
236
291
|
}
|
|
237
292
|
|
|
293
|
+
/** Single-call convenience wrapper (kept for direct callers/tests). Delegates to
|
|
294
|
+
* the batch runner with one call, preserving the legacy payload shape. */
|
|
295
|
+
export async function runPostTurnHooks(
|
|
296
|
+
cwd: string,
|
|
297
|
+
tool: string,
|
|
298
|
+
args: Record<string, any>,
|
|
299
|
+
success: boolean,
|
|
300
|
+
output: string,
|
|
301
|
+
signal?: AbortSignal,
|
|
302
|
+
onNotice?: (msg: string) => void
|
|
303
|
+
): Promise<{ diags: PostTurnHookDiag[]; ran: number }> {
|
|
304
|
+
return runPostTurnHooksForBatch(cwd, [{ tool, args, success, output }], signal, onNotice);
|
|
305
|
+
}
|
|
306
|
+
|
|
238
307
|
export async function runPostImplementationHooks(
|
|
239
308
|
cwd: string,
|
|
240
309
|
request: string,
|
|
@@ -185,13 +185,32 @@ export function sanitizeForFrame(s: string): string {
|
|
|
185
185
|
return out;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/** Active SGR state after applying every SGR escape in `segment` to `prior`.
|
|
189
|
+
* Pragmatic model (matches wrap-ansi): a full reset (`\x1b[0m` / `\x1b[m`) clears
|
|
190
|
+
* the open set; any other SGR is appended. Good enough for the fg/bg/bold coloring
|
|
191
|
+
* a TUI box actually uses; it does not model selective resets (e.g. `\x1b[22m`). */
|
|
192
|
+
function sgrStateAfter(prior: string, segment: string): string {
|
|
193
|
+
if (!segment.includes("\x1b")) return prior;
|
|
194
|
+
let state = prior;
|
|
195
|
+
const re = /\x1b\[[0-9;]*m/g;
|
|
196
|
+
let m: RegExpExecArray | null;
|
|
197
|
+
while ((m = re.exec(segment))) {
|
|
198
|
+
state = m[0] === "\x1b[0m" || m[0] === "\x1b[m" ? "" : state + m[0];
|
|
199
|
+
}
|
|
200
|
+
return state;
|
|
201
|
+
}
|
|
202
|
+
|
|
188
203
|
/**
|
|
189
204
|
* Hard-wrap text to `cols` display columns, breaking long words and preserving
|
|
190
|
-
* existing newlines. SGR-aware (escapes don't consume width)
|
|
191
|
-
*
|
|
205
|
+
* existing newlines. SGR-aware (escapes don't consume width) AND SGR-stateful:
|
|
206
|
+
* a color opened before a wrap point is RE-APPLIED at the start of each continuation
|
|
207
|
+
* line and CLOSED at its end, so a wrapped colored span stays colored on every row
|
|
208
|
+
* instead of losing its tint after the first line (and never bleeds into the padding
|
|
209
|
+
* or box border). Returns the wrapped lines.
|
|
192
210
|
*/
|
|
193
211
|
export function wrapTextWithAnsi(text: string, cols: number): string[] {
|
|
194
212
|
const width = Math.max(1, cols);
|
|
213
|
+
const RESET = "\x1b[0m";
|
|
195
214
|
const out: string[] = [];
|
|
196
215
|
for (const rawLine of text.split("\n")) {
|
|
197
216
|
if (visibleWidth(rawLine) <= width) {
|
|
@@ -199,16 +218,31 @@ export function wrapTextWithAnsi(text: string, cols: number): string[] {
|
|
|
199
218
|
continue;
|
|
200
219
|
}
|
|
201
220
|
let rest = rawLine;
|
|
221
|
+
let active = ""; // SGR open at the wrap boundary, carried to the next line
|
|
202
222
|
while (visibleWidth(rest) > width) {
|
|
203
223
|
const head = truncateToWidth(rest, width);
|
|
204
|
-
// Advance past exactly the consumed substring
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
224
|
+
// Advance past exactly the consumed substring. truncateToWidth may append a
|
|
225
|
+
// SYNTHETIC trailing reset (frame safety) that is NOT in `rest` — including it
|
|
226
|
+
// would over-advance and drop real chars. `rest.startsWith(head)` means the
|
|
227
|
+
// reset is genuinely part of the source; otherwise strip the synthetic one.
|
|
228
|
+
const consumed = rest.startsWith(head)
|
|
229
|
+
? head
|
|
230
|
+
: head.endsWith(RESET) && rest.startsWith(head.slice(0, -RESET.length))
|
|
231
|
+
? head.slice(0, -RESET.length)
|
|
232
|
+
: head;
|
|
233
|
+
let line = active + head;
|
|
234
|
+
const next = sgrStateAfter(active, consumed);
|
|
235
|
+
// Close any color still open at the line end so it cannot tint the padding/border.
|
|
236
|
+
if (next && !line.endsWith(RESET)) line += RESET;
|
|
237
|
+
out.push(line);
|
|
238
|
+
active = next;
|
|
209
239
|
rest = rest.slice(consumed.length);
|
|
210
240
|
}
|
|
211
|
-
if (rest.length > 0)
|
|
241
|
+
if (rest.length > 0) {
|
|
242
|
+
let line = active + rest;
|
|
243
|
+
if (active && !line.endsWith(RESET)) line += RESET;
|
|
244
|
+
out.push(line);
|
|
245
|
+
}
|
|
212
246
|
}
|
|
213
247
|
return out;
|
|
214
248
|
}
|